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