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.
This commit is contained in:
24Play-Mykyta-Slobodianiuk
2026-06-04 19:04:12 +03:00
parent 4216b451f4
commit 6d4e4e2800
9 changed files with 848 additions and 20 deletions
+126
View File
@@ -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<Crate>();
private static hitThisStrike = new Set<Crate>(); // 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); }
}
}
}
+264
View File
@@ -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);
}
}
+126
View File
@@ -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 = 10067%, S2 = 6634%, S3 = 331%, 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<n> 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()));
}
}
+203 -12
View File
@@ -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);
+1 -1
View File
@@ -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;
+95
View File
@@ -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<number, Trigger>();
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?.();
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

+5
View File
@@ -0,0 +1,5 @@
import { ConvertToBase64WhenRelease } from "@24tools/ads_common";
// URL дерев'яної іконки лута. У релізі інлайниться в base64 (як меші/звуки).
// Шлях відносно цього файлу.
export const woodIconUrl = ConvertToBase64WhenRelease("./Icon_Wood.webp");
+28 -7
View File
@@ -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();
};