diff --git a/src/controllers/CombatC.ts b/src/controllers/CombatC.ts new file mode 100644 index 0000000..cdc5648 --- /dev/null +++ b/src/controllers/CombatC.ts @@ -0,0 +1,126 @@ +import { UpdateController } from "@24tools/playable_template"; +import { Vector3 } from "three"; +import { PlayerC } from "./PlayerC"; +import { Crate, LootableC } from "./LootableC"; +import { Trigger } from "./TriggerC"; + +// Health removed from a crate per strike (the bat touches it during a swing). +const ATTACK_DAMAGE = 10; +// Bat-tip → crate horizontal distance under which the bat is "touching" it. +const HIT_DIST = 0.8; +// The swing only reaches crates within this asymmetric arc of the facing dir +// (negative = player's left, positive = right): left cut at 90°, right 130°. +const ARC_LEFT = (90 * Math.PI) / 180; +const ARC_RIGHT = (130 * Math.PI) / 180; + +const _tmp = new Vector3(); +const _center = new Vector3(); +const _fwd = new Vector3(); +const _pos = new Vector3(); +const _tip = new Vector3(); + +/** + * "Stand near crates → auto-attack" loop. + * + * Each crate has a Trigger zone (= which crates are in reach). While the player + * is stopped, the Loot swing plays and the player faces the crates' centre. + * The Loot clip has TWO strikes (left swing, then right). Per STRIKE, every + * crate the bat tip actually reaches takes one hit (front arc only) — so one + * swing damages all the crates it sweeps over, and a crate caught by both + * swings takes two sequential hits, rather than being hit at random times. + */ +export class CombatC { + private static inRange = new Set(); + private static hitThisStrike = new Set(); // crates already hit in the current strike + private static lastStrike = -1; + + static init() { + // A proximity trigger around every crate. Its size = the attack reach. + for (const crate of LootableC.crates) { + crate.root.getWorldPosition(_tmp); + crate.trigger = new Trigger( + { x: _tmp.x, y: _tmp.y + 0.5, z: _tmp.z }, + { x: 1.1, y: 1.0, z: 1.1 }, + { + onEnter: () => this.inRange.add(crate), + onExit: () => this.inRange.delete(crate), + } + ); + } + + UpdateController.Instance.onUpdate.addDelegate(() => this.update()); + } + + private static update() { + this.pruneBroken(); + + // Moving → stop at once. + if (PlayerC.isMoving()) { + PlayerC.setAttacking(false); + this.resetCycle(); + return; + } + // Nothing left in reach → let the current swing finish, then idle. + if (this.inRange.size === 0) { + PlayerC.finishAttack(); + this.resetCycle(); + return; + } + + // Face the centre of the crates in reach and keep swinging. + _center.set(0, 0, 0); + for (const c of this.inRange) { + c.root.getWorldPosition(_tmp); + _center.add(_tmp); + } + _center.divideScalar(this.inRange.size); + PlayerC.setAttacking(true, _center); + + this.applyBatContact(); + } + + // Per strike: damage every in-reach crate the bat tip reaches (front arc). + private static applyBatContact() { + // New strike → all crates can be hit once again. + const strike = PlayerC.getSwingCycle(); + if (strike !== this.lastStrike) { + this.hitThisStrike.clear(); + this.lastStrike = strike; + } + + const tip = PlayerC.getBatTip(_tip); + if (!tip) return; + PlayerC.getForward(_fwd); + PlayerC.getPosition(_pos); + const rx = _fwd.z, rz = -_fwd.x; // player's right + + for (const c of [...this.inRange]) { + if (c.broken || this.hitThisStrike.has(c)) continue; + c.root.getWorldPosition(_tmp); + + // Front-arc gate (the bat can't reach behind the player). + const dx = _tmp.x - _pos.x, dz = _tmp.z - _pos.z; + const len = Math.hypot(dx, dz) || 1; + const angle = Math.atan2((rx * dx + rz * dz) / len, (_fwd.x * dx + _fwd.z * dz) / len); + if (angle < -ARC_LEFT || angle > ARC_RIGHT) continue; + + // Bat tip actually reached this crate → hit it once this strike. + if (Math.hypot(tip.x - _tmp.x, tip.z - _tmp.z) <= HIT_DIST) { + this.hitThisStrike.add(c); + LootableC.damageCrate(c, ATTACK_DAMAGE); + } + } + this.pruneBroken(); + } + + private static resetCycle() { + this.hitThisStrike.clear(); + this.lastStrike = -1; + } + + private static pruneBroken() { + for (const c of this.inRange) { + if (c.broken) { this.inRange.delete(c); this.hitThisStrike.delete(c); } + } + } +} diff --git a/src/controllers/LootC.ts b/src/controllers/LootC.ts new file mode 100644 index 0000000..033a444 --- /dev/null +++ b/src/controllers/LootC.ts @@ -0,0 +1,264 @@ +import { Sprite, SpriteMaterial, Texture, TextureLoader, SRGBColorSpace, Vector3 } from "three"; +import * as TWEEN from "@tweenjs/tween.js"; +import { UpdateController, CameraC_internal } from "@24tools/playable_template"; +import { ThreeC } from "./ThreeC"; +import { TestSceneC } from "./TestSceneC"; // for groundY (ground level) +import { woodIconUrl } from "../resources/images/woodIcon"; + +// Tunables — tweak here +const PIECES_MIN = 3; // min pieces per drop +const PIECES_MAX = 6; // max pieces per drop +const SCATTER_MIN = 0.6; // near landing radius +const SCATTER_MAX = 1.4; // far landing radius (wider scatter) +const ANGLE_JITTER = 0.3; // fraction of the sector used for jitter (smaller = more even, fewer overlaps) +const PIECE_SIZE = 0.6; // size of the falling loot (bigger than UI → shrinks in flight) + +const ARC_HEIGHT = 1.2; // height of the first (main) flight — the tallest hop +const FLIGHT_MS = 520; // duration of the first flight +const BOUNCES = 2; // how many bounces after landing +const BOUNCE_HEIGHT = 0.4; // each bounce = this fraction of the previous height +const BOUNCE_TIME = 0.6; // each bounce is shorter in time +const BOUNCE_FORWARD = 0.5; // each bounce covers this fraction of the previous horizontal step +const FLIGHT_STRETCH = 0.35; // vertical stretch in flight (scaled by hop height) +const LAND_SQUASH = 0.6; // squash on the final landing +const LAND_POP_MS = 160; // duration of the final "pop" + +// Collect (#8): delay after landing before flying to the corner, UI icon size, etc. +const COLLECT_DELAY_MS = 100; // almost immediately after the bounces +const UI_ICON_SIZE = 28; // wood UI icon size (px) — smaller than loot on the ground, but not tiny +const UI_RIGHT = 16; // offset from the right edge (px) +const UI_TOP = 110; // offset from the top (px) — lower, like in the REF +const SHRINK_MS = 250; // shrink to UI size before the flight +const FLY_MS = 500; // duration of the flight to the corner +const BLINK_MS = 120; // ramp-up duration of the white flash (fade-out is longer) + +const _ndc = new Vector3(); +const _topV = new Vector3(); + +export class LootC { + static pieces: Sprite[] = []; + private static texture: Texture | null = null; + private static uiIcon: HTMLImageElement | null = null; + // Our own tween group. In tween.js v25 `new Tween(obj)` does NOT join the + // default group automatically, so we keep and update our own (else tweens freeze). + private static tweens = new TWEEN.Group(); + + static init() { + this.texture = new TextureLoader().load(woodIconUrl); + this.texture.colorSpace = SRGBColorSpace; // correct color + + // Wood UI icon in the top-right corner (HTML overlay). Loot flies into it. + const icon = document.createElement("img"); + icon.id = "wood-ui"; // stable id → UI/counter hooks onto it, LootC reads its position + icon.src = woodIconUrl; + icon.style.cssText = + `position:fixed; top:${UI_TOP}px; right:${UI_RIGHT}px;` + + `width:${UI_ICON_SIZE}px; height:${UI_ICON_SIZE}px;` + + `z-index:1001; pointer-events:none; transition:transform 0.12s ease-out;`; + document.body.appendChild(icon); + this.uiIcon = icon; + + // ⚠️ Key: pump our group every frame, otherwise the tweens don't advance. + UpdateController.Instance.onUpdate.addDelegate(() => this.tweens.update()); + } + + /** Spawn loot at a point. If count is omitted → random PIECES_MIN..PIECES_MAX. */ + static spawn(origin: Vector3, count?: number) { + const n = count ?? (PIECES_MIN + Math.floor(Math.random() * (PIECES_MAX - PIECES_MIN + 1))); + const slice = (Math.PI * 2) / n; // each piece gets its own sector of the circle + + for (let i = 0; i < n; i++) { + const piece = this.createPiece(); + piece.position.copy(origin); + + // Even sector + a little jitter → pieces spread out and don't clump. + const angle = i * slice + (Math.random() - 0.5) * slice * ANGLE_JITTER; + const dist = SCATTER_MIN + Math.random() * (SCATTER_MAX - SCATTER_MIN); + const landing = new Vector3( + origin.x + Math.cos(angle) * dist, + TestSceneC.groundY + PIECE_SIZE / 2, // sprite center above ground → its bottom touches the ground + origin.z + Math.sin(angle) * dist, + ); + + this.animatePiece(piece, origin.clone(), landing); + this.pieces.push(piece); + } + } + + /** Flat piece: a Sprite (billboard — always faces the camera) with the wood texture. */ + private static createPiece(): Sprite { + const mat = new SpriteMaterial({ map: this.texture, transparent: true }); + const piece = new Sprite(mat); + piece.scale.set(PIECE_SIZE, PIECE_SIZE, 1); + ThreeC.addToScene(piece); + return piece; + } + + /** + * Scatter: a tall first arc (stretched along its motion), then a few decaying + * bounces off the ground, and a final "pop" (squash → springs back to normal). + */ + private static animatePiece(piece: Sprite, from: Vector3, to: Vector3) { + const restY = to.y; // sprite center at rest (= groundY + PIECE_SIZE/2) + + // One "hop": parabola fx,fz→tx,tz peaking at peak; stretched by speed. + const hop = (fx: number, fz: number, tx: number, tz: number, peak: number, ms: number) => + new TWEEN.Tween({ t: 0 }, this.tweens) + .to({ t: 1 }, ms) + .easing(TWEEN.Easing.Linear.None) + .onUpdate(({ t }) => { + piece.position.x = fx + (tx - fx) * t; + piece.position.z = fz + (tz - fz) * t; + piece.position.y = restY + peak * 4 * t * (1 - t); // parabolic arc + // |1-2t|: fast on the way up/down → stretch; at the peak → normal. + // Scale the stretch by hop height (small bounces stretch less). + const s = 1 + FLIGHT_STRETCH * (peak / ARC_HEIGHT) * Math.abs(1 - 2 * t); + piece.scale.set(PIECE_SIZE / s, PIECE_SIZE * s, 1); + }); + + // Horizontal throw direction (target = the final resting spot). + const dx = to.x - from.x, dz = to.z - from.z; + const totalDist = Math.hypot(dx, dz) || 1e-4; + const dirX = dx / totalDist, dirZ = dz / totalDist; + + // Split the horizontal distance between the flight and the bounces (geometric + // decay) so the plank also moves forward on bounces, not just up; sum = totalDist. + const hops = BOUNCES + 1; + const series = (1 - Math.pow(BOUNCE_FORWARD, hops)) / (1 - BOUNCE_FORWARD); + let step = totalDist / series; + let cx = from.x, cz = from.z; + let peak = ARC_HEIGHT, ms = FLIGHT_MS; + let first: TWEEN.Tween<{ t: number }> | null = null; + let prev: TWEEN.Tween<{ t: number }> | null = null; + + for (let k = 0; k < hops; k++) { + const nx = cx + dirX * step, nz = cz + dirZ * step; + const h = hop(cx, cz, nx, nz, peak, ms); + if (!first) first = h; else prev!.chain(h); + prev = h; + cx = nx; cz = nz; + step *= BOUNCE_FORWARD; peak *= BOUNCE_HEIGHT; ms *= BOUNCE_TIME; + } + + // 3) final impact: sharp squash (bottom on the ground) → springs back to normal + const groundY = restY - PIECE_SIZE / 2; + const pop = new TWEEN.Tween({ k: 0 }, this.tweens) + .to({ k: 1 }, LAND_POP_MS) + .easing(TWEEN.Easing.Back.Out) + .onUpdate(({ k }) => { + const s = LAND_SQUASH + (1 - LAND_SQUASH) * k; // 0.6 → 1 (with a slight overshoot) + piece.scale.set(PIECE_SIZE / s, PIECE_SIZE * s, 1); + piece.position.y = groundY + (PIECE_SIZE * s) / 2; // bottom stays on the ground + }) + .onComplete(() => { + // After resting briefly → flies into the UI icon. + setTimeout(() => this.collect(piece), COLLECT_DELAY_MS); + }); + prev!.chain(pop); + + first!.start(); + } + + /** + * Collect (#8): project the piece into screen pixels, swap the 3D sprite for an + * HTML image of the same size, shrink it to the UI icon size and fly it to the + * top-right corner — sizes match there, so the "arrival" is seamless. + */ + private static collect(piece: Sprite) { + if (!this.pieces.includes(piece)) return; // already collected/removed + const cam = CameraC_internal.camera; + const canvas = document.querySelector("canvas"); + if (!cam || !canvas) return; + const rect = canvas.getBoundingClientRect(); + + // sprite center and top → screen pixels (for on-screen position and size) + const center = this.toScreen(piece.position, cam, rect); + _topV.copy(piece.position); _topV.y += piece.scale.y / 2; + const sizePx = Math.max(8, Math.abs(center.y - this.toScreen(_topV, cam, rect).y) * 2); + + // drop the 3D sprite, replace it with an HTML image at the same point/size + this.remove(piece); + + const flier = document.createElement("img"); + flier.src = woodIconUrl; + flier.style.cssText = + `position:fixed; left:0; top:0; width:${sizePx}px; height:${sizePx}px;` + + `z-index:1000; pointer-events:none; transform:translate(-50%,-50%); will-change:left,top,width,height;`; + document.body.appendChild(flier); + + const st = { x: center.x, y: center.y, size: sizePx }; + const apply = () => { + flier.style.left = `${st.x}px`; + flier.style.top = `${st.y}px`; + flier.style.width = `${st.size}px`; + flier.style.height = `${st.size}px`; + }; + apply(); + + const target = this.uiIconCenter(); + // 1) shrink to UI size in place → 2) fly to the corner + const shrink = new TWEEN.Tween(st, this.tweens) + .to({ size: UI_ICON_SIZE }, SHRINK_MS) + .easing(TWEEN.Easing.Quadratic.Out) + .onUpdate(apply); + const fly = new TWEEN.Tween(st, this.tweens) + .to({ x: target.x, y: target.y }, FLY_MS) + .easing(TWEEN.Easing.Quadratic.In) + .onUpdate(apply) + .onComplete(() => { flier.remove(); this.pulseUiIcon(); }); + shrink.chain(fly); + + // Smooth white blink before the flight: a white copy of the plank fades in + // and out on top ("collected" feedback), then the shrink + flight. + const flash = document.createElement("img"); + flash.src = woodIconUrl; + flash.style.cssText = flier.style.cssText; // same position/size + flash.style.filter = "brightness(0) invert(1)"; // solid white silhouette + flash.style.opacity = "0"; + flash.style.zIndex = "1002"; + document.body.appendChild(flash); + + const fl = { o: 0 }; + const setO = () => { flash.style.opacity = `${fl.o}`; }; + const flashIn = new TWEEN.Tween(fl, this.tweens) + .to({ o: 1 }, BLINK_MS) + .easing(TWEEN.Easing.Quadratic.Out) + .onUpdate(setO); + const flashOut = new TWEEN.Tween(fl, this.tweens) + .to({ o: 0 }, BLINK_MS * 1.6) + .easing(TWEEN.Easing.Quadratic.In) + .onUpdate(setO) + .onComplete(() => { flash.remove(); shrink.start(); }); + flashIn.chain(flashOut); + flashIn.start(); + } + + /** World point → screen pixels (accounting for the canvas position on the page). */ + private static toScreen(world: Vector3, cam: any, rect: DOMRect) { + _ndc.copy(world).project(cam); + return { + x: rect.left + (_ndc.x * 0.5 + 0.5) * rect.width, + y: rect.top + (-_ndc.y * 0.5 + 0.5) * rect.height, + }; + } + + private static uiIconCenter() { + const r = this.uiIcon?.getBoundingClientRect(); + return r ? { x: r.left + r.width / 2, y: r.top + r.height / 2 } : { x: 0, y: 0 }; + } + + /** A small "pulse" of the UI icon when a piece arrives. */ + private static pulseUiIcon() { + const el = this.uiIcon; + if (!el) return; + el.style.transform = "scale(1.25)"; + setTimeout(() => { if (this.uiIcon) this.uiIcon.style.transform = "scale(1)"; }, 120); + } + + /** Remove a piece from the scene (used by #8 — after collecting). */ + static remove(piece: Sprite) { + ThreeC.removeFromScene(piece); + const i = this.pieces.indexOf(piece); + if (i >= 0) this.pieces.splice(i, 1); + } +} diff --git a/src/controllers/LootableC.ts b/src/controllers/LootableC.ts new file mode 100644 index 0000000..76996a0 --- /dev/null +++ b/src/controllers/LootableC.ts @@ -0,0 +1,126 @@ +import { Object3D, Vector3 } from "three"; +import { PhysicsBody, PhysicsLayer } from "./PhysicsC"; +import { Trigger } from "./TriggerC"; +import { LootC } from "./LootC"; + +// A full crate's health. There are 3 damage levels (S1/S2/S3) splitting it +// evenly: S1 = 100–67%, S2 = 66–34%, S3 = 33–1%, broken at 0. A crate that +// ships already damaged (no S1 node, etc.) starts at the matching lower health. +const CRATE_MAX_HEALTH = 100; +const LEVELS = 3; + +/** One breakable crate. Damage states are indexed by level: 0=S1, 1=S2, 2=S3. */ +export interface Crate { + root: Object3D; + statesByLevel: (Object3D | null)[]; // length 3; null where that state isn't authored + startLevel: number; // lowest authored state = how damaged it starts + level: number; // currently shown level + collider: PhysicsBody; + trigger: Trigger | null; + health: number; + maxHealth: number; + broken: boolean; +} + +/** + * Sets up the crates baked into the map's "Lootable" group. + * + * Each Wooden_Box ships its damage states (S1/S2/S3) all visible at once, but + * NOT every crate has all three — some start at S2 or S3, i.e. pre-damaged. We + * read the lowest authored state, show only it, set health to the matching + * percentage, and turn the collider proxy into a STATIC cannon box. + */ +export class LootableC { + static crates: Crate[] = []; + + static init(lootableGroup: Object3D | null) { + if (!lootableGroup) { + console.warn("[Lootable] group not found"); + return; + } + + lootableGroup.visible = true; + lootableGroup.updateWorldMatrix(true, true); // collider world positions must be current + + for (const crate of lootableGroup.children) { + // Map each authored damage state to its level via the _S suffix. + const statesGroup = crate.children.find(c => c.name.includes("_States")); + const statesByLevel: (Object3D | null)[] = [null, null, null]; + if (statesGroup) { + for (const s of statesGroup.children) { + const m = s.name.match(/_S(\d)$/); + if (m) { + const lvl = parseInt(m[1], 10) - 1; // S1→0, S2→1, S3→2 + if (lvl >= 0 && lvl < LEVELS) statesByLevel[lvl] = s; + } + } + } + + // Start at the lowest authored state (most intact one present). + let startLevel = statesByLevel.findIndex(s => s !== null); + if (startLevel < 0) startLevel = 0; + statesByLevel.forEach((s, lvl) => { if (s) s.visible = lvl === startLevel; }); + + // Health for that starting level (full crate = 100, S2 ≈ 67, S3 ≈ 33). + const health = CRATE_MAX_HEALTH * (LEVELS - startLevel) / LEVELS; + + // The per-crate collider proxy → static box, then hide it (physics only). + const proxy = crate.children.find(c => c.name.startsWith("BoxCollider")); + if (!proxy) continue; + + const collider = new PhysicsBody( + proxy, + false, // not a trigger — it's solid + 0, // mass 0 → static + PhysicsLayer.Wall, // same layer as walls, so the player collides with it + PhysicsLayer.Player + ); + proxy.visible = false; + + this.crates.push({ + root: crate, statesByLevel, startLevel, level: startLevel, + collider, trigger: null, health, maxHealth: CRATE_MAX_HEALTH, broken: false, + }); + } + + console.log(`[Lootable] crates built: ${this.crates.length}`); + } + + /** Subtract health; switch to the matching damage state, or break at 0. */ + static damageCrate(crate: Crate, amount: number) { + if (crate.broken) return; + + crate.health -= amount; + if (crate.health <= 0) { + this.breakCrate(crate); + return; + } + + // Map remaining health to a level, never below where this crate started. + let level = Math.floor((1 - crate.health / crate.maxHealth) * LEVELS); + level = Math.max(crate.startLevel, Math.min(LEVELS - 1, level)); + if (level !== crate.level) { + crate.level = level; + crate.statesByLevel.forEach((s, lvl) => { if (s) s.visible = lvl === level; }); + // Loot drops on every state change, not only on destruction. + LootC.spawn(crate.root.getWorldPosition(new Vector3())); + } + } + + /** Crate destroyed: hide it and remove its physics + trigger from the world. */ + static breakCrate(crate: Crate) { + if (crate.broken) return; + crate.broken = true; + + crate.statesByLevel.forEach(s => { if (s) s.visible = false; }); + crate.collider.destroy(); + crate.trigger?.destroy(); + crate.trigger = null; + + // TODO #7/#8: spawn wood loot at crate.root world position, scatter with a + // bounce, then tween it into the resource counter. + console.log("[Lootable] crate broken"); + + LootC.spawn(crate.root.getWorldPosition(new Vector3())); + } +} diff --git a/src/controllers/PlayerC.ts b/src/controllers/PlayerC.ts index 47b48d3..d9c1c03 100644 --- a/src/controllers/PlayerC.ts +++ b/src/controllers/PlayerC.ts @@ -1,5 +1,5 @@ import { CameraC_internal, JoystickC, ThreeC_internal, UpdateController } from "@24tools/playable_template"; -import { AnimationAction, AnimationMixer, Object3D, Vector3 } from "three"; +import { AnimationAction, AnimationMixer, Mesh, Object3D, Raycaster, Vector3 } from "three"; import { Body } from "cannon-es"; import { PhysicsBody, PhysicsLayer } from "./PhysicsC"; @@ -13,6 +13,15 @@ enum MoveState { Idle = "idle", Walk = "walk", Run = "run" } const CHAR_RADIUS = 0.35; +// Extra time the bat keeps swinging after the hit that breaks the last crate, +// so the strike completes visually before returning to idle. +const ATTACK_FOLLOW_THROUGH = 0.25; + +// How fast the feet ease toward the sampled ground height (per second). +// Higher = snappier / hugs the surface tighter; lower = smoother but lags more +// on slopes and curbs. This is what kills the jolt when crossing a curb. +const GROUND_SMOOTH = 12; + const _inputTarget = new Vector3(); export class PlayerC { @@ -29,7 +38,33 @@ export class PlayerC { private static state = MoveState.Idle; private static currentAction: AnimationAction | null = null; - // Desired planar velocity (x/z). The body's y is left to gravity. + // Attack state (auto-attacking a nearby crate). + private static attacking = false; + private static attackTarget = new Vector3(); // world point to face while attacking + private static hasAttackTarget = false; + private static attackAction: AnimationAction | null = null; // the "Loot" clip used as the swing + + // Cached local ends of the bat mesh (computed once) → its swinging tip in + // world space. CombatC reads getBatTip() to damage a crate only when the bat + // actually reaches it (geometric contact, no animation-time markers). + private static _batEndA: Vector3 | null = null; + private static _batEndB: Vector3 | null = null; + private static _tipA = new Vector3(); + private static _tipB = new Vector3(); + + // Swing counter: the Loot clip contains TWO strikes (a left swing then a + // right swing), so this bumps TWICE per loop — at the half-way point and at + // the wrap. CombatC uses it to damage every touched crate once per strike, + // so a crate the bat sweeps over in both swings takes two sequential hits. + private static swingCycle = 0; + private static _prevStrike = 0; + + // When the killing blow lands we don't cut the swing at the impact frame — + // we let the bat follow through for this long, then return to idle. + private static finishing = false; + private static finishTimer = 0; + + // Desired planar velocity (x/z). The body's y is driven by ground-follow. static velocity = new Vector3(); private static inputDir = new Vector3(); @@ -37,8 +72,20 @@ export class PlayerC { private static _camRight = new Vector3(); private static _worldUp = new Vector3(0, 1, 0); - static init(mesh: Object3D) { + // Ground following — the map isn't flat (road sits above the sand), so a + // downward ray finds the surface under the player each frame. + private static groundObjects: Object3D[] = []; + private static groundY = 0; // fallback surface (flat sand) if the ray misses + private static _currentSurfaceY = 0; // smoothed feet height (eases toward the sampled ground) + private static _downRay = new Raycaster(); + private static _rayFrom = new Vector3(); + private static _rayDown = new Vector3(0, -1, 0); + + static init(mesh: Object3D, groundObjects: Object3D[] = [], groundY = 0) { this.mesh = mesh; + this.groundObjects = groundObjects; + this.groundY = groundY; + this._currentSurfaceY = groundY; this.createBody(); this.setupWeapon(); this.setupAnimations(); @@ -54,6 +101,45 @@ export class PlayerC { if (this.batOnBack) this.batOnBack.visible = !inHand; } + /** + * Enter/leave the attack state. While attacking, the bat is in the hand, the + * "Loot" swing animation loops, and the player faces `targetPos`. The + * locomotion animation state machine is suspended until this is turned off. + */ + static setAttacking(active: boolean, targetPos: Vector3 | null = null) { + if (active && targetPos) { this.attackTarget.copy(targetPos); this.hasAttackTarget = true; } + if (this.attacking === active) return; + this.attacking = active; + this.finishing = false; // any real start/stop cancels a pending follow-through + this.setBatInHand(active); + + if (active && this.attackAction) { + this.currentAction?.fadeOut(0.15); + this.attackAction.reset().fadeIn(0.15).play(); + this.currentAction = this.attackAction; + this._prevStrike = 0; + this.swingCycle++; // new attack = fresh strike + } else if (!active) { + this.hasAttackTarget = false; + this.attackAction?.fadeOut(0.15); + const idle = this.findAction(MoveState.Idle); + if (idle) { idle.reset().fadeIn(0.15).play(); this.currentAction = idle; } + this.state = MoveState.Idle; // let the locomotion machine take over again + } + } + + /** + * Stop attacking, but only after the current swing follows through (so a + * crate broken on the first swing still shows the bat completing the strike). + * Used when there are no crates left in reach; for "player walked away" use + * setAttacking(false), which stops at once. + */ + static finishAttack() { + if (!this.attacking || this.finishing) return; + this.finishing = true; + this.finishTimer = ATTACK_FOLLOW_THROUGH; + } + // ── Private ──────────────────────────────────────────────────────────────── private static setupWeapon() { @@ -62,6 +148,18 @@ export class PlayerC { this.setBatInHand(false); // normal state: bat rests on the back } + // Cast a ray straight down from above the player and return the Y of the + // topmost surface hit. Falls back to the flat sand level if nothing is hit. + private static sampleGroundY(): number { + if (this.groundObjects.length) { + this._rayFrom.set(this.body.position.x, this.body.position.y + 5, this.body.position.z); + this._downRay.set(this._rayFrom, this._rayDown); + const hits = this._downRay.intersectObjects(this.groundObjects, true); + if (hits.length) return hits[0].point.y; + } + return this.groundY; + } + private static createBody() { // Sphere collider (PhysicsLayer.Player makes PhysicsBody use a Sphere shape). const pb = new PhysicsBody( @@ -79,11 +177,35 @@ export class PlayerC { this.body.updateMassProperties(); this.body.linearDamping = 0; // we set planar velocity explicitly every frame + // Collide with solids (walls + crates, all on the Wall layer) AND register + // overlaps with trigger zones so resource/gather triggers fire. + this.body.collisionFilterMask = PhysicsLayer.Wall | PhysicsLayer.Trigger; + // Rest the sphere on the floor at the spawn point. this.body.position.set(this.mesh.position.x, CHAR_RADIUS + 0.05, this.mesh.position.z); this.body.velocity.set(0, 0, 0); } + /** The player's cannon body — used by TriggerC to know who entered a zone. */ + static getBody(): Body { + return this.body; + } + + /** True while the player is actively moving (used to gate auto-attacks). */ + static isMoving(): boolean { + return this.velocity.length() > 0.05; + } + + /** Unit vector the player currently faces (where the bat swings). */ + static getForward(out: Vector3): Vector3 { + return out.set(Math.sin(this.mesh.rotation.y), 0, Math.cos(this.mesh.rotation.y)); + } + + /** World position of the player's body (x/z used for hit-direction checks). */ + static getPosition(out: Vector3): Vector3 { + return out.set(this.body.position.x, this.body.position.y, this.body.position.z); + } + private static setupAnimations() { const gltf = ThreeC_internal.getMesh("character"); this.mixer = new AnimationMixer(this.mesh); @@ -94,6 +216,10 @@ export class PlayerC { this.actions.set(clip.name, this.mixer.clipAction(clip)); }); } + // Cache the "Loot" clip — reused as the crate-breaking swing. + for (const [name, action] of this.actions) { + if (name.toLowerCase().includes("loot")) { this.attackAction = action; break; } + } // Play idle directly — transitionTo guards same-state calls so it would no-op here const idleAction = this.findAction(MoveState.Idle); if (idleAction) { @@ -147,20 +273,45 @@ export class PlayerC { this.body.velocity.x = this.velocity.x; this.body.velocity.z = this.velocity.z; + // Follow whatever surface is directly below (sand, raised road, etc.). + // cannon still resolves x/z against the walls; we drive y ourselves. Ease + // the feet toward the sampled height instead of snapping, so crossing a + // curb is a smooth step-up rather than a one-frame jolt. + const targetSurfaceY = this.sampleGroundY(); + this._currentSurfaceY += (targetSurfaceY - this._currentSurfaceY) * Math.min(1, GROUND_SMOOTH * delta); + + this.body.position.y = this._currentSurfaceY + CHAR_RADIUS; + this.body.velocity.y = 0; + // Sync the mesh to the body. The body origin is the sphere centre, so the - // mesh (origin at the feet) is dropped by the radius. - this.mesh.position.set( - this.body.position.x, - this.body.position.y - CHAR_RADIUS, - this.body.position.z, - ); + // mesh (origin at the feet) sits at the surface itself. + this.mesh.position.set(this.body.position.x, this._currentSurfaceY, this.body.position.z); const speed = this.velocity.length(); + // While attacking, face the crate and let the Loot animation run — the + // locomotion state machine below is suspended so it can't override it. + if (this.attacking) { + if (this.hasAttackTarget) this.faceTowards(this.attackTarget.x, this.attackTarget.z, delta); + // Split the Loot clip into its two strikes (first half = left swing, + // second half = right swing). Bump the counter on each → two hits/loop. + const a = this.attackAction; + if (a) { + const dur = a.getClip().duration; + const phase = dur > 0 ? (a.time % dur) / dur : 0; // 0..1 within the swing + const strike = phase < 0.5 ? 0 : 1; + if (strike !== this._prevStrike) this.swingCycle++; + this._prevStrike = strike; + } + if (this.finishing) { + this.finishTimer -= delta; + if (this.finishTimer <= 0) this.setAttacking(false); + } + return; + } + if (speed > 0.05) { - const targetAngle = Math.atan2(this.velocity.x, this.velocity.z); - const diff = ((targetAngle - this.mesh.rotation.y + Math.PI * 3) % (Math.PI * 2)) - Math.PI; - this.mesh.rotation.y += diff * Math.min(1, this.rotateSpeed * delta); + this.faceTowards(this.mesh.position.x + this.velocity.x, this.mesh.position.z + this.velocity.z, delta); } // Animation state machine @@ -180,6 +331,46 @@ export class PlayerC { } } + // World position of the bat's swinging tip (the end farther from the body), + // or null when the bat isn't in hand. CombatC uses this to damage a crate the + // instant the bat actually reaches it. + static getBatTip(out: Vector3): Vector3 | null { + const bat = this.batInHand as Mesh | null; + if (!bat || !bat.visible || !bat.geometry) return null; + + if (!this._batEndA || !this._batEndB) { + if (!bat.geometry.boundingBox) bat.geometry.computeBoundingBox(); + const bb = bat.geometry.boundingBox!; + const cx = (bb.min.x + bb.max.x) / 2, cy = (bb.min.y + bb.max.y) / 2, cz = (bb.min.z + bb.max.z) / 2; + const sx = bb.max.x - bb.min.x, sy = bb.max.y - bb.min.y, sz = bb.max.z - bb.min.z; + if (sz >= sx && sz >= sy) { this._batEndA = new Vector3(cx, cy, bb.min.z); this._batEndB = new Vector3(cx, cy, bb.max.z); } + else if (sx >= sy) { this._batEndA = new Vector3(bb.min.x, cy, cz); this._batEndB = new Vector3(bb.max.x, cy, cz); } + else { this._batEndA = new Vector3(cx, bb.min.y, cz); this._batEndB = new Vector3(cx, bb.max.y, cz); } + } + + bat.updateWorldMatrix(true, false); + this._tipA.copy(this._batEndA).applyMatrix4(bat.matrixWorld); + this._tipB.copy(this._batEndB).applyMatrix4(bat.matrixWorld); + const farther = this._tipA.distanceToSquared(this.mesh.position) >= this._tipB.distanceToSquared(this.mesh.position) + ? this._tipA : this._tipB; + return out.copy(farther); + } + + /** Index of the current strike (bumps twice per Loot loop: left then right swing). */ + static getSwingCycle(): number { + return this.swingCycle; + } + + // Smoothly rotate the mesh's Y so it faces the given world x/z point. + private static faceTowards(x: number, z: number, delta: number) { + const dx = x - this.mesh.position.x; + const dz = z - this.mesh.position.z; + if (dx * dx + dz * dz < 1e-4) return; + const targetAngle = Math.atan2(dx, dz); + const diff = ((targetAngle - this.mesh.rotation.y + Math.PI * 3) % (Math.PI * 2)) - Math.PI; + this.mesh.rotation.y += diff * Math.min(1, this.rotateSpeed * delta); + } + private static findAction(state: MoveState): AnimationAction | null { for (const name of ANIM_NAMES[state]) { const action = this.actions.get(name); diff --git a/src/controllers/TestSceneC.ts b/src/controllers/TestSceneC.ts index 95589d2..be919a7 100644 --- a/src/controllers/TestSceneC.ts +++ b/src/controllers/TestSceneC.ts @@ -54,7 +54,7 @@ export class TestSceneC { // …then hide the proxies and the groups we are not activating yet. if (this.colliderGroup) this.colliderGroup.visible = false; // physics-only, never rendered - if (this.lootableGroup) this.lootableGroup.visible = false; // interactive — enabled later, per crate + // Lootable crates are set up by LootableC (one state shown + colliders). if (uiGroup) uiGroup.visible = false; // playable UI is HTML/CSS, not in-world if (uiWood) uiWood.visible = false; diff --git a/src/controllers/TriggerC.ts b/src/controllers/TriggerC.ts new file mode 100644 index 0000000..a6f3120 --- /dev/null +++ b/src/controllers/TriggerC.ts @@ -0,0 +1,95 @@ +import { Physics_internal } from "@24tools/playable_template"; +import { Body, Box, Vec3 } from "cannon-es"; +import { PhysicsLayer } from "./PhysicsC"; + +/** + * A single invisible trigger zone. + * + * It is a cannon body flagged `isTrigger`: the physics world still detects when + * something overlaps it (so we get events), but it produces NO push — the + * player walks straight through. Use it for "player entered this area" logic + * (resource pickups, gather zones, etc.). + */ +export class Trigger { + readonly body: Body; + onEnter?: () => void; + onExit?: () => void; + + constructor( + center: { x: number; y: number; z: number }, + halfExtents: { x: number; y: number; z: number }, + handlers: { onEnter?: () => void; onExit?: () => void } = {} + ) { + this.onEnter = handlers.onEnter; + this.onExit = handlers.onExit; + + this.body = new Body({ + isTrigger: true, + type: Body.STATIC, + shape: new Box(new Vec3(halfExtents.x, halfExtents.y, halfExtents.z)), + collisionFilterGroup: PhysicsLayer.Trigger, + collisionFilterMask: PhysicsLayer.Player, // only reacts to the player + }); + this.body.position.set(center.x, center.y, center.z); + + Physics_internal.physicsWorld?.addBody(this.body); + TriggerC.register(this); + } + + destroy() { + TriggerC.unregister(this); + Physics_internal.physicsWorld?.removeBody(this.body); + } +} + +/** + * Central trigger dispatcher. + * + * Instead of attaching a `collide` handler to every body, we listen ONCE to the + * world's `beginContact` / `endContact` events. Each event gives us the two + * bodies that started/stopped touching; if one of them is the player and the + * other is a registered trigger, we fire that trigger's enter/exit callback. + * `endContact` is what makes a clean "player left the zone" (stop) event easy. + */ +export class TriggerC { + private static byBodyId = new Map(); + private static playerBody: Body | null = null; + private static started = false; + + static init(playerBody: Body) { + this.playerBody = playerBody; + if (this.started) return; + + const world = Physics_internal.physicsWorld; + if (!world) return; + world.addEventListener("beginContact", this.onBegin); + world.addEventListener("endContact", this.onEnd); + this.started = true; + } + + static register(t: Trigger) { this.byBodyId.set(t.body.id, t); } + static unregister(t: Trigger) { this.byBodyId.delete(t.body.id); } + + private static onBegin = (e: any) => this.dispatch(e.bodyA, e.bodyB, true); + private static onEnd = (e: any) => this.dispatch(e.bodyA, e.bodyB, false); + + private static dispatch(a: Body, b: Body, enter: boolean) { + // When a trigger body is removed from the world (e.g. a crate breaks) + // cannon emits an endContact whose other body can be undefined. Guard it, + // otherwise the throw aborts the physics step and FREEZES the whole world. + if (!this.playerBody || !a || !b) return; + + // Exactly one of the two bodies must be the player; the other must be a + // registered trigger — otherwise this contact isn't ours. + let other: Body | null = null; + if (a === this.playerBody) other = b; + else if (b === this.playerBody) other = a; + else return; + + const trigger = other ? this.byBodyId.get(other.id) : undefined; + if (!trigger) return; + + if (enter) trigger.onEnter?.(); + else trigger.onExit?.(); + } +} diff --git a/src/resources/images/Icon_Wood.webp b/src/resources/images/Icon_Wood.webp new file mode 100644 index 0000000..f30a357 Binary files /dev/null and b/src/resources/images/Icon_Wood.webp differ diff --git a/src/resources/images/woodIcon.ts b/src/resources/images/woodIcon.ts new file mode 100644 index 0000000..a0c7bbd --- /dev/null +++ b/src/resources/images/woodIcon.ts @@ -0,0 +1,5 @@ +import { ConvertToBase64WhenRelease } from "@24tools/ads_common"; + +// URL дерев'яної іконки лута. У релізі інлайниться в base64 (як меші/звуки). +// Шлях відносно цього файлу. +export const woodIconUrl = ConvertToBase64WhenRelease("./Icon_Wood.webp"); diff --git a/src/templateConfig/afterResourcesLoadedCb.ts b/src/templateConfig/afterResourcesLoadedCb.ts index 246ba7b..810f75e 100644 --- a/src/templateConfig/afterResourcesLoadedCb.ts +++ b/src/templateConfig/afterResourcesLoadedCb.ts @@ -1,7 +1,11 @@ import { TestSceneC } from "../controllers/TestSceneC"; import { FollowCameraC } from "../controllers/FollowCameraC"; import { PlayerC } from "../controllers/PlayerC"; +import { LootableC } from "../controllers/LootableC"; +import { TriggerC } from "../controllers/TriggerC"; +import { CombatC } from "../controllers/CombatC"; import { JoystickC, SoundC, Template } from "@24tools/playable_template"; +import { LootC } from "../controllers/LootC"; export const afterResourcesLoadedCb: (() => void) | undefined = async () => { TestSceneC.init(); @@ -22,16 +26,33 @@ export const afterResourcesLoadedCb: (() => void) | undefined = async () => { }); } - // Player is now a cannon body — collisions with the floor and boundary - // walls are handled by the physics world (no more raycasting). - PlayerC.init(TestSceneC.characterObject); + // Player is a cannon body (walls handled by the physics world). Vertical + // placement follows the ground via a downward ray against the environment, + // so the character walks correctly on both the sand and the raised road. + PlayerC.init( + TestSceneC.characterObject, + TestSceneC.environment ? [TestSceneC.environment] : [], + TestSceneC.groundY, + ); FollowCameraC.init(TestSceneC.characterObject); - if (import.meta.env.DEV) { - const { CameraDebugUI } = await import("../controllers/CameraDebugUI"); - CameraDebugUI.init(); - } + // Crates: show one state + give each a solid collider. + LootableC.init(TestSceneC.lootableGroup); + + // Trigger system: start listening for player-vs-trigger overlaps. + TriggerC.init(PlayerC.getBody()); + + // Combat: proximity trigger around each crate → auto-attack & break it. + CombatC.init(); + + // Loot: spawn loot pieces when a crate breaks. + LootC.init(); + + // if (import.meta.env.DEV) { + // const { CameraDebugUI } = await import("../controllers/CameraDebugUI"); + // CameraDebugUI.init(); + // } Template.disableLoader(); };