From 6d4e4e280012304d8b8084468f1bc39312c19453 Mon Sep 17 00:00:00 2001 From: 24Play-Mykyta-Slobodianiuk Date: Thu, 4 Jun 2026 19:04:12 +0300 Subject: [PATCH] feat: Implement loot system with crates and triggers - Added LootC class for spawning loot pieces with animations and UI integration. - Introduced LootableC class to manage breakable crates with health states and loot drops. - Enhanced PlayerC to support attacking mechanics and ground following. - Created TriggerC for managing trigger zones and player interactions. - Added wood icon resource for loot representation. - Updated afterResourcesLoadedCb to initialize new loot and trigger systems. --- src/controllers/CombatC.ts | 126 +++++++++ src/controllers/LootC.ts | 264 +++++++++++++++++++ src/controllers/LootableC.ts | 126 +++++++++ src/controllers/PlayerC.ts | 215 ++++++++++++++- src/controllers/TestSceneC.ts | 2 +- src/controllers/TriggerC.ts | 95 +++++++ src/resources/images/Icon_Wood.webp | Bin 0 -> 9828 bytes src/resources/images/woodIcon.ts | 5 + src/templateConfig/afterResourcesLoadedCb.ts | 35 ++- 9 files changed, 848 insertions(+), 20 deletions(-) create mode 100644 src/controllers/CombatC.ts create mode 100644 src/controllers/LootC.ts create mode 100644 src/controllers/LootableC.ts create mode 100644 src/controllers/TriggerC.ts create mode 100644 src/resources/images/Icon_Wood.webp create mode 100644 src/resources/images/woodIcon.ts 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 0000000000000000000000000000000000000000..f30a357ecd3ed6a41b0e7ee7b2c0137018a3cb81 GIT binary patch literal 9828 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 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(); };