-Add UI (Health bars, Resources UI, HUD Interface, Weapon U, Zoombie Invasion progress Indicator)

-Refactor code structure for improved readability and maintainability
This commit is contained in:
24Play-Mykyta-Slobodianiuk
2026-06-08 18:33:28 +03:00
parent d6fc6be717
commit 78d85c3799
31 changed files with 6308 additions and 624 deletions
+3 -2
View File
@@ -21,13 +21,14 @@
"cannon-es-debugger": "^1.0.0", "cannon-es-debugger": "^1.0.0",
"howler": "^2.2.4", "howler": "^2.2.4",
"nipplejs": "^1.0.4", "nipplejs": "^1.0.4",
"three": "^0.184.0" "three": "^0.184.0",
"three.quarks": "^0.16.0"
}, },
"devDependencies": { "devDependencies": {
"@types/howler": "^2.2.13", "@types/howler": "^2.2.13",
"@types/three": "^0.184.1", "@types/three": "^0.184.1",
"lil-gui": "^0.21.0",
"rollup": "^4.61.0", "rollup": "^4.61.0",
"sass": "^1.100.0",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^6.4.3" "vite": "^6.4.3"
} }
+1 -1
View File
@@ -5,7 +5,7 @@ export class CameraC extends CameraC_internal {
static setCamera(portraitOrientation: boolean) { static setCamera(portraitOrientation: boolean) {
const CATEGORY = Template.getCategory("global"); const CATEGORY = Template.getCategory("global");
if (this.camera !== null) { if (this.camera !== null) {
let position = portraitOrientation const position = portraitOrientation
? Helper.returnVectorCamera(CATEGORY["camera_position_p"] as number[]) ? Helper.returnVectorCamera(CATEGORY["camera_position_p"] as number[])
: Helper.returnVectorCamera(CATEGORY["camera_position_l"] as number[]); : Helper.returnVectorCamera(CATEGORY["camera_position_l"] as number[]);
const rotation = portraitOrientation const rotation = portraitOrientation
-107
View File
@@ -1,107 +0,0 @@
import GUI from "lil-gui";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { CameraC_internal, Delegate, UpdateController } from "@24tools/playable_template";
import { FollowCameraC } from "./FollowCameraC";
import { TestSceneC } from "./TestSceneC";
// Transparent overlay for OrbitControls — sits above canvas but below GUI panel.
// Allows the GUI to keep receiving clicks while OrbitControls is active.
function createOrbitOverlay(): HTMLDivElement {
const div = document.createElement("div");
div.style.cssText = `
position: fixed; inset: 0;
z-index: 9000;
touch-action: auto;
cursor: grab;
`;
return div;
}
export class CameraDebugUI {
static init() {
const gui = new GUI({ title: "📷 Camera Debug", width: 280 });
gui.domElement.style.zIndex = "99999";
gui.domElement.style.position = "fixed";
const offsetFolder = gui.addFolder("Offset — camera position (from config)");
offsetFolder.add(FollowCameraC.offset, "x", -10, 10, 0.1).name("X (left / right)");
offsetFolder.add(FollowCameraC.offset, "y", -10, 10, 0.1).name("Y (height)");
offsetFolder.add(FollowCameraC.offset, "z", -10, 10, 0.1).name("Z (distance behind)");
gui.add(FollowCameraC, "lerpSpeed", 1, 15, 0.5).name("Follow smoothness [115]");
const charFolder = gui.addFolder("Character");
const scaleProxy = { scale: TestSceneC.characterObject.scale.x };
charFolder
.add(scaleProxy, "scale", 0.1, 3, 0.01)
.name("Scale [0.13]")
.onChange((v: number) => TestSceneC.characterObject.scale.setScalar(v));
charFolder.add(TestSceneC.characterObject.position, "x", -50, 50, 0.1).name("Pos X");
charFolder.add(TestSceneC.characterObject.position, "y", -10, 10, 0.1).name("Pos Y");
charFolder.add(TestSceneC.characterObject.position, "z", -50, 50, 0.1).name("Pos Z");
let orbitControls: OrbitControls | null = null;
let orbitDelegate: Delegate<number> | null = null;
let orbitOverlay: HTMLDivElement | null = null;
const orbitProxy = { enabled: false };
gui
.add(orbitProxy, "enabled")
.name("🔭 Free camera (OrbitControls)")
.onChange((enabled: boolean) => {
if (enabled) {
FollowCameraC.paused = true;
orbitOverlay = createOrbitOverlay();
document.body.appendChild(orbitOverlay);
orbitControls = new OrbitControls(CameraC_internal.camera!, orbitOverlay);
orbitControls.update();
orbitDelegate = UpdateController.Instance.onUpdate.addDelegate(() => {
orbitControls?.update();
});
} else {
if (orbitDelegate !== null) {
UpdateController.Instance.onUpdate.removeListeners(orbitDelegate);
orbitDelegate = null;
}
orbitControls?.dispose();
orbitControls = null;
orbitOverlay?.remove();
orbitOverlay = null;
FollowCameraC.paused = false;
FollowCameraC.snapToTarget();
}
});
gui
.add(
{
log: () => {
const cam = CameraC_internal.camera!;
const s = TestSceneC.characterObject.scale.x;
const p = cam.position;
// Convert radians to degrees for config
const rx = Math.round(cam.rotation.x * (180 / Math.PI));
const ry = Math.round(cam.rotation.y * (180 / Math.PI));
const rz = Math.round(cam.rotation.z * (180 / Math.PI));
console.log(
`%c[CameraDebug] Paste into globalSettings.ts:
camera_position: x=${p.x.toFixed(2)}, y=${p.y.toFixed(2)}, z=${p.z.toFixed(2)}
camera_rotation: x=${rx}°, y=${ry}°, z=${rz}°
charScale: ${s.toFixed(2)}`,
"color: #7cf; font-weight: bold"
);
},
},
"log"
)
.name("📋 Print values to console");
}
}
+16 -16
View File
@@ -7,9 +7,9 @@ import { Trigger } from "./TriggerC";
const ATTACK_DAMAGE = 10; // damage per bat-tip touch const ATTACK_DAMAGE = 10; // damage per bat-tip touch
const CONTACT_DIST = 0.8; // bat tip → crate distance that counts as a touch const CONTACT_DIST = 0.8; // bat tip → crate distance that counts as a touch
const _tmp = new Vector3(); const _scratch = new Vector3(); // reused crate-position holder (no per-frame allocation)
const _center = new Vector3(); const _center = new Vector3(); // averaged centre of the crates in reach
const _tip = new Vector3(); const _tip = new Vector3(); // current bat-tip world position
/** /**
* Auto-attack: while the player stands near crates, swing the bat and damage a * Auto-attack: while the player stands near crates, swing the bat and damage a
@@ -24,9 +24,9 @@ export class CombatC {
static init() { static init() {
// Proximity trigger around every crate → decides which crates are in reach. // Proximity trigger around every crate → decides which crates are in reach.
for (const crate of LootableC.crates) { for (const crate of LootableC.crates) {
crate.root.getWorldPosition(_tmp); crate.root.getWorldPosition(_scratch);
crate.trigger = new Trigger( crate.trigger = new Trigger(
{ x: _tmp.x, y: _tmp.y + 0.5, z: _tmp.z }, { x: _scratch.x, y: _scratch.y + 0.5, z: _scratch.z },
{ x: 1.1, y: 1.0, z: 1.1 }, { x: 1.1, y: 1.0, z: 1.1 },
{ {
onEnter: () => this.inRange.add(crate), onEnter: () => this.inRange.add(crate),
@@ -56,9 +56,9 @@ export class CombatC {
// Face the centre of the crates in reach and keep swinging. // Face the centre of the crates in reach and keep swinging.
_center.set(0, 0, 0); _center.set(0, 0, 0);
for (const c of this.inRange) { for (const crate of this.inRange) {
c.root.getWorldPosition(_tmp); crate.root.getWorldPosition(_scratch);
_center.add(_tmp); _center.add(_scratch);
} }
_center.divideScalar(this.inRange.size); _center.divideScalar(this.inRange.size);
PlayerC.setAttacking(true, _center); PlayerC.setAttacking(true, _center);
@@ -80,12 +80,12 @@ export class CombatC {
const tip = PlayerC.getBatTip(_tip); const tip = PlayerC.getBatTip(_tip);
if (!tip) return; if (!tip) return;
for (const c of [...this.inRange]) { for (const crate of [...this.inRange]) {
if (c.broken || this.hitThisSwing.has(c)) continue; if (crate.broken || this.hitThisSwing.has(crate)) continue;
c.root.getWorldPosition(_tmp); crate.root.getWorldPosition(_scratch);
if (Math.hypot(tip.x - _tmp.x, tip.z - _tmp.z) <= CONTACT_DIST) { if (Math.hypot(tip.x - _scratch.x, tip.z - _scratch.z) <= CONTACT_DIST) {
this.hitThisSwing.add(c); this.hitThisSwing.add(crate);
LootableC.damageCrate(c, ATTACK_DAMAGE); LootableC.damageCrate(crate, ATTACK_DAMAGE);
} }
} }
this.pruneBroken(); this.pruneBroken();
@@ -97,8 +97,8 @@ export class CombatC {
} }
private static pruneBroken() { private static pruneBroken() {
for (const c of this.inRange) { for (const crate of this.inRange) {
if (c.broken) { this.inRange.delete(c); this.hitThisSwing.delete(c); } if (crate.broken) { this.inRange.delete(crate); this.hitThisSwing.delete(crate); }
} }
} }
} }
+3 -10
View File
@@ -44,13 +44,6 @@ export class FollowCameraC {
this._lookAheadCurrent.set(0, 0, 0); this._lookAheadCurrent.set(0, 0, 0);
} }
static snapToTarget() {
const camera = CameraC_internal.camera;
if (!this.target || !camera) return;
this.target.getWorldPosition(_targetWorldPos);
camera.position.copy(_targetWorldPos).add(this.offset);
}
private static update(delta: number) { private static update(delta: number) {
if (this.paused) return; if (this.paused) return;
const camera = CameraC_internal.camera; const camera = CameraC_internal.camera;
@@ -62,11 +55,11 @@ export class FollowCameraC {
// target.rotation.y is the mesh Y-axis rotation set by PlayerC. // target.rotation.y is the mesh Y-axis rotation set by PlayerC.
// When the player stops, the lerp keeps drifting toward the last // When the player stops, the lerp keeps drifting toward the last
// facing direction — the "settle after stop" effect comes for free. // facing direction — the "settle after stop" effect comes for free.
const ry = this.target.rotation.y; const yaw = this.target.rotation.y;
_lookAheadTarget.set( _lookAheadTarget.set(
Math.sin(ry) * this.lookAheadStrength, Math.sin(yaw) * this.lookAheadStrength,
0, 0,
Math.cos(ry) * this.lookAheadStrength, Math.cos(yaw) * this.lookAheadStrength,
); );
this._lookAheadCurrent.lerp(_lookAheadTarget, Math.min(1, this.lookAheadLerpSpeed * delta)); this._lookAheadCurrent.lerp(_lookAheadTarget, Math.min(1, this.lookAheadLerpSpeed * delta));
+205
View File
@@ -0,0 +1,205 @@
import { Box3, DoubleSide, Group, Material, Mesh, Object3D, Vector3 } from "three";
import * as TWEEN from "@tweenjs/tween.js";
import { UpdateController, CameraC_internal } from "@24tools/playable_template";
import { ThreeC } from "./ThreeC";
import type { Crate } from "./LootableC";
// --- Placement ---
const HEIGHT_ABOVE = 1.4; // world Y offset of the bar above the crate origin
const BAR_SCALE = 1.2; // world scale of the cloned bar (prototype is ~1m wide)
// --- Fill animation ---
const FOREGROUND_MS = 120; // Foreground (current health) drops fast — animates first
const MIDDLEGROUND_DELAY = 120; // Middleground starts a touch later…
const MIDDLEGROUND_MS = 600; // …and catches up slowly behind it (the "lost chunk" sliver)
// --- Show / hide ---
const FADE_IN_MS = 150;
const FADE_OUT_MS = 700; // slow, soft fade-out
const HOLD_S = 3.0; // stay visible this long after the last hit, then fade out
// One bar instance bound to a crate. The two fills slide their right edge while
// keeping the left edge pinned: foreground leads (fast), middleground trails.
interface Bar {
group: Group; // billboarded container, positioned above the crate
foreground: Mesh; // lead fill (snaps to the new health quickly)
middleground: Mesh; // catch-up fill (trails behind the foreground)
foregroundBaseX: number; // foreground resting local X
foregroundMinX: number; // foreground geometry left edge (anchor for the shrink)
middlegroundBaseX: number;
middlegroundMinX: number;
materials: Material[]; // all 3 cloned materials (driven together by the fade)
alpha: number; // current fade level (0 hidden … 1 shown)
idleSeconds: number; // time since the last hit (drives the auto fade-out)
visible: boolean;
fadeTween: TWEEN.Tween<{ alpha: number }> | null; // live fade (stopped before re-firing)
foregroundTween: TWEEN.Tween<{ scaleX: number }> | null; // live fill tweens (stopped before re-firing)
middlegroundTween: TWEEN.Tween<{ scaleX: number }> | null;
}
/**
* In-world health bars above crates, cloned from the GLB "UI" prototype
* (Background track + Foreground + Middleground). A bar appears on the first hit
* and fades out when the crate is left alone. On each hit the Foreground snaps to
* the new health quickly and the Middleground trails behind it.
*/
export class HealthBarC {
private static protoNode: Object3D | null = null;
private static bars = new Map<Crate, Bar>();
private static tweens = new TWEEN.Group();
static init(prototype: Object3D | null) {
if (!prototype) {
console.warn("[HealthBar] no prototype (UI node) found — bars disabled");
return;
}
this.protoNode = prototype;
UpdateController.Instance.onUpdate.addDelegate((delta: number) => this.update(delta));
}
/** Show/refresh the bar for a crate at the given health fraction (0..1). */
static showDamage(crate: Crate, fraction: number) {
if (!this.protoNode) return;
let bar = this.bars.get(crate);
if (!bar) {
const built = this.build();
if (!built) return;
bar = built;
this.bars.set(crate, bar);
}
bar.idleSeconds = 0;
if (!bar.visible) { bar.visible = true; this.fade(bar, 1, FADE_IN_MS); }
const health = Math.max(0, Math.min(1, fraction));
// Foreground first (fast), Middleground catching up behind it (slower, delayed).
bar.foregroundTween = this.tweenFill(
bar.foregroundTween, bar.foreground, bar.foregroundBaseX, bar.foregroundMinX, health, FOREGROUND_MS, 0);
bar.middlegroundTween = this.tweenFill(
bar.middlegroundTween, bar.middleground, bar.middlegroundBaseX, bar.middlegroundMinX, health, MIDDLEGROUND_MS, MIDDLEGROUND_DELAY);
}
/** Crate gone (broken): fade the bar out and drop it. */
static hide(crate: Crate) {
const bar = this.bars.get(crate);
if (!bar) return;
this.bars.delete(crate);
this.fade(bar, 0, FADE_OUT_MS, () => {
ThreeC.removeFromScene(bar.group);
bar.materials.forEach(material => material.dispose());
});
}
// Clone the prototype, make its materials fade-able, center it, and add it to the
// scene. The bar is positioned over its crate every frame in update(), so build
// itself needs nothing from the crate.
private static build(): Bar | null {
const group = new Group();
const ui = this.protoNode!.clone(true);
ui.quaternion.identity(); // drop the prototype's authored tilt — we billboard instead
ui.visible = true;
ui.traverse(o => { o.visible = true; });
const foreground = ui.getObjectByName("UI_Foreground") as Mesh | undefined;
const middleground = ui.getObjectByName("UI_Middleground") as Mesh | undefined;
const background = ui.getObjectByName("UI_Background") as Mesh | undefined;
if (!foreground || !middleground || !background) {
console.warn("[HealthBar] prototype missing UI_Foreground/Middleground/Background");
return null;
}
// Keep the GLB's original look: clone each mesh's authored (textured, unlit)
// material and only make it fade-able + force the layer order (Foreground on
// top of Middleground on top of Background). depthTest off so the bar always
// draws over the scene; DoubleSide so it shows no matter how it's billboarded.
const materials: Material[] = [];
const prepMaterial = (mesh: Mesh, renderOrder: number) => {
const material = (mesh.material as Material).clone();
material.transparent = true;
material.opacity = 0; // start hidden; the fade-in brings it up
material.depthTest = false;
material.depthWrite = false;
material.side = DoubleSide;
mesh.material = material;
mesh.renderOrder = renderOrder;
materials.push(material);
};
prepMaterial(background, 0);
prepMaterial(middleground, 1);
prepMaterial(foreground, 2);
// Anchor data so the fills shrink from the right (left edge stays put).
const leftEdgeX = (mesh: Mesh) => { mesh.geometry.computeBoundingBox(); return mesh.geometry.boundingBox!.min.x; };
const foregroundMinX = leftEdgeX(foreground), middlegroundMinX = leftEdgeX(middleground);
const foregroundBaseX = foreground.position.x, middlegroundBaseX = middleground.position.x;
// Center the bar content on the group origin (the prototype meshes sit offset).
ui.updateMatrixWorld(true);
const center = new Box3().setFromObject(ui).getCenter(new Vector3());
ui.position.sub(center);
group.add(ui);
group.scale.setScalar(BAR_SCALE);
ThreeC.addToScene(group);
return {
group, foreground, middleground,
foregroundBaseX, foregroundMinX, middlegroundBaseX, middlegroundMinX,
materials, alpha: 0, idleSeconds: 0, visible: false,
fadeTween: null, foregroundTween: null, middlegroundTween: null,
};
}
// Animate one fill to a fraction, keeping its left edge fixed (right edge moves).
private static tweenFill(
prev: TWEEN.Tween<{ scaleX: number }> | null,
mesh: Mesh, baseX: number, minX: number, targetFraction: number, ms: number, delay: number,
) {
prev?.stop();
const state = { scaleX: mesh.scale.x };
return new TWEEN.Tween(state, this.tweens)
.to({ scaleX: targetFraction }, ms)
.delay(delay)
.easing(TWEEN.Easing.Quadratic.Out)
.onUpdate(({ scaleX }) => {
mesh.scale.x = scaleX;
mesh.position.x = baseX + minX * (1 - scaleX); // pin the left edge as it shrinks
})
.start();
}
// Fade all of a bar's materials to a target alpha. Stops any in-flight fade
// first so a re-show mid-fade-out doesn't fight it (fixes the flicker on the
// next hit right after the bar started disappearing).
private static fade(bar: Bar, to: number, ms: number, onDone?: () => void) {
bar.fadeTween?.stop();
const state = { alpha: bar.alpha };
bar.fadeTween = new TWEEN.Tween(state, this.tweens)
.to({ alpha: to }, ms)
.easing(TWEEN.Easing.Quadratic.Out)
.onUpdate(({ alpha }) => {
bar.alpha = alpha;
bar.materials.forEach(material => { material.opacity = alpha; });
})
.onComplete(() => { bar.fadeTween = null; onDone?.(); })
.start();
}
// Each frame: billboard active bars to the camera, keep them above their crate,
// fade out idle bars, and advance the tweens.
private static update(delta: number) {
this.tweens.update();
const camera = CameraC_internal.camera;
for (const [crate, bar] of this.bars) {
crate.root.getWorldPosition(bar.group.position);
bar.group.position.y += HEIGHT_ABOVE;
if (camera) bar.group.quaternion.copy(camera.quaternion);
if (bar.visible) {
bar.idleSeconds += delta;
if (bar.idleSeconds >= HOLD_S) { bar.visible = false; this.fade(bar, 0, FADE_OUT_MS); }
}
}
}
}
+245
View File
@@ -0,0 +1,245 @@
import { UpdateController } from "@24tools/playable_template";
import { ensurePassionOne } from "../fonts/passionOne";
import { PlayerC } from "./PlayerC";
import * as TWEEN from "@tweenjs/tween.js";
import {
zombieHeadUrl,
woodPanelUrl,
metalPanelUrl,
toolPanelUrl,
toolIconUrl,
} from "../resources/OnbordingUI/onboardingUI";
// Onboarding/invasion pacing.
const MOVE_TO_HURRY_S = 5; // after the player starts moving, wait this long…
const HURRY_DURATION_S = 2.5; // …then show "HURRY UP" for this long, then the invasion bar
const INVASION_DURATION_S = 60; // the invasion countdown drains over this many seconds
const LOW_FRACTION = 0.25; // below this the bar turns red
const HALF_FRACTION = 0.5; // at/below this the zombie icon grows + shakes
const DEATH_FLY_DELAY_MS = 1000; // pause after death before the zombie icon flies to center
// The onboarding → invasion → death state machine.
enum Phase {
WaitMove,
Moving,
Hurry,
Invasion,
Dead,
}
/**
* The screen-space HUD (HTML overlay): an onboarding flow ("DRAG TO MOVE", then
* "HURRY UP" → invasion countdown — both inside the invasion container, since one
* replaces the other), wood/metal counters and a weapon panel. When the invasion
* timer runs out the player dies and a restart end-card flies in. The wood
* panel/count carry stable ids so LootC can fly loot into them.
*
* Markup classes follow the BEM scheme defined in css/ui.scss: blocks are
* `.hud-<block>`, children `__element`, state `--modifier`.
*/
export class HudC {
private static phase = Phase.WaitMove;
private static timer = 0; // seconds spent in the current phase
private static remaining = INVASION_DURATION_S;
private static root: HTMLElement | null = null;
private static tutorial: HTMLElement | null = null;
private static invasion: HTMLElement | null = null;
private static invasionFill: HTMLElement | null = null;
private static invasionHead: HTMLElement | null = null;
private static invasionHeadWrap: HTMLElement | null = null; // rides the fill's edge
private static tweens = new TWEEN.Group();
private static buttonTween: TWEEN.Tween<{ x: number; y: number }> | null = null;
static init() {
ensurePassionOne();
const hud = document.createElement("div");
hud.id = "hud";
hud.innerHTML = this.markup();
document.body.appendChild(hud);
this.root = hud;
this.tutorial = hud.querySelector(".hud-tutorial");
this.invasion = hud.querySelector(".hud-invasion");
this.invasionFill = hud.querySelector(".hud-invasion__fill");
this.invasionHead = hud.querySelector(".hud-invasion__head");
this.invasionHeadWrap = hud.querySelector(".hud-invasion__head-wrap");
// Start with the "DRAG TO MOVE" hint up and the invasion container hidden.
// Pre-arm the hurry phase while hidden so revealing it later shows only
// "HURRY UP" (never a flash of the bar before it).
this.showTutorial("DRAG TO MOVE");
this.invasion?.classList.add("hud-invasion--hidden", "hud-invasion--hurry-phase");
UpdateController.Instance.onUpdate.addDelegate((delta: number) => {
this.tick(delta);
this.tweens.update();
});
}
// Whole HUD as one HTML string — simpler than building each node by hand.
// "HURRY UP" lives inside the invasion container: it shows first, then the bar
// takes its place (toggled by the .hud-invasion--hurry-phase modifier).
private static markup(): string {
return `
<div class="hud-tutorial"></div>
<div class="hud-invasion">
<div class="hud-invasion__hurry">HURRY UP!</div>
<div class="hud-invasion__title">ZOMBIE INVASION</div>
<div class="hud-invasion__row">
<div class="hud-invasion__bar">
<div class="hud-invasion__track"><div class="hud-invasion__fill"></div></div>
<div class="hud-invasion__head-wrap"><img class="hud-invasion__head" src="${zombieHeadUrl}" /></div>
</div>
</div>
</div>
<div class="hud-resources">
<div id="wood-ui" class="hud-resources__panel" style="background-image:url(${woodPanelUrl})">
<div id="wood-count" class="hud-resources__count">0</div>
</div>
<div class="hud-resources__panel" style="background-image:url(${metalPanelUrl})">
<div class="hud-resources__count">0</div>
</div>
</div>
<div class="hud-weapon" style="background-image:url(${toolPanelUrl})">
<img class="hud-weapon__icon" src="${toolIconUrl}" />
<div class="hud-weapon__lvl">LVL 0</div>
</div>
`;
}
private static showTutorial(text: string) {
if (!this.tutorial) return;
this.tutorial.textContent = text;
this.tutorial.classList.add("hud-tutorial--show");
}
private static hideTutorial() {
this.tutorial?.classList.remove("hud-tutorial--show");
}
// Onboarding → invasion → death.
private static tick(delta: number) {
switch (this.phase) {
case Phase.WaitMove:
// Wait until the player drags to move, then drop the hint.
if (PlayerC.isMoving()) {
this.hideTutorial();
this.phase = Phase.Moving;
this.timer = 0;
}
break;
case Phase.Moving:
// A few seconds into play, show "HURRY UP" inside the invasion container.
this.timer += delta;
if (this.timer >= MOVE_TO_HURRY_S) {
// --hurry-phase was pre-set in init → fade the container in showing
// only HURRY UP.
this.invasion?.classList.remove("hud-invasion--hidden");
this.phase = Phase.Hurry;
this.timer = 0;
}
break;
case Phase.Hurry:
// After the nudge, swap "HURRY UP" out for the countdown bar (same box).
this.timer += delta;
if (this.timer >= HURRY_DURATION_S) {
this.invasion?.classList.remove("hud-invasion--hurry-phase");
this.phase = Phase.Invasion;
this.remaining = INVASION_DURATION_S;
}
break;
case Phase.Invasion: {
this.remaining = Math.max(0, this.remaining - delta);
const frac = this.remaining / INVASION_DURATION_S; // time LEFT (1 → 0)
const progress = 1 - frac; // invasion FILLS as time elapses (0 → 1)
if (this.invasionFill) {
this.invasionFill.style.width = `${progress * 100}%`;
this.invasionFill.classList.toggle("hud-invasion__fill--low", frac <= LOW_FRACTION);
}
// The zombie icon rides the filling edge, advancing as the bar fills up.
if (this.invasionHeadWrap)
this.invasionHeadWrap.style.left = `${progress * 100}%`;
// Half time left: grow + shake. Red zone (low): shake harder + bigger.
this.invasionHead?.classList.toggle("hud-invasion__head--alert", frac <= HALF_FRACTION);
this.invasionHead?.classList.toggle("hud-invasion__head--alert-strong", frac <= LOW_FRACTION);
if (this.remaining <= 0) {
PlayerC.die();
this.onDeath();
this.phase = Phase.Dead;
}
break;
}
case Phase.Dead:
break;
}
}
// End-card: the zombie icon flies from the bar to screen center while growing,
// then a restart button fades in beneath it.
private static onDeath() {
if (!this.root) return;
// Where the small zombie icon sits now (fly start), and the screen center (end).
const r = this.invasionHead?.getBoundingClientRect();
const sx = r ? r.left + r.width / 2 : window.innerWidth / 2;
const sy = r ? r.top + r.height / 2 : 0;
const cx = window.innerWidth / 2;
const cy = window.innerHeight / 2;
this.invasion?.classList.add("hud-invasion--hidden"); // hide the bar + its small icon
const card = document.createElement("div");
card.className = "hud-endcard";
card.innerHTML = `<img class="hud-endcard__zombie" src="${zombieHeadUrl}" />`;
this.root.appendChild(card);
const zombie = card.querySelector(".hud-endcard__zombie") as HTMLElement;
// Start the icon small, at the bar's position; then transition to centered
// full size (CSS animates the transform; the button fades in near the end).
zombie.style.transform = `translate(${sx - cx}px, ${sy - cy}px) scale(0.35)`;
void zombie.offsetWidth; // force reflow so the next change animates
// Hold for a beat (the death animation plays), then fly the icon to center.
setTimeout(() => {
zombie.style.transform = "translate(0, 0) scale(1)";
card.classList.add("hud-endcard--show");
const btn = document.querySelector(".sudHolder_right") as HTMLElement;
if (btn) {
const startRect = btn.getBoundingClientRect();
btn.style.position = "fixed";
btn.style.zIndex = "20";
btn.style.width = "auto";
btn.style.left = `${startRect.left}px`;
btn.style.top = `${startRect.top}px`;
btn.style.transform = "none";
// Target = centered just under where the zombie ENDS UP (screen center),
// using its final CSS height. Reading its live rect here would catch it
// mid-flight (still small / off-center) and put the button in the wrong spot.
const zh = parseFloat(getComputedStyle(zombie).height) || 0;
const targetX = cx - btn.offsetWidth / 2;
const targetY = cy + zh / 2 + 16;
this.buttonTween?.stop();
const state = { x: startRect.left, y: startRect.top };
this.buttonTween = new TWEEN.Tween(state, this.tweens)
.to({ x: targetX, y: targetY }, 800)
.delay(150)
.easing(TWEEN.Easing.Quadratic.Out)
.onUpdate(({ x, y }) => {
btn.style.left = `${x}px`;
btn.style.top = `${y}px`;
})
.start();
}
}, DEATH_FLY_DELAY_MS);
}
}
+135 -128
View File
@@ -4,6 +4,7 @@ import { UpdateController, CameraC_internal } from "@24tools/playable_template";
import { ThreeC } from "./ThreeC"; import { ThreeC } from "./ThreeC";
import { TestSceneC } from "./TestSceneC"; // for groundY (ground level) import { TestSceneC } from "./TestSceneC"; // for groundY (ground level)
import { woodIconUrl } from "../resources/images/woodIcon"; import { woodIconUrl } from "../resources/images/woodIcon";
import { worldToScreen } from "../utils/screen";
// Tunables — tweak here // Tunables — tweak here
const PIECES_MIN = 3; // min pieces per drop const PIECES_MIN = 3; // min pieces per drop
@@ -19,21 +20,21 @@ const BOUNCES = 2; // how many bounces after landing
const BOUNCE_HEIGHT = 0.4; // each bounce = this fraction of the previous height 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_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 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 FLIGHT_STRETCH = 0.22; // vertical stretch in flight (softer = smoother, less rubbery)
const LAND_SQUASH = 0.6; // squash on the final landing const LAND_SQUASH = 0.72; // squash on the final landing (gentler)
const LAND_POP_MS = 160; // duration of the final "pop" const LAND_POP_MS = 180; // duration of the final "pop"
// Collect (#8): delay after landing before flying to the corner, UI icon size, etc. // Collect (#8): when/where the wood flies to the UI.
const COLLECT_DELAY_MS = 40; // almost immediately after the bounces (flows into the collect) const COLLECT_LEAD_MS = 170; // start the collect this long BEFORE the bounces finish,
const UI_ICON_SIZE = 28; // wood UI icon size (px) — smaller than loot on the ground, but not tiny // so the shrink+flight flow out of the last bounce
const UI_RIGHT = 16; // offset from the right edge (px) const COLLECT_STAGGER_MS = 70; // extra per-piece delay so they stream in, not all at once
const UI_TOP = 110; // offset from the top (px) — lower, like in the REF const UI_ICON_SIZE = 28; // wood arrival size (px) — about the wood plank on the panel
const SHRINK_MS = 250; // shrink to UI size before the flight const UI_WOOD_X_FRAC = 0.82; // where the wood art sits across the panel (right-side plank)
const FLY_MS = 500; // duration of the flight to the corner const FLY_MS = 950; // flight duration — slower, calmer travel to the UI (size tracks it)
const BLINK_MS = 120; // ramp-up duration of the white flash (fade-out is longer) const FLY_ARC_PX = 90; // how high the flight bows upward (curved path, not a straight line)
const BLINK_MS = 120; // ramp-up of the white glint
const _ndc = new Vector3(); const _spriteTop = new Vector3(); // scratch: a sprite's top point, for measuring its screen size
const _topV = new Vector3();
export class LootC { export class LootC {
static pieces: Sprite[] = []; static pieces: Sprite[] = [];
@@ -48,28 +49,10 @@ export class LootC {
this.texture = new TextureLoader().load(woodIconUrl); this.texture = new TextureLoader().load(woodIconUrl);
this.texture.colorSpace = SRGBColorSpace; // correct color this.texture.colorSpace = SRGBColorSpace; // correct color
// Wood UI icon in the top-right corner (HTML overlay). Loot flies into it. // The wood icon/count live in the HUD (built by HudC). We just reference
const icon = document.createElement("img"); // them: read the icon's screen position as the fly target, write the count.
icon.id = "wood-ui"; // stable id → UI/counter hooks onto it, LootC reads its position this.uiIcon = document.getElementById("wood-ui") as HTMLImageElement | null;
icon.src = woodIconUrl; this.countEl = document.getElementById("wood-count");
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;
// The numeric balance, just left of the icon.
const count = document.createElement("div");
count.id = "wood-count";
count.style.cssText =
`position:fixed; top:${UI_TOP}px; right:${UI_RIGHT + UI_ICON_SIZE + 6}px;` +
`height:${UI_ICON_SIZE}px; line-height:${UI_ICON_SIZE}px;` +
`font-family:sans-serif; font-weight:700; font-size:18px; color:#fff;` +
`text-shadow:0 1px 2px rgba(0,0,0,0.6); z-index:1001; pointer-events:none;` +
`transition:transform 0.12s ease-out;`;
document.body.appendChild(count);
this.countEl = count;
this.renderCount(); this.renderCount();
// ⚠️ Key: pump our group every frame, otherwise the tweens don't advance. // ⚠️ Key: pump our group every frame, otherwise the tweens don't advance.
@@ -81,9 +64,9 @@ export class LootC {
return this.balance; return this.balance;
} }
/** Spend up to `n` wood; returns how much was actually taken (clamped to balance). */ /** Spend up to `amount` wood; returns how much was actually taken (clamped to balance). */
static spend(n: number): number { static spend(amount: number): number {
const taken = Math.min(n, this.balance); const taken = Math.min(amount, this.balance);
this.balance -= taken; this.balance -= taken;
this.renderCount(); this.renderCount();
return taken; return taken;
@@ -95,100 +78,115 @@ export class LootC {
} }
private static renderCount() { private static renderCount() {
if (this.countEl) this.countEl.textContent = `×${this.balance}`; if (this.countEl) this.countEl.textContent = `${this.balance}`;
} }
/** Spawn loot at a point. If count is omitted → random PIECES_MIN..PIECES_MAX. */ /** Spawn loot at a point. If count is omitted → random PIECES_MIN..PIECES_MAX. */
static spawn(origin: Vector3, count?: number) { static spawn(origin: Vector3, count?: number) {
const n = count ?? (PIECES_MIN + Math.floor(Math.random() * (PIECES_MAX - PIECES_MIN + 1))); const pieceCount = 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 const sectorAngle = (Math.PI * 2) / pieceCount; // each piece gets its own slice of the circle
for (let i = 0; i < n; i++) { for (let i = 0; i < pieceCount; i++) {
const piece = this.createPiece(); const piece = this.createPiece();
piece.position.copy(origin); piece.position.copy(origin);
// Even sector + a little jitter → pieces spread out and don't clump. // Even sector + a little jitter → pieces spread out and don't clump.
const angle = i * slice + (Math.random() - 0.5) * slice * ANGLE_JITTER; const angle = i * sectorAngle + (Math.random() - 0.5) * sectorAngle * ANGLE_JITTER;
const dist = SCATTER_MIN + Math.random() * (SCATTER_MAX - SCATTER_MIN); const radius = SCATTER_MIN + Math.random() * (SCATTER_MAX - SCATTER_MIN);
const landing = new Vector3( const landing = new Vector3(
origin.x + Math.cos(angle) * dist, origin.x + Math.cos(angle) * radius,
TestSceneC.groundY + PIECE_SIZE / 2, // sprite center above ground → its bottom touches the ground TestSceneC.groundY + PIECE_SIZE / 2, // sprite center above ground → its bottom touches the ground
origin.z + Math.sin(angle) * dist, origin.z + Math.sin(angle) * radius,
); );
this.animatePiece(piece, origin.clone(), landing); this.animatePiece(piece, origin.clone(), landing, i);
this.pieces.push(piece); this.pieces.push(piece);
} }
} }
/** Flat piece: a Sprite (billboard — always faces the camera) with the wood texture. */ /** Flat piece: a Sprite (billboard — always faces the camera) with the wood texture. */
private static createPiece(): Sprite { private static createPiece(): Sprite {
const mat = new SpriteMaterial({ map: this.texture, transparent: true }); const material = new SpriteMaterial({ map: this.texture, transparent: true });
const piece = new Sprite(mat); const piece = new Sprite(material);
piece.scale.set(PIECE_SIZE, PIECE_SIZE, 1); piece.scale.set(PIECE_SIZE, PIECE_SIZE, 1);
ThreeC.addToScene(piece); ThreeC.addToScene(piece);
return piece; return piece;
} }
/** A piece flies in an arc, bounces a couple times, then pops on landing. */ /** A piece flies in an arc, bounces a couple times, then pops on landing. */
private static animatePiece(piece: Sprite, from: Vector3, to: Vector3) { private static animatePiece(piece: Sprite, from: Vector3, to: Vector3, index = 0) {
const restY = to.y; // sprite center at rest const restY = to.y; // sprite center at rest
// One hop: an arc from (fx,fz) to (tx,tz), stretched while moving fast. // One hop: a parabolic arc from (fromX,fromZ) to (toX,toZ), stretched while
const hop = (fx: number, fz: number, tx: number, tz: number, peak: number, ms: number) => // moving fast (the "squash & stretch" that sells the speed).
const hop = (fromX: number, fromZ: number, toX: number, toZ: number, peakHeight: number, durationMs: number) =>
new TWEEN.Tween({ t: 0 }, this.tweens) new TWEEN.Tween({ t: 0 }, this.tweens)
.to({ t: 1 }, ms) .to({ t: 1 }, durationMs)
.easing(TWEEN.Easing.Linear.None) .easing(TWEEN.Easing.Linear.None)
.onUpdate(({ t }) => { .onUpdate(({ t }) => {
piece.position.x = fx + (tx - fx) * t; piece.position.x = fromX + (toX - fromX) * t;
piece.position.z = fz + (tz - fz) * t; piece.position.z = fromZ + (toZ - fromZ) * t;
piece.position.y = restY + peak * 4 * t * (1 - t); // parabolic arc piece.position.y = restY + peakHeight * 4 * t * (1 - t); // parabolic arc
// |1-2t|: fast on the way up/down → stretch; at the peak → normal. // |1-2t|: fast on the way up/down → stretch; at the peak → normal.
// Scale the stretch by hop height (small bounces stretch less). // Scale the stretch by hop height (small bounces stretch less).
const s = 1 + FLIGHT_STRETCH * (peak / ARC_HEIGHT) * Math.abs(1 - 2 * t); const stretch = 1 + FLIGHT_STRETCH * (peakHeight / ARC_HEIGHT) * Math.abs(1 - 2 * t);
piece.scale.set(PIECE_SIZE / s, PIECE_SIZE * s, 1); piece.scale.set(PIECE_SIZE / stretch, PIECE_SIZE * stretch, 1);
}); });
// Horizontal throw direction (target = the final resting spot). // Horizontal throw direction (target = the final resting spot).
const dx = to.x - from.x, dz = to.z - from.z; const deltaX = to.x - from.x, deltaZ = to.z - from.z;
const totalDist = Math.hypot(dx, dz) || 1e-4; const totalDist = Math.hypot(deltaX, deltaZ) || 1e-4;
const dirX = dx / totalDist, dirZ = dz / totalDist; const dirX = deltaX / totalDist, dirZ = deltaZ / totalDist;
// Share the horizontal distance across the flight + bounces (so it also // Share the horizontal distance across the flight + bounces (so the piece
// moves forward on each bounce, not just up). // also moves forward on each bounce, not just up). Each hop covers
const hops = BOUNCES + 1; // BOUNCE_FORWARD× the previous one, so the steps form a geometric series
const series = (1 - Math.pow(BOUNCE_FORWARD, hops)) / (1 - BOUNCE_FORWARD); // whose sum we divide the total distance by to get the first step.
let step = totalDist / series; const hopCount = BOUNCES + 1;
let cx = from.x, cz = from.z; const forwardSum = (1 - Math.pow(BOUNCE_FORWARD, hopCount)) / (1 - BOUNCE_FORWARD);
let peak = ARC_HEIGHT, ms = FLIGHT_MS; let stepDist = totalDist / forwardSum;
let first: TWEEN.Tween<{ t: number }> | null = null; let curX = from.x, curZ = from.z;
let prev: TWEEN.Tween<{ t: number }> | null = null; let peakHeight = ARC_HEIGHT, durationMs = FLIGHT_MS;
let firstHop: TWEEN.Tween<{ t: number }> | null = null;
let prevHop: TWEEN.Tween<{ t: number }> | null = null;
const allTweens: TWEEN.Tween<any>[] = []; // every hop/pop tween (so we can stop them early)
let bouncesMs = 0; // total duration of all the hop arcs
for (let k = 0; k < hops; k++) { for (let i = 0; i < hopCount; i++) {
const nx = cx + dirX * step, nz = cz + dirZ * step; const nextX = curX + dirX * stepDist, nextZ = curZ + dirZ * stepDist;
const h = hop(cx, cz, nx, nz, peak, ms); const hopTween = hop(curX, curZ, nextX, nextZ, peakHeight, durationMs);
if (!first) first = h; else prev!.chain(h); allTweens.push(hopTween);
prev = h; if (!firstHop) firstHop = hopTween; else prevHop!.chain(hopTween);
cx = nx; cz = nz; prevHop = hopTween;
step *= BOUNCE_FORWARD; peak *= BOUNCE_HEIGHT; ms *= BOUNCE_TIME; bouncesMs += durationMs;
curX = nextX; curZ = nextZ;
stepDist *= BOUNCE_FORWARD; peakHeight *= BOUNCE_HEIGHT; durationMs *= BOUNCE_TIME;
} }
// Landing pop: squash on the ground, then spring back to normal. // Landing pop: squash on the ground, then spring back. Only seen if the piece
// somehow isn't collected first (the collect normally lifts off before this).
const groundY = restY - PIECE_SIZE / 2; const groundY = restY - PIECE_SIZE / 2;
const pop = new TWEEN.Tween({ k: 0 }, this.tweens) const pop = new TWEEN.Tween({ t: 0 }, this.tweens)
.to({ k: 1 }, LAND_POP_MS) .to({ t: 1 }, LAND_POP_MS)
.easing(TWEEN.Easing.Back.Out) .easing(TWEEN.Easing.Back.Out)
.onUpdate(({ k }) => { .onUpdate(({ t }) => {
const s = LAND_SQUASH + (1 - LAND_SQUASH) * k; // 0.6 → 1 (with a slight overshoot) const squash = LAND_SQUASH + (1 - LAND_SQUASH) * t; // 0.72 → 1 (with a slight overshoot)
piece.scale.set(PIECE_SIZE / s, PIECE_SIZE * s, 1); piece.scale.set(PIECE_SIZE / squash, PIECE_SIZE * squash, 1);
piece.position.y = groundY + (PIECE_SIZE * s) / 2; // bottom stays on the ground piece.position.y = groundY + (PIECE_SIZE * squash) / 2; // bottom stays on the ground
})
.onComplete(() => {
setTimeout(() => this.collect(piece), COLLECT_DELAY_MS); // then fly to the UI
}); });
prev!.chain(pop); allTweens.push(pop);
prevHop!.chain(pop);
first!.start(); firstHop!.start();
// Lift off into the UI a bit BEFORE the bounces finish, so the shrink + flight
// flow straight out of the last bounce (no "settle, pause, then fly"). Stop the
// remaining bounce/pop on this piece and hand straight over to the collect.
const liftOffDelayMs = Math.max(FLIGHT_MS * 0.7, bouncesMs - COLLECT_LEAD_MS) + index * COLLECT_STAGGER_MS;
setTimeout(() => {
allTweens.forEach(tween => tween.stop());
this.collect(piece);
}, liftOffDelayMs);
} }
/** /**
@@ -203,9 +201,9 @@ export class LootC {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
// Sprite center + size in screen pixels. // Sprite center + size in screen pixels.
const center = this.toScreen(piece.position, cam, rect); const center = worldToScreen(piece.position, cam, rect);
_topV.copy(piece.position); _topV.y += piece.scale.y / 2; _spriteTop.copy(piece.position); _spriteTop.y += piece.scale.y / 2;
const sizePx = Math.max(8, Math.abs(center.y - this.toScreen(_topV, cam, rect).y) * 2); const sizePx = Math.max(8, Math.abs(center.y - worldToScreen(_spriteTop, cam, rect).y) * 2);
// Drop the 3D sprite; the HTML image takes over from the same spot. // Drop the 3D sprite; the HTML image takes over from the same spot.
this.remove(piece); this.remove(piece);
@@ -214,7 +212,8 @@ export class LootC {
flier.src = woodIconUrl; flier.src = woodIconUrl;
flier.style.cssText = flier.style.cssText =
`position:fixed; left:0; top:0; width:${sizePx}px; height:${sizePx}px;` + `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;`; // above #hud (z-index 9999) so the wood clearly flies on top of, and into, the icon
`z-index:10000; pointer-events:none; transform:translate(-50%,-50%); will-change:left,top,width,height;`;
document.body.appendChild(flier); document.body.appendChild(flier);
// White "glint" copy that rides on top of the flier and fades out as it moves. // White "glint" copy that rides on top of the flier and fades out as it moves.
@@ -222,63 +221,69 @@ export class LootC {
flash.src = woodIconUrl; flash.src = woodIconUrl;
flash.style.cssText = flier.style.cssText; flash.style.cssText = flier.style.cssText;
flash.style.filter = "brightness(0) invert(1)"; // solid white silhouette flash.style.filter = "brightness(0) invert(1)"; // solid white silhouette
flash.style.zIndex = "1002"; flash.style.zIndex = "10001";
document.body.appendChild(flash); document.body.appendChild(flash);
const target = this.uiIconCenter(); const target = this.uiIconCenter();
// Shared state. The flight, shrink and blink below all run at once, so they // Curved flight path (quadratic Bézier): start → a lifted control point → UI.
// blend into one smooth motion instead of separate steps. // The upward bow makes the wood swoop in an arc instead of a flat diagonal,
const st = { x: center.x, y: center.y, size: sizePx, o: 0 }; // which reads much smoother.
const startX = center.x, startY = center.y;
const endX = target.x, endY = target.y;
const ctrlX = (startX + endX) / 2;
const ctrlY = Math.min(startY, endY) - FLY_ARC_PX;
// Shared animation state: progress along the path (0→1), current size, and
// the white glint's opacity. All three tweens below mutate this one object.
const anim = { progress: 0, size: sizePx, glow: 0 };
const place = (el: HTMLElement) => { const place = (el: HTMLElement) => {
el.style.left = `${st.x}px`; const inv = 1 - anim.progress; // (1t) term of the Bézier
el.style.top = `${st.y}px`; const x = inv * inv * startX + 2 * inv * anim.progress * ctrlX + anim.progress * anim.progress * endX;
el.style.width = `${st.size}px`; const y = inv * inv * startY + 2 * inv * anim.progress * ctrlY + anim.progress * anim.progress * endY;
el.style.height = `${st.size}px`; el.style.left = `${x}px`;
el.style.top = `${y}px`;
el.style.width = `${anim.size}px`;
el.style.height = `${anim.size}px`;
}; };
const apply = () => { place(flier); place(flash); flash.style.opacity = `${st.o}`; }; const apply = () => { place(flier); place(flash); flash.style.opacity = `${anim.glow}`; };
apply(); apply();
// Flight (position) — the longest tween, so it owns the cleanup. // Flight along the curve — owns the cleanup. Ease in AND out so it starts and
const fly = new TWEEN.Tween(st, this.tweens) // arrives gently.
.to({ x: target.x, y: target.y }, FLY_MS) const fly = new TWEEN.Tween(anim, this.tweens)
.easing(TWEEN.Easing.Quadratic.In) .to({ progress: 1 }, FLY_MS)
.easing(TWEEN.Easing.Quadratic.InOut)
.onUpdate(apply) .onUpdate(apply)
.onComplete(() => { flier.remove(); flash.remove(); this.balance++; this.renderCount(); this.pulseUiIcon(); }); .onComplete(() => { flier.remove(); flash.remove(); this.balance++; this.renderCount(); this.pulseUiIcon(); });
// Shrink (size) — runs alongside the flight, eases out so it shrinks early. // Shrink — SAME duration as the flight, so the two start AND finish together
const shrink = new TWEEN.Tween(st, this.tweens) // (no "shrink first"); Sinusoidal makes the size change extra smooth.
.to({ size: UI_ICON_SIZE }, SHRINK_MS) const shrink = new TWEEN.Tween(anim, this.tweens)
.to({ size: UI_ICON_SIZE }, FLY_MS)
.easing(TWEEN.Easing.Sinusoidal.InOut)
.onUpdate(apply);
// Blink — a quick glint that overlaps the start of the motion.
const flashIn = new TWEEN.Tween(anim, this.tweens)
.to({ glow: 1 }, BLINK_MS)
.easing(TWEEN.Easing.Quadratic.Out) .easing(TWEEN.Easing.Quadratic.Out)
.onUpdate(apply); .onUpdate(apply);
// Blink (opacity) — a quick glint that overlaps the start of the motion. const flashOut = new TWEEN.Tween(anim, this.tweens)
const flashIn = new TWEEN.Tween(st, this.tweens) .to({ glow: 0 }, BLINK_MS * 1.6)
.to({ o: 1 }, BLINK_MS)
.easing(TWEEN.Easing.Quadratic.Out)
.onUpdate(apply);
const flashOut = new TWEEN.Tween(st, this.tweens)
.to({ o: 0 }, BLINK_MS * 1.6)
.easing(TWEEN.Easing.Quadratic.In) .easing(TWEEN.Easing.Quadratic.In)
.onUpdate(apply); .onUpdate(apply);
flashIn.chain(flashOut); flashIn.chain(flashOut);
// Kick them all off together → blended, fluid collect. // Kick them off together → one blended, fluid collect.
fly.start(); fly.start();
shrink.start(); shrink.start();
flashIn.start(); 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() { private static uiIconCenter() {
const r = this.uiIcon?.getBoundingClientRect(); const r = this.uiIcon?.getBoundingClientRect();
return r ? { x: r.left + r.width / 2, y: r.top + r.height / 2 } : { x: 0, y: 0 }; // Aim at the wood plank on the right of the panel, not the panel's center,
// so the loot lands on the actual wood art.
return r ? { x: r.left + r.width * UI_WOOD_X_FRAC, y: r.top + r.height / 2 } : { x: 0, y: 0 };
} }
/** A small "pulse" of the UI icon when a piece arrives. */ /** A small "pulse" of the UI icon when a piece arrives. */
@@ -289,9 +294,11 @@ export class LootC {
setTimeout(() => { if (this.uiIcon) this.uiIcon.style.transform = "scale(1)"; }, 120); setTimeout(() => { if (this.uiIcon) this.uiIcon.style.transform = "scale(1)"; }, 120);
} }
/** Remove a piece from the scene (used by #8 — after collecting). */ /** Remove a piece from the scene and free its material (the texture is shared,
* so it's loaded once in init() and never disposed per-piece). */
static remove(piece: Sprite) { static remove(piece: Sprite) {
ThreeC.removeFromScene(piece); ThreeC.removeFromScene(piece);
(piece.material as SpriteMaterial).dispose();
const i = this.pieces.indexOf(piece); const i = this.pieces.indexOf(piece);
if (i >= 0) this.pieces.splice(i, 1); if (i >= 0) this.pieces.splice(i, 1);
} }
+47 -40
View File
@@ -4,6 +4,7 @@ import { UpdateController } from "@24tools/playable_template";
import { PhysicsBody, PhysicsLayer } from "./PhysicsC"; import { PhysicsBody, PhysicsLayer } from "./PhysicsC";
import { Trigger } from "./TriggerC"; import { Trigger } from "./TriggerC";
import { LootC } from "./LootC"; import { LootC } from "./LootC";
import { HealthBarC } from "./HealthBarC";
import { VfxManager } from "../resources/vfx/VfxManager"; import { VfxManager } from "../resources/vfx/VfxManager";
// Crate health, split evenly across 3 damage states (S1/S2/S3), broken at 0. // Crate health, split evenly across 3 damage states (S1/S2/S3), broken at 0.
@@ -31,7 +32,7 @@ export interface Crate {
broken: boolean; broken: boolean;
baseScale: Vector3; // resting scale (tweens multiply this) baseScale: Vector3; // resting scale (tweens multiply this)
baseRotY: number; // resting Y rotation baseRotY: number; // resting Y rotation
hitTween: TWEEN.Tween<{ k: number }> | null; // live hit-punch tween (killed before re-firing) hitTween: TWEEN.Tween<{ progress: number }> | null; // live hit-punch tween (killed before re-firing)
} }
/** /**
@@ -57,28 +58,28 @@ export class LootableC {
for (const crate of lootableGroup.children) { for (const crate of lootableGroup.children) {
// Map each authored damage state to its level via the _S<n> suffix. // Map each authored damage state to its level via the _S<n> suffix.
const statesGroup = crate.children.find(c => c.name.includes("_States")); const statesGroup = crate.children.find(child => child.name.includes("_States"));
const statesByLevel: (Object3D | null)[] = [null, null, null]; const statesByLevel: (Object3D | null)[] = [null, null, null];
if (statesGroup) { if (statesGroup) {
for (const s of statesGroup.children) { for (const stateNode of statesGroup.children) {
const m = s.name.match(/_S(\d)$/); const match = stateNode.name.match(/_S(\d)$/);
if (m) { if (match) {
const lvl = parseInt(m[1], 10) - 1; // S1→0, S2→1, S3→2 const level = parseInt(match[1], 10) - 1; // S1→0, S2→1, S3→2
if (lvl >= 0 && lvl < LEVELS) statesByLevel[lvl] = s; if (level >= 0 && level < LEVELS) statesByLevel[level] = stateNode;
} }
} }
} }
// Start at the lowest authored state (most intact one present). // Start at the lowest authored state (most intact one present).
let startLevel = statesByLevel.findIndex(s => s !== null); let startLevel = statesByLevel.findIndex(state => state !== null);
if (startLevel < 0) startLevel = 0; if (startLevel < 0) startLevel = 0;
statesByLevel.forEach((s, lvl) => { if (s) s.visible = lvl === startLevel; }); statesByLevel.forEach((state, level) => { if (state) state.visible = level === startLevel; });
// Health for that starting level (full crate = 100, S2 ≈ 67, S3 ≈ 33). // Health for that starting level (full crate = 100, S2 ≈ 67, S3 ≈ 33).
const health = CRATE_MAX_HEALTH * (LEVELS - startLevel) / LEVELS; const health = CRATE_MAX_HEALTH * (LEVELS - startLevel) / LEVELS;
// The per-crate collider proxy → static box, then hide it (physics only). // The per-crate collider proxy → static box, then hide it (physics only).
const proxy = crate.children.find(c => c.name.startsWith("BoxCollider")); const proxy = crate.children.find(child => child.name.startsWith("BoxCollider"));
if (!proxy) continue; if (!proxy) continue;
const collider = new PhysicsBody( const collider = new PhysicsBody(
@@ -114,12 +115,15 @@ export class LootableC {
this.punchCrate(crate); this.punchCrate(crate);
this.flashCrate(crate); this.flashCrate(crate);
// Show/refresh the floating health bar above the crate.
HealthBarC.showDamage(crate, crate.health / crate.maxHealth);
// Map remaining health to a level, never below where this crate started. // Map remaining health to a level, never below where this crate started.
let level = Math.floor((1 - crate.health / crate.maxHealth) * LEVELS); let level = Math.floor((1 - crate.health / crate.maxHealth) * LEVELS);
level = Math.max(crate.startLevel, Math.min(LEVELS - 1, level)); level = Math.max(crate.startLevel, Math.min(LEVELS - 1, level));
if (level !== crate.level) { if (level !== crate.level) {
crate.level = level; crate.level = level;
crate.statesByLevel.forEach((s, lvl) => { if (s) s.visible = lvl === level; }); crate.statesByLevel.forEach((state, lvl) => { if (state) state.visible = lvl === level; });
// Loot drops on every state change, not only on destruction. // Loot drops on every state change, not only on destruction.
LootC.spawn(crate.root.getWorldPosition(new Vector3())); LootC.spawn(crate.root.getWorldPosition(new Vector3()));
} }
@@ -130,6 +134,9 @@ export class LootableC {
if (crate.broken) return; if (crate.broken) return;
crate.broken = true; crate.broken = true;
// Drop the floating health bar.
HealthBarC.hide(crate);
// Stop the hit-punch so it doesn't fight the break animation. // Stop the hit-punch so it doesn't fight the break animation.
crate.hitTween?.stop(); crate.hitTween?.stop();
crate.hitTween = null; crate.hitTween = null;
@@ -143,17 +150,17 @@ export class LootableC {
LootC.spawn(crate.root.getWorldPosition(new Vector3())); LootC.spawn(crate.root.getWorldPosition(new Vector3()));
// Shrink to nothing while spinning, then hide the meshes. // Shrink to nothing while spinning, then hide the meshes.
const s = { k: 0 }; const anim = { progress: 0 };
new TWEEN.Tween(s, this.tweens) new TWEEN.Tween(anim, this.tweens)
.to({ k: 1 }, BREAK_MS) .to({ progress: 1 }, BREAK_MS)
.easing(TWEEN.Easing.Back.In) .easing(TWEEN.Easing.Back.In)
.onUpdate(({ k }) => { .onUpdate(({ progress }) => {
const sc = Math.max(0, 1 - k); const scale = Math.max(0, 1 - progress);
crate.root.scale.set(crate.baseScale.x * sc, crate.baseScale.y * sc, crate.baseScale.z * sc); crate.root.scale.set(crate.baseScale.x * scale, crate.baseScale.y * scale, crate.baseScale.z * scale);
crate.root.rotation.y = crate.baseRotY + k * BREAK_SPIN; crate.root.rotation.y = crate.baseRotY + progress * BREAK_SPIN;
}) })
.onComplete(() => { .onComplete(() => {
crate.statesByLevel.forEach(st => { if (st) st.visible = false; }); crate.statesByLevel.forEach(state => { if (state) state.visible = false; });
}) })
.start(); .start();
@@ -165,16 +172,16 @@ export class LootableC {
// Squash the crate, then spring back (with a small overshoot pop). // Squash the crate, then spring back (with a small overshoot pop).
private static punchCrate(crate: Crate) { private static punchCrate(crate: Crate) {
crate.hitTween?.stop(); // drop the previous punch so rapid hits don't stack crate.hitTween?.stop(); // drop the previous punch so rapid hits don't stack
const s = { k: 0 }; const anim = { progress: 0 };
crate.hitTween = new TWEEN.Tween(s, this.tweens) crate.hitTween = new TWEEN.Tween(anim, this.tweens)
.to({ k: 1 }, HIT_PUNCH_MS) .to({ progress: 1 }, HIT_PUNCH_MS)
.easing(TWEEN.Easing.Back.Out) .easing(TWEEN.Easing.Back.Out)
.onUpdate(({ k }) => { .onUpdate(({ progress }) => {
const q = (1 - k) * HIT_PUNCH; // HIT_PUNCH (squashed) → 0 (rest) const squash = (1 - progress) * HIT_PUNCH; // HIT_PUNCH (squashed) → 0 (rest)
crate.root.scale.set( crate.root.scale.set(
crate.baseScale.x * (1 + q), crate.baseScale.x * (1 + squash),
crate.baseScale.y * (1 - q), crate.baseScale.y * (1 - squash),
crate.baseScale.z * (1 + q), crate.baseScale.z * (1 + squash),
); );
}) })
.onComplete(() => { .onComplete(() => {
@@ -196,30 +203,30 @@ export class LootableC {
if (!state) return; if (!state) return;
if (!this.flashedStates.has(state)) { if (!this.flashedStates.has(state)) {
state.traverse(o => { state.traverse(node => {
const mesh = o as Mesh; const mesh = node as Mesh;
if (!mesh.isMesh) return; if (!mesh.isMesh) return;
mesh.material = Array.isArray(mesh.material) mesh.material = Array.isArray(mesh.material)
? mesh.material.map(m => m.clone()) ? mesh.material.map(material => material.clone())
: (mesh.material as any).clone(); : (mesh.material as any).clone();
}); });
this.flashedStates.add(state); this.flashedStates.add(state);
} }
const mats: any[] = []; const materials: any[] = [];
state.traverse(o => { state.traverse(node => {
const mesh = o as Mesh; const mesh = node as Mesh;
if (!mesh.isMesh) return; if (!mesh.isMesh) return;
(Array.isArray(mesh.material) ? mesh.material : [mesh.material]).forEach(m => mats.push(m)); (Array.isArray(mesh.material) ? mesh.material : [mesh.material]).forEach(material => materials.push(material));
}); });
mats.forEach(m => { if (m.emissive) m.emissive.setRGB(1, 1, 1); }); materials.forEach(material => { if (material.emissive) material.emissive.setRGB(1, 1, 1); });
const s = { k: 1 }; const anim = { intensity: 1 };
new TWEEN.Tween(s, this.tweens) new TWEEN.Tween(anim, this.tweens)
.to({ k: 0 }, FLASH_MS) .to({ intensity: 0 }, FLASH_MS)
.easing(TWEEN.Easing.Quadratic.Out) .easing(TWEEN.Easing.Quadratic.Out)
.onUpdate(({ k }) => { mats.forEach(m => { if (m.emissive) m.emissiveIntensity = k; }); }) .onUpdate(({ intensity }) => { materials.forEach(material => { if (material.emissive) material.emissiveIntensity = intensity; }); })
.onComplete(() => { mats.forEach(m => { if (m.emissive) m.emissiveIntensity = 0; }); }) .onComplete(() => { materials.forEach(material => { if (material.emissive) material.emissiveIntensity = 0; }); })
.start(); .start();
} }
} }
+35 -38
View File
@@ -5,6 +5,7 @@ import { Trigger } from "./TriggerC";
import { TestSceneC } from "./TestSceneC"; import { TestSceneC } from "./TestSceneC";
import { LootC } from "./LootC"; import { LootC } from "./LootC";
import { woodIconUrl } from "../resources/images/woodIcon"; import { woodIconUrl } from "../resources/images/woodIcon";
import { worldToScreen } from "../utils/screen";
// --- Tunables --- // --- Tunables ---
const COST = 15; // wood needed to fully pay the zone const COST = 15; // wood needed to fully pay the zone
@@ -20,8 +21,7 @@ const PLANK_PX = 26; // flying plank size (px)
const FILL_GROW = 0.15; // how much the pad grows when full const FILL_GROW = 0.15; // how much the pad grows when full
const PULSE_MS = 160; // per-plank pulse time const PULSE_MS = 160; // per-plank pulse time
const _v = new Vector3(); const _scratch = new Vector3(); // reused so positioning never allocates
const _ndc = new Vector3();
/** /**
* Pay zone: the player stands on the UI_Tool_Zone pad and the wood collected in * Pay zone: the player stands on the UI_Tool_Zone pad and the wood collected in
@@ -36,10 +36,10 @@ export class PayZoneC {
private static inside = false; // is the player standing on the pad private static inside = false; // is the player standing on the pad
private static done = false; // already paid in full private static done = false; // already paid in full
private static paid = 0; // wood delivered so far private static paid = 0; // wood delivered so far
private static payCd = 0; // cooldown until the next plank private static payCooldown = 0; // seconds until the next plank may fly
private static baseScale = new Vector3(1, 1, 1); // pad resting scale (tweens multiply it) private static baseScale = new Vector3(1, 1, 1); // pad resting scale (tweens multiply it)
private static pulseTween: TWEEN.Tween<{ k: number }> | null = null; private static pulseTween: TWEEN.Tween<{ progress: number }> | null = null;
static init() { static init() {
const zone = TestSceneC.payZone; const zone = TestSceneC.payZone;
@@ -48,7 +48,7 @@ export class PayZoneC {
// Where to put the pad: UI_Interactive_Zone_02's spot, shifted toward the // Where to put the pad: UI_Interactive_Zone_02's spot, shifted toward the
// player, sitting on the real surface (raycast — the road is above the sand). // player, sitting on the real surface (raycast — the road is above the sand).
const target = _v.copy(ZONE_FALLBACK); const target = _scratch.copy(ZONE_FALLBACK);
if (TestSceneC.interactiveZone) TestSceneC.interactiveZone.getWorldPosition(target); if (TestSceneC.interactiveZone) TestSceneC.interactiveZone.getWorldPosition(target);
target.x += ZONE_OFFSET.x; target.x += ZONE_OFFSET.x;
target.z += ZONE_OFFSET.z; target.z += ZONE_OFFSET.z;
@@ -75,7 +75,7 @@ export class PayZoneC {
{ onEnter: () => { this.inside = true; }, onExit: () => { this.inside = false; } }, { onEnter: () => { this.inside = true; }, onExit: () => { this.inside = false; } },
); );
UpdateController.Instance.onUpdate.addDelegate((d) => this.update(d)); UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta));
} }
private static update(delta: number) { private static update(delta: number) {
@@ -83,14 +83,14 @@ export class PayZoneC {
if (this.done || !this.inside) return; if (this.done || !this.inside) return;
// One plank at a time: wait out the cooldown, stop if paid or out of wood. // One plank at a time: wait out the cooldown, stop if paid or out of wood.
this.payCd -= delta; this.payCooldown -= delta;
if (this.payCd > 0) return; if (this.payCooldown > 0) return;
if (this.paid >= COST || LootC.getBalance() <= 0) return; if (this.paid >= COST || LootC.getBalance() <= 0) return;
// Spend one wood and fly it into the zone. // Spend one wood and fly it into the zone.
LootC.spend(1); LootC.spend(1);
this.paid++; this.paid++;
this.payCd = PAY_INTERVAL; this.payCooldown = PAY_INTERVAL;
this.flyOnePlank(); this.flyOnePlank();
if (this.paid >= COST) this.complete(); if (this.paid >= COST) this.complete();
@@ -98,9 +98,9 @@ export class PayZoneC {
// Fly one HTML plank from the UI counter to the zone along an arc. // Fly one HTML plank from the UI counter to the zone along an arc.
private static flyOnePlank() { private static flyOnePlank() {
const src = LootC.uiIconScreenCenter(); const from = LootC.uiIconScreenCenter();
const tgt = this.zoneScreenCenter(); const to = this.zoneScreenCenter();
if (!tgt) return; if (!to) return;
const img = document.createElement("img"); const img = document.createElement("img");
img.src = woodIconUrl; img.src = woodIconUrl;
@@ -109,13 +109,13 @@ export class PayZoneC {
`z-index:1000; pointer-events:none; transform:translate(-50%,-50%); will-change:left,top;`; `z-index:1000; pointer-events:none; transform:translate(-50%,-50%); will-change:left,top;`;
document.body.appendChild(img); document.body.appendChild(img);
const st = { t: 0 }; const anim = { progress: 0 };
new TWEEN.Tween(st, this.tweens) new TWEEN.Tween(anim, this.tweens)
.to({ t: 1 }, FLY_MS) .to({ progress: 1 }, FLY_MS)
.easing(TWEEN.Easing.Quadratic.In) .easing(TWEEN.Easing.Quadratic.In)
.onUpdate(({ t }) => { .onUpdate(({ progress }) => {
const x = src.x + (tgt.x - src.x) * t; const x = from.x + (to.x - from.x) * progress;
const y = src.y + (tgt.y - src.y) * t - Math.sin(Math.PI * t) * FLY_ARC_PX; // arc const y = from.y + (to.y - from.y) * progress - Math.sin(Math.PI * progress) * FLY_ARC_PX; // arc
img.style.left = `${x}px`; img.style.left = `${x}px`;
img.style.top = `${y}px`; img.style.top = `${y}px`;
}) })
@@ -128,15 +128,15 @@ export class PayZoneC {
const zone = this.zone; const zone = this.zone;
if (!zone || this.done) return; if (!zone || this.done) return;
const fill = FILL_GROW * (this.paid / COST); const grow = FILL_GROW * (this.paid / COST); // resting scale bump for the current fill level
this.pulseTween?.stop(); // drop the previous pulse so they don't fight this.pulseTween?.stop(); // drop the previous pulse so they don't fight
const s = { k: 0 }; const anim = { progress: 0 };
this.pulseTween = new TWEEN.Tween(s, this.tweens) this.pulseTween = new TWEEN.Tween(anim, this.tweens)
.to({ k: 1 }, PULSE_MS) .to({ progress: 1 }, PULSE_MS)
.easing(TWEEN.Easing.Back.Out) .easing(TWEEN.Easing.Back.Out)
.onUpdate(({ k }) => { .onUpdate(({ progress }) => {
const g = 1 + fill + (1 - k) * 0.12; // settle at fill size with a small punch const scale = 1 + grow + (1 - progress) * 0.12; // settle at fill size with a small punch
zone.scale.set(this.baseScale.x * g, this.baseScale.y * g, this.baseScale.z * g); zone.scale.set(this.baseScale.x * scale, this.baseScale.y * scale, this.baseScale.z * scale);
}) })
.onComplete(() => { this.pulseTween = null; }) .onComplete(() => { this.pulseTween = null; })
.start(); .start();
@@ -153,16 +153,17 @@ export class PayZoneC {
const zone = this.zone; const zone = this.zone;
if (!zone) return; if (!zone) return;
const setScale = (g: number) => zone.scale.set(this.baseScale.x * g, this.baseScale.y * g, this.baseScale.z * g); const setScale = (scale: number) =>
zone.scale.set(this.baseScale.x * scale, this.baseScale.y * scale, this.baseScale.z * scale);
const grow = new TWEEN.Tween({ k: 0 }, this.tweens) const grow = new TWEEN.Tween({ progress: 0 }, this.tweens)
.to({ k: 1 }, 180) .to({ progress: 1 }, 180)
.easing(TWEEN.Easing.Back.Out) .easing(TWEEN.Easing.Back.Out)
.onUpdate(({ k }) => setScale(1 + 0.45 * k)); .onUpdate(({ progress }) => setScale(1 + 0.45 * progress));
const vanish = new TWEEN.Tween({ k: 0 }, this.tweens) const vanish = new TWEEN.Tween({ progress: 0 }, this.tweens)
.to({ k: 1 }, 260) .to({ progress: 1 }, 260)
.easing(TWEEN.Easing.Back.In) .easing(TWEEN.Easing.Back.In)
.onUpdate(({ k }) => setScale(1.45 * Math.max(0, 1 - k))) .onUpdate(({ progress }) => setScale(1.45 * Math.max(0, 1 - progress)))
.onComplete(() => { .onComplete(() => {
zone.visible = false; zone.visible = false;
if (TestSceneC.payZoneIcon) TestSceneC.payZoneIcon.visible = false; // hide the icon too if (TestSceneC.payZoneIcon) TestSceneC.payZoneIcon.visible = false; // hide the icon too
@@ -191,11 +192,7 @@ export class PayZoneC {
const zone = this.zone; const zone = this.zone;
if (!cam || !canvas || !zone) return null; if (!cam || !canvas || !zone) return null;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
_v.copy(zone.position); _v.y += 0.3; _scratch.copy(zone.position); _scratch.y += 0.3; // aim a little above the pad's base
_ndc.copy(_v).project(cam); return worldToScreen(_scratch, cam, rect);
return {
x: rect.left + (_ndc.x * 0.5 + 0.5) * rect.width,
y: rect.top + (-_ndc.y * 0.5 + 0.5) * rect.height,
};
} }
} }
+37 -94
View File
@@ -1,11 +1,10 @@
import { import { Physics_internal } from "@24tools/playable_template";
Delegate,
Physics_internal,
UpdateController,
} from "@24tools/playable_template";
import { Box3, Object3D, Vector3 } from "three"; import { Box3, Object3D, Vector3 } from "three";
import { Body, Box, Quaternion, Sphere, Vec3 } from "cannon-es"; import { Body, Box, Quaternion, Sphere, Vec3 } from "cannon-es";
// Collision layers. Each body has a `group` (what it IS) and a `mask` (what it
// COLLIDES WITH); two bodies interact only if each one's group is in the other's
// mask. Values are bit flags so masks can be OR-combined (e.g. Wall | Trigger).
export enum PhysicsLayer { export enum PhysicsLayer {
Player = 1, Player = 1,
Wall = 2, Wall = 2,
@@ -13,117 +12,61 @@ export enum PhysicsLayer {
Enemy = 8, Enemy = 8,
} }
/**
* Wraps a three.js object in a cannon-es rigid body. The shape is derived from
* the object: the player gets a Sphere (rolls smoothly along walls/floor),
* everything else gets a Box sized to the object's bounding box.
*/
export class PhysicsBody { export class PhysicsBody {
private body: Body; private body: Body;
private pair: PhysicsObjPair | null = null;
constructor( constructor(
threeObj: Object3D, object: Object3D,
trigger: boolean, isTrigger: boolean,
mass: number, mass: number,
col_group: PhysicsLayer, collisionGroup: PhysicsLayer,
col_mask: PhysicsLayer, collisionMask: PhysicsLayer,
player_sphere: number = 0.3 sphereRadius = 0.3,
) { ) {
let isPlayer = col_group === PhysicsLayer.Player; const isPlayer = collisionGroup === PhysicsLayer.Player;
let oldQuaternion = threeObj.quaternion.clone(); // Measure the bounding box with rotation temporarily zeroed, so the box
// half-extents match the object's un-rotated size; the body's own
let nullQuaternion = new Quaternion(); // quaternion (set below) then applies the real orientation.
threeObj.quaternion.copy(nullQuaternion); const savedRotation = object.quaternion.clone();
object.quaternion.copy(new Quaternion());
let bbox = new Box3().setFromObject(threeObj); const size = new Box3().setFromObject(object).getSize(new Vector3());
object.quaternion.copy(savedRotation);
let size = new Vector3();
bbox.getSize(size);
// if you need custom size
// if (col_group === PhysicsLayer.wall) {
// size.x = size.z = 1;
// size.y = 1;
// }
threeObj.quaternion.copy(oldQuaternion);
this.body = new Body({ this.body = new Body({
isTrigger: trigger, isTrigger,
mass: mass, mass,
//shape: shape,
shape: isPlayer shape: isPlayer
? new Sphere(player_sphere) ? new Sphere(sphereRadius)
: new Box(new Vec3(size.x / 2, size.y / 2, size.z / 2)), : new Box(new Vec3(size.x / 2, size.y / 2, size.z / 2)),
collisionFilterGroup: col_group, collisionFilterGroup: collisionGroup,
collisionFilterMask: col_mask, collisionFilterMask: collisionMask,
}); });
let worldPos = threeObj.getWorldPosition(new Vector3()); const worldPos = object.getWorldPosition(new Vector3());
this.body.position.set(worldPos.x, worldPos.y, worldPos.z); this.body.position.set(worldPos.x, worldPos.y, worldPos.z);
this.body.quaternion.setFromEuler( this.body.quaternion.setFromEuler(
threeObj.rotation.x, object.rotation.x,
threeObj.rotation.y, object.rotation.y,
threeObj.rotation.z, object.rotation.z,
"XYZ" "XYZ",
); );
// if you need sync three obj and physics body Physics_internal.physicsWorld?.addBody(this.body);
// if (isEnemy) {
// let pair = new PhysicsObjPair(threeObj, this.body);
// PhysicsC_Instance.addPhysicsPair(pair);
// this.pair = pair;
// }
Physics_internal.physicsWorld &&
Physics_internal.physicsWorld.addBody(this.body);
return this;
} }
disablePhysicsPair() { /** The underlying cannon body (for direct velocity/position control). */
if (this.pair) { getPhysicsBody(): Body {
this.pair.destroyed = true;
}
}
getPhysicsBody() {
return this.body; return this.body;
} }
/** Remove the body from the physics world (e.g. when a crate breaks). */
destroy() { destroy() {
if (!Physics_internal.physicsWorld) return; Physics_internal.physicsWorld?.removeBody(this.body);
Physics_internal.physicsWorld.removeBody(this.body);
(this.body as any) = null;
}
}
export class PhysicsObjPair {
threeObj: Object3D;
physicsObj: Body;
destroyed: boolean;
delegateId: null | Delegate<number>;
constructor(threeObj: Object3D, physicsObj: Body) {
this.threeObj = threeObj;
this.physicsObj = physicsObj;
this.destroyed = false;
this.delegateId = UpdateController.Instance.onUpdate.addDelegate(() => {
this.update();
});
}
update() {
if (this.destroyed) return;
if (this.threeObj && this.physicsObj) {
// @ts-ignore
this.threeObj.position.copy(this.physicsObj.position);
// @ts-ignore
this.threeObj.quaternion.copy(this.physicsObj.quaternion);
}
} }
} }
+69 -32
View File
@@ -1,5 +1,5 @@
import { CameraC_internal, JoystickC, ThreeC_internal, UpdateController } from "@24tools/playable_template"; import { CameraC_internal, JoystickC, ThreeC_internal, UpdateController } from "@24tools/playable_template";
import { AnimationAction, AnimationMixer, Mesh, Object3D, Raycaster, Vector3 } from "three"; import { AnimationAction, AnimationMixer, LoopOnce, Mesh, Object3D, Raycaster, Vector3 } from "three";
import { Body } from "cannon-es"; import { Body } from "cannon-es";
import { PhysicsBody, PhysicsLayer } from "./PhysicsC"; import { PhysicsBody, PhysicsLayer } from "./PhysicsC";
@@ -41,6 +41,9 @@ export class PlayerC {
private static state = MoveState.Idle; private static state = MoveState.Idle;
private static currentAction: AnimationAction | null = null; private static currentAction: AnimationAction | null = null;
// Set once the player dies (invasion timer ran out): freezes input & combat.
private static dead = false;
// Attack state (auto-attacking a nearby crate). // Attack state (auto-attacking a nearby crate).
private static attacking = false; private static attacking = false;
private static attackTarget = new Vector3(); // world point to face while attacking private static attackTarget = new Vector3(); // world point to face while attacking
@@ -106,6 +109,7 @@ export class PlayerC {
* locomotion animation state machine is suspended until this is turned off. * locomotion animation state machine is suspended until this is turned off.
*/ */
static setAttacking(active: boolean, targetPos: Vector3 | null = null) { static setAttacking(active: boolean, targetPos: Vector3 | null = null) {
if (this.dead) return; // no combat once dead
if (active && targetPos) { this.attackTarget.copy(targetPos); this.hasAttackTarget = true; } if (active && targetPos) { this.attackTarget.copy(targetPos); this.hasAttackTarget = true; }
if (this.attacking === active) return; if (this.attacking === active) return;
this.attacking = active; this.attacking = active;
@@ -139,6 +143,35 @@ export class PlayerC {
this.finishTimer = ATTACK_FOLLOW_THROUGH; this.finishTimer = ATTACK_FOLLOW_THROUGH;
} }
/** Kill the player: stop control/combat and play the one-shot Death clip. */
static die() {
if (this.dead) return;
this.dead = true;
this.attacking = false;
this.finishing = false;
this.hasAttackTarget = false;
this.inputDir.set(0, 0, 0);
this.velocity.set(0, 0, 0);
this.body.velocity.set(0, 0, 0);
// Find the "Death" clip (exact, then any name containing "death").
let death = this.actions.get("Death");
if (!death) for (const [n, a] of this.actions) { if (n.toLowerCase().includes("death")) { death = a; break; } }
if (!death) return;
this.currentAction?.fadeOut(0.2);
death.reset();
death.setLoop(LoopOnce, 1);
death.clampWhenFinished = true; // hold the last (dead) frame
death.fadeIn(0.2).play();
this.currentAction = death;
}
/** True once the player has died. */
static isDead(): boolean {
return this.dead;
}
// ── Private ──────────────────────────────────────────────────────────────── // ── Private ────────────────────────────────────────────────────────────────
private static setupWeapon() { private static setupWeapon() {
@@ -195,16 +228,6 @@ export class PlayerC {
return this.velocity.length() > 0.05; 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() { private static setupAnimations() {
const gltf = ThreeC_internal.getMesh("character"); const gltf = ThreeC_internal.getMesh("character");
this.mixer = new AnimationMixer(this.mesh); this.mixer = new AnimationMixer(this.mesh);
@@ -262,6 +285,18 @@ export class PlayerC {
} }
private static update(delta: number) { private static update(delta: number) {
// Dead: freeze in place (the Death clip plays via the global mixer). Still
// follow the ground so the body doesn't float, but take no input/combat.
if (this.dead) {
this.velocity.set(0, 0, 0);
this.body.velocity.set(0, 0, 0);
const surfaceY = this.sampleGroundY();
this._currentSurfaceY += (surfaceY - this._currentSurfaceY) * Math.min(1, GROUND_SMOOTH * delta);
this.body.position.y = this._currentSurfaceY + CHAR_RADIUS;
this.mesh.position.set(this.body.position.x, this._currentSurfaceY, this.body.position.z);
return;
}
// Smoothly ramp the desired planar velocity toward the input target. // Smoothly ramp the desired planar velocity toward the input target.
_inputTarget.copy(this.inputDir).multiplyScalar(this.maxSpeed); _inputTarget.copy(this.inputDir).multiplyScalar(this.maxSpeed);
this.velocity.lerp(_inputTarget, Math.min(1, this.acceleration * delta)); this.velocity.lerp(_inputTarget, Math.min(1, this.acceleration * delta));
@@ -293,17 +328,17 @@ export class PlayerC {
if (this.attacking) { if (this.attacking) {
if (this.hasAttackTarget) this.faceTowards(this.attackTarget.x, this.attackTarget.z, delta); if (this.hasAttackTarget) this.faceTowards(this.attackTarget.x, this.attackTarget.z, delta);
// Which hit-window the Loot clip is in now (-1 = none → bat can't hit). // Which hit-window the Loot clip is in now (-1 = none → bat can't hit).
const a = this.attackAction; const action = this.attackAction;
if (a) { if (action) {
const dur = a.getClip().duration; const duration = action.getClip().duration;
const phase = dur > 0 ? (a.time % dur) / dur : 0; // 0..1 within the clip const phase = duration > 0 ? (action.time % duration) / duration : 0; // 0..1 within the clip
let wi = -1; let windowIndex = -1;
for (let i = 0; i < IMPACT_PHASES.length; i++) { for (let i = 0; i < IMPACT_PHASES.length; i++) {
if (Math.abs(phase - IMPACT_PHASES[i]) <= CONTACT_WINDOW) { wi = i; break; } if (Math.abs(phase - IMPACT_PHASES[i]) <= CONTACT_WINDOW) { windowIndex = i; break; }
} }
if (wi !== this._activeWindow) { if (windowIndex !== this._activeWindow) {
if (wi !== -1) this._swingId++; // entered a new window → new swing if (windowIndex !== -1) this._swingId++; // entered a new window → new swing
this._activeWindow = wi; this._activeWindow = windowIndex;
} }
} }
if (this.finishing) { if (this.finishing) {
@@ -346,12 +381,13 @@ export class PlayerC {
if (!this._batEndA || !this._batEndB) { if (!this._batEndA || !this._batEndB) {
if (!bat.geometry.boundingBox) bat.geometry.computeBoundingBox(); if (!bat.geometry.boundingBox) bat.geometry.computeBoundingBox();
const bb = bat.geometry.boundingBox!; const box = 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 centerX = (box.min.x + box.max.x) / 2, centerY = (box.min.y + box.max.y) / 2, centerZ = (box.min.z + box.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; const sizeX = box.max.x - box.min.x, sizeY = box.max.y - box.min.y, sizeZ = box.max.z - box.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); } // The bat's longest axis is its length → its two ends are the box faces on that axis.
else if (sx >= sy) { this._batEndA = new Vector3(bb.min.x, cy, cz); this._batEndB = new Vector3(bb.max.x, cy, cz); } if (sizeZ >= sizeX && sizeZ >= sizeY) { this._batEndA = new Vector3(centerX, centerY, box.min.z); this._batEndB = new Vector3(centerX, centerY, box.max.z); }
else { this._batEndA = new Vector3(cx, bb.min.y, cz); this._batEndB = new Vector3(cx, bb.max.y, cz); } else if (sizeX >= sizeY) { this._batEndA = new Vector3(box.min.x, centerY, centerZ); this._batEndB = new Vector3(box.max.x, centerY, centerZ); }
else { this._batEndA = new Vector3(centerX, box.min.y, centerZ); this._batEndB = new Vector3(centerX, box.max.y, centerZ); }
} }
bat.updateWorldMatrix(true, false); bat.updateWorldMatrix(true, false);
@@ -364,12 +400,13 @@ export class PlayerC {
// Smoothly rotate the mesh's Y so it faces the given world x/z point. // Smoothly rotate the mesh's Y so it faces the given world x/z point.
private static faceTowards(x: number, z: number, delta: number) { private static faceTowards(x: number, z: number, delta: number) {
const dx = x - this.mesh.position.x; const deltaX = x - this.mesh.position.x;
const dz = z - this.mesh.position.z; const deltaZ = z - this.mesh.position.z;
if (dx * dx + dz * dz < 1e-4) return; if (deltaX * deltaX + deltaZ * deltaZ < 1e-4) return;
const targetAngle = Math.atan2(dx, dz); const targetAngle = Math.atan2(deltaX, deltaZ);
const diff = ((targetAngle - this.mesh.rotation.y + Math.PI * 3) % (Math.PI * 2)) - Math.PI; // Shortest signed turn into the -π..π range so we never spin the long way round.
this.mesh.rotation.y += diff * Math.min(1, this.rotateSpeed * delta); const angleDiff = ((targetAngle - this.mesh.rotation.y + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
this.mesh.rotation.y += angleDiff * Math.min(1, this.rotateSpeed * delta);
} }
private static findAction(state: MoveState): AnimationAction | null { private static findAction(state: MoveState): AnimationAction | null {
+48 -33
View File
@@ -4,7 +4,6 @@ import { Box3, Mesh, Object3D, Vector3 } from "three";
import { Body, Box, Vec3 } from "cannon-es"; import { Body, Box, Vec3 } from "cannon-es";
import { PhysicsBody, PhysicsLayer } from "./PhysicsC"; import { PhysicsBody, PhysicsLayer } from "./PhysicsC";
export class TestSceneC { export class TestSceneC {
static mapObject: Object3D; static mapObject: Object3D;
static characterObject: Object3D; static characterObject: Object3D;
@@ -16,6 +15,7 @@ export class TestSceneC {
static payZone: Object3D | null = null; // UI_Tool_Zone — the pay-zone pad static payZone: Object3D | null = null; // UI_Tool_Zone — the pay-zone pad
static payZoneIcon: Object3D | null = null; // UI_Wood — wood icon shown on the pad static payZoneIcon: Object3D | null = null; // UI_Wood — wood icon shown on the pad
static interactiveZone: Object3D | null = null; // UI_Interactive_Zone_02 — marks where the pad goes static interactiveZone: Object3D | null = null; // UI_Interactive_Zone_02 — marks where the pad goes
static healthBarPrototype: Object3D | null = null; // UI — 3-mesh health bar template, cloned per crate
// World Y of the walkable sand surface (the floor proxy is aligned to it). // World Y of the walkable sand surface (the floor proxy is aligned to it).
static groundY = 0; static groundY = 0;
@@ -43,8 +43,10 @@ export class TestSceneC {
this.lootableGroup = this.mapObject.getObjectByName("Lootable") ?? null; this.lootableGroup = this.mapObject.getObjectByName("Lootable") ?? null;
this.payZone = this.mapObject.getObjectByName("UI_Tool_Zone") ?? null; this.payZone = this.mapObject.getObjectByName("UI_Tool_Zone") ?? null;
this.payZoneIcon = this.mapObject.getObjectByName("UI_Wood") ?? null; this.payZoneIcon = this.mapObject.getObjectByName("UI_Wood") ?? null;
this.interactiveZone = this.mapObject.getObjectByName("UI_Interactive_Zone_02") ?? null; this.interactiveZone =
const uiGroup = this.mapObject.getObjectByName("UI") ?? null; this.mapObject.getObjectByName("UI_Interactive_Zone_02") ?? null;
// The "UI" group is the 3-mesh health-bar template; keep it as a hidden prototype.
this.healthBarPrototype = this.mapObject.getObjectByName("UI") ?? null;
// Add the whole graph so every world transform stays intact (the collider // Add the whole graph so every world transform stays intact (the collider
// proxies' world positions depend on the full parent chain), then hide // proxies' world positions depend on the full parent chain), then hide
@@ -60,21 +62,24 @@ export class TestSceneC {
// …then hide the proxies and the groups we are not activating yet. // …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.colliderGroup) this.colliderGroup.visible = false; // physics-only, never rendered
// Lootable crates are set up by LootableC (one state shown + colliders). // 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 (this.healthBarPrototype) this.healthBarPrototype.visible = false; // template only; clones are shown per crate
if (this.interactiveZone) this.interactiveZone.visible = false; // only its position is used if (this.interactiveZone) this.interactiveZone.visible = false; // only its position is used
// payZone + its icon are placed and shown by PayZoneC. // payZone + its icon are placed and shown by PayZoneC.
if (this.payZone) this.payZone.visible = false; if (this.payZone) this.payZone.visible = false;
if (this.payZoneIcon) this.payZoneIcon.visible = false; if (this.payZoneIcon) this.payZoneIcon.visible = false;
// Hide the unused duplicate wood icon (three.js drops the dot → "UI_Wood001"). // Hide the unused duplicate wood icon (three.js drops the dot → "UI_Wood001").
const strayWoodIcon = this.mapObject.getObjectByName("UI_Wood001") const strayWoodIcon =
?? this.mapObject.getObjectByName("UI_Wood.001"); this.mapObject.getObjectByName("UI_Wood001") ??
this.mapObject.getObjectByName("UI_Wood.001");
if (strayWoodIcon) strayWoodIcon.visible = false; if (strayWoodIcon) strayWoodIcon.visible = false;
// Only the environment is rendered with shadows. // Only the environment is rendered with shadows.
if (this.environment) { if (this.environment) {
ThreeC.setShadowsStateForChildren(this.environment, true, true); ThreeC.setShadowsStateForChildren(this.environment, true, true);
} else { } else {
console.warn("[Map] 'Ground_' group not found — nothing to render as environment"); console.warn(
"[Map] 'Ground_' group not found — nothing to render as environment",
);
} }
} }
@@ -83,18 +88,20 @@ export class TestSceneC {
// Lootable and are built separately when crates are activated. // Lootable and are built separately when crates are activated.
private static buildMapPhysics() { private static buildMapPhysics() {
if (!this.colliderGroup) { if (!this.colliderGroup) {
console.warn("[Map] No 'Colliders' group found in GLB — map has no physics"); console.warn(
"[Map] No 'Colliders' group found in GLB — map has no physics",
);
return; return;
} }
this.colliderGroup.traverse(child => { this.colliderGroup.traverse((child) => {
if (!(child instanceof Mesh)) return; if (!(child instanceof Mesh)) return;
const body = new PhysicsBody( const body = new PhysicsBody(
child, child,
false, // not a trigger false, // not a trigger
0, // mass 0 → static 0, // mass 0 → static
PhysicsLayer.Wall, PhysicsLayer.Wall,
PhysicsLayer.Player // collides with the player (other masks added when needed) PhysicsLayer.Player, // collides with the player (other masks added when needed)
); );
this.mapBodies.push(body); this.mapBodies.push(body);
}); });
@@ -109,16 +116,19 @@ export class TestSceneC {
private static alignFloorToGround() { private static alignFloorToGround() {
if (!this.environment || this.mapBodies.length === 0) return; if (!this.environment || this.mapBodies.length === 0) return;
const sand = this.environment.getObjectByName("M_Floor_Sand") ?? this.environment; const sand =
this.environment.getObjectByName("M_Floor_Sand") ?? this.environment;
this.groundY = new Box3().setFromObject(sand).max.y; this.groundY = new Box3().setFromObject(sand).max.y;
for (const pb of this.mapBodies) { for (const physicsBody of this.mapBodies) {
const body = pb.getPhysicsBody(); const body = physicsBody.getPhysicsBody();
const half = (body.shapes[0] as Box).halfExtents.y; const halfHeight = (body.shapes[0] as Box).halfExtents.y;
const top = body.position.y + half; const top = body.position.y + halfHeight;
body.position.y += this.groundY - top; body.position.y += this.groundY - top;
} }
console.log(`[Map] Floor aligned to sand surface Y=${this.groundY.toFixed(3)}`); console.log(
`[Map] Floor aligned to sand surface Y=${this.groundY.toFixed(3)}`,
);
} }
// The GLB only authors a floor collider, so the play area would be unbounded. // The GLB only authors a floor collider, so the play area would be unbounded.
@@ -135,35 +145,37 @@ export class TestSceneC {
const max = bounds.max; const max = bounds.max;
const sizeX = max.x - min.x; const sizeX = max.x - min.x;
const sizeZ = max.z - min.z; const sizeZ = max.z - min.z;
const cx = (min.x + max.x) / 2; const floorCenterX = (min.x + max.x) / 2;
const cz = (min.z + max.z) / 2; const floorCenterZ = (min.z + max.z) / 2;
const t = 0.5; // wall thickness const thickness = 0.5;
const h = 3; // wall height const height = 3;
const midY = max.y + h / 2; // sits on top of the floor const midY = max.y + height / 2; // sits on top of the floor
// [centerX, centerZ, halfX, halfZ] height is shared. Thickness overlaps // Each spec is [centerX, centerZ, halfX, halfZ]; height is shared. The +thickness
// at corners (+t) so there are no gaps. // makes the walls overlap at the corners so there are no gaps.
const specs: [number, number, number, number][] = [ const wallSpecs: [number, number, number, number][] = [
[cx, max.z + t / 2, sizeX / 2 + t, t / 2], // +Z [floorCenterX, max.z + thickness / 2, sizeX / 2 + thickness, thickness / 2], // +Z
[cx, min.z - t / 2, sizeX / 2 + t, t / 2], // -Z [floorCenterX, min.z - thickness / 2, sizeX / 2 + thickness, thickness / 2], // -Z
[max.x + t / 2, cz, t / 2, sizeZ / 2 + t], // +X [max.x + thickness / 2, floorCenterZ, thickness / 2, sizeZ / 2 + thickness], // +X
[min.x - t / 2, cz, t / 2, sizeZ / 2 + t], // -X [min.x - thickness / 2, floorCenterZ, thickness / 2, sizeZ / 2 + thickness], // -X
]; ];
for (const [px, pz, hx, hz] of specs) { for (const [wallX, wallZ, halfX, halfZ] of wallSpecs) {
const body = new Body({ const body = new Body({
mass: 0, mass: 0,
shape: new Box(new Vec3(hx, h / 2, hz)), shape: new Box(new Vec3(halfX, height / 2, halfZ)),
collisionFilterGroup: PhysicsLayer.Wall, collisionFilterGroup: PhysicsLayer.Wall,
collisionFilterMask: PhysicsLayer.Player, collisionFilterMask: PhysicsLayer.Player,
}); });
body.position.set(px, midY, pz); body.position.set(wallX, midY, wallZ);
Physics_internal.physicsWorld.addBody(body); Physics_internal.physicsWorld.addBody(body);
this.boundaryBodies.push(body); this.boundaryBodies.push(body);
} }
console.log(`[Map] Boundary walls built: ${this.boundaryBodies.length} (floor ${sizeX.toFixed(1)}×${sizeZ.toFixed(1)})`); console.log(
`[Map] Boundary walls built: ${this.boundaryBodies.length} (floor ${sizeX.toFixed(1)}×${sizeZ.toFixed(1)})`,
);
} }
private static loadCharacter() { private static loadCharacter() {
@@ -199,7 +211,10 @@ export class TestSceneC {
for (const name of ["Character_Pistol", "Bullet"]) { for (const name of ["Character_Pistol", "Bullet"]) {
const obj = this.characterObject.getObjectByName(name); const obj = this.characterObject.getObjectByName(name);
if (obj) obj.visible = false; if (obj) obj.visible = false;
else console.warn(`[Character] node '${name}' not found while hiding loadout`); else
console.warn(
`[Character] node '${name}' not found while hiding loadout`,
);
} }
} }
} }
+2 -29
View File
@@ -15,37 +15,10 @@ export class ThreeC extends ThreeC_internal {
Template.getValue<number>("global", "light_intensity") Template.getValue<number>("global", "light_intensity")
} }
static setupDirectionalLightFromScene(dirLightObj:DirectionalLight) {
if (!dirLightObj) return;
ThreeC.removeFromScene(this.defaultDirectionalLight);
let light = dirLightObj;
let d = 15;
light.shadow.camera.left = -10;
light.shadow.camera.right = d;
light.shadow.camera.top = d;
light.shadow.camera.bottom = -2;
light.castShadow = true;
light.shadow.normalBias = 0.04;
light.intensity = 1;
light.shadow.mapSize.width = 1024;
light.shadow.mapSize.height = 1024;
const ambLight = new AmbientLight(0xffffff, 0.4); // soft white light
this.addToScene(ambLight);
this.addToScene(light);
this.defaultDirectionalLight = light;
}
static setupDirectionalLight() { static setupDirectionalLight() {
let dirLight = this.defaultDirectionalLight; const dirLight = this.defaultDirectionalLight;
dirLight.position.set(8, 10, 4); //default; light shining from top dirLight.position.set(8, 10, 4); // shines down from the top-front
dirLight.target.position.set(0, 0, 0); dirLight.target.position.set(0, 0, 0);
// Real-time shadows are disabled on purpose. The character ships its own // Real-time shadows are disabled on purpose. The character ships its own
+5 -2
View File
@@ -38,8 +38,11 @@ html {
} }
canvas { canvas {
width: 100%; /* !important beats the renderer's inline px size so the canvas always fills
height: 100%; the layout viewport — needed when we lock the viewport to a 360px min width
and let the browser scale the whole page (canvas + HUD) down together. */
width: 100% !important;
height: 100% !important;
} }
#debug_label { #debug_label {
-4
View File
@@ -1,4 +0,0 @@
#ui {
/* flex-basis: 60%; */
flex-grow: 1;
}
+363
View File
@@ -0,0 +1,363 @@
@use "sass:math";
#ui {
flex-grow: 1;
}
/* ============================================================
HUD — screen-space overlay built by HudC.
Structure follows BEM: each visual block (invasion bar, resources,
weapon, tutorial hint, death end-card) is a `.hud-<block>` class with
`&__element` children and `&--modifier` states. SCSS nesting (`&`) keeps
each block's rules in one place so we never repeat the prefix.
The whole HUD is viewport-relative: lengths use vw via the fluid helpers
below, so it scales as one piece with the device.
============================================================ */
// Core fluid helper: a clamp() that grows linearly with the viewport from $min
// (at $vp-min) to $max (at $vp-max), and stays flat outside that range.
//
// The unit it scales against is the CSS variable --fluid-unit, set on #hud:
// 1vw in portrait (scale with width) and 1vh in landscape (scale with the
// limiting height). Landscape screens are wide but short, so scaling by width
// there blows the HUD up — height keeps it proportional.
@function fluid-clamp($min, $max, $vp-min: 320px, $vp-max: 900px) {
$slope: math.div($max - $min, $vp-max - $vp-min); // px/px → unitless
$intercept: $min - $slope * $vp-min; // px
@return clamp(#{$min}, #{$intercept} + #{$slope * 100} * var(--fluid-unit, 1vw), #{$max});
}
// Fluid font-size from a px range.
@mixin fluid($min, $max) {
font-size: fluid-clamp($min, $max);
}
// Fluid image size from px ranges. Pass 2 args for a square image (height reuses
// the width range) or 4 args for a non-square one (separate height range).
@mixin fluid-img($wmin, $wmax, $hmin: null, $hmax: null) {
width: fluid-clamp($wmin, $wmax);
@if $hmin == null {
height: fluid-clamp($wmin, $wmax); // square: reuse the width range
} @else if $hmax == null {
height: fluid-clamp($hmin, $wmax); // only a min height given: cap at the width max
} @else {
height: fluid-clamp($hmin, $hmax); // full height range given
}
}
// Fluid value for any single property from a px range — bounds it between
// $min/$max while it scales with the viewport (e.g. to cap the invasion bar).
@mixin fluid-prop($prop, $min, $max) {
#{$prop}: fluid-clamp($min, $max);
}
#hud {
position: fixed;
max-width: 1280px;
margin: 0 auto;
padding-inline: 16px;
inset: 0;
z-index: 9999;
pointer-events: none;
font-family: "PassionOne", "Roboto", sans-serif;
// Portrait: every fluid size scales with viewport width.
--fluid-unit: 1vw;
@media screen and (min-width: 500px) {
padding-inline: 24px;
}
// Landscape: scale with height instead (short screens → keep the HUD small).
@media (orientation: landscape) {
--fluid-unit: 1vh;
}
}
/* ---- Zombie Invasion bar (top) ----------------------------
"HURRY UP" and the countdown share this one container and crossfade: the
--hurry-phase modifier shows the hurry text and hides the bar; dropping it
reveals the bar. The zombie head rides the fill's leading edge. */
.hud-invasion {
position: absolute;
min-height: 80px;
padding-inline: 64px;
top: 0.5rem;
left: 50%;
transform: translate(-50%);
width: 100%;
max-width: 768px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.25rem;
transition: opacity 0.4s ease;
@media (orientation: landscape) {
padding-inline: 80px;
}
// Fully hidden (before the onboarding reaches the invasion stage, and after death).
&--hidden {
opacity: 0;
}
&__title {
@include fluid(24px, 48px);
text-align: center;
color: #fff;
letter-spacing: 1px;
-webkit-text-stroke: 1px #000;
-webkit-text-fill-color: #fff;
}
&__row {
display: flex;
align-items: center;
}
// Holds the track + the head that rides the fill's moving edge.
&__bar {
position: relative;
flex: 1;
display: flex;
align-items: center;
}
&__track {
width: 100%;
@include fluid-prop(height, 20px, 32px); // bound the bar thickness so it can't blow up
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.45));
border: 2px solid #000;
border-radius: 1000px;
overflow: hidden;
}
&__fill {
height: 100%;
width: 100%;
// glossy green: highlight on top → base → darker bottom (width set per-frame in JS)
background: linear-gradient(to bottom, #c2f57a 0%, #7fd23a 45%, #4f9e18 100%);
// Final stretch (low time left): glossy red.
&--low {
background: linear-gradient(to bottom, #ff9a6a 0%, #f0512c 45%, #c5301a 100%);
}
}
// The zombie head sits on the fill's leading edge; `left` is set per-frame in
// JS (0%..100% of the bar). The transform here only centers it — the shake
// animation lives on the inner <img>, so the two transforms don't fight.
&__head-wrap {
position: absolute;
top: 50%;
left: 100%;
transform: translate(-50%, -50%);
z-index: 2;
}
&__head {
@include fluid-img(44px, 70px);
display: block; // kill the inline-image descender gap so it centers on the track
object-fit: contain;
filter: drop-shadow(0 0.5vw 0.8vw rgba(0, 0, 0, 0.5));
flex: none;
transform-origin: center;
// Half time left: the icon grows and shakes to draw attention.
&--alert {
animation: hud-zombie-shake 0.45s ease-in-out infinite;
}
// Red zone: shake harder + bigger. Declared after --alert so it wins when both are set.
&--alert-strong {
animation: hud-zombie-shake-strong 0.28s ease-in-out infinite;
}
}
// "HURRY UP" overlays the bar in the same box; absolute + inset:0 centers it
// over the bar regardless of the container's side padding.
&__hurry {
@include fluid(48px, 64px);
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
color: #fff;
letter-spacing: 1px;
-webkit-text-stroke: 1px #000;
opacity: 0;
transition: opacity 0.35s ease;
pointer-events: none;
}
// Crossfade between the two states (both transition opacity).
&__title,
&__row {
transition: opacity 0.35s ease;
}
&--hurry-phase &__hurry {
opacity: 1;
}
&--hurry-phase &__title,
&--hurry-phase &__row {
opacity: 0;
}
}
/* ---- Resources (top-right) -------------------------------- */
.hud-resources {
position: absolute;
top: 20%;
right: 16px;
display: flex;
flex-direction: column;
gap: 0.1rem;
align-items: flex-end;
@media screen and (min-width: 500px) {
right: 24px;
}
@media (orientation: landscape) {
top: 30%;
}
// One resource panel. The background art already includes the icon, so the
// whole panel is both the loot fly-target and the thing that pulses on collect.
&__panel {
@include fluid-img(64px, 96px, 30px, 46px);
position: relative;
background-size: 100% 100%;
background-repeat: no-repeat;
transition: transform 0.12s ease-out;
}
&__count {
@include fluid(13px, 22px);
position: absolute;
left: 6%;
right: 28%; // keep the number clear of the colored tab on the right
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-family: Roboto, sans-serif;
font-weight: 600;
}
}
/* ---- Weapon panel (bottom-left) --------------------------- */
.hud-weapon {
@include fluid-img(64px, 80px);
position: absolute;
bottom: 25%;
background-size: 100% 100%;
background-repeat: no-repeat;
display: flex;
align-items: center;
justify-content: center;
&__icon {
width: 70%;
height: 70%;
position: relative;
top: -5px;
object-fit: contain;
}
&__lvl {
@include fluid(14px, 18px);
font-family: Roboto, sans-serif;
font-weight: 600;
position: absolute;
bottom: 1%;
left: 62%;
transform: translateX(-50%);
color: #fff;
letter-spacing: 1px;
white-space: nowrap;
}
}
/* ---- Onboarding hint -------------------------------------- */
/* Centered hint ("DRAG TO MOVE") that fades + scales in/out. */
.hud-tutorial {
@include fluid(30px, 56px);
position: absolute;
top: 63%;
left: 50%;
transform: translate(-50%, -50%) scale(0.9);
text-align: center;
color: #fff;
letter-spacing: 1px;
-webkit-text-stroke: 1px #000;
opacity: 0;
transition:
opacity 0.25s ease,
transform 0.25s ease;
white-space: nowrap;
&--show {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
/* ---- Death end-card --------------------------------------- */
/* The zombie icon flies to center (smooth ease-out + fade), then the button. */
.hud-endcard {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.3rem;
&__zombie {
@include fluid-img(150px, 300px);
object-fit: contain;
filter: drop-shadow(0 1vw 2vw rgba(0, 0, 0, 0.6));
opacity: 0;
transition:
transform 1s cubic-bezier(0.16, 1, 0.3, 1),
opacity 0.6s ease;
will-change: transform, opacity;
}
// Revealed once the death animation has played (set in JS).
&--show .hud-endcard__zombie {
opacity: 1;
}
}
@keyframes hud-zombie-shake {
0%,
100% {
transform: scale(1.25) rotate(0deg);
}
25% {
transform: scale(1.25) rotate(-12deg);
}
75% {
transform: scale(1.25) rotate(12deg);
}
}
@keyframes hud-zombie-shake-strong {
0%,
100% {
transform: scale(1.45) rotate(0deg);
}
25% {
transform: scale(1.45) rotate(-22deg);
}
75% {
transform: scale(1.45) rotate(22deg);
}
}
View File
-4
View File
@@ -1,4 +0,0 @@
export enum VFXType {
HitEffect,
DestroyEffect
}
Binary file not shown.
+20
View File
@@ -0,0 +1,20 @@
import { ConvertToBase64WhenRelease } from "@24tools/ads_common";
// Heavy display font used for the HUD labels (matches the REF look). Inlined as
// base64 on release. We inject an @font-face at runtime so it works in dev too.
const passionOneUrl = ConvertToBase64WhenRelease("./PassionOne-Black.otf");
export const PASSION_ONE = "PassionOne";
let injected = false;
/** Register the PassionOne font once. Safe to call multiple times. */
export function ensurePassionOne() {
if (injected) return;
injected = true;
const style = document.createElement("style");
style.textContent =
`@font-face{font-family:'${PASSION_ONE}';` +
`src:url(${passionOneUrl}) format('opentype');font-weight:900;font-display:swap;}`;
document.head.appendChild(style);
}
+1 -1
View File
@@ -11,7 +11,7 @@
<link href="./css/loader.css" rel="stylesheet" /> <link href="./css/loader.css" rel="stylesheet" />
<link href="./css/main.css" rel="stylesheet" /> <link href="./css/main.css" rel="stylesheet" />
<link href="./css/ui.css" rel="stylesheet" /> <link href="./css/ui.scss" rel="stylesheet" />
<style id="animations"></style> <style id="animations"></style>
</head> </head>
<body> <body>
+4 -2
View File
@@ -9,16 +9,18 @@ import { customFont } from "./fonts/customFont";
import { configUIParams } from "./configUIParams/configUIParams"; import { configUIParams } from "./configUIParams/configUIParams";
import { Template, Template3d } from "@24tools/playable_template"; import { Template, Template3d } from "@24tools/playable_template";
import { firstClickCb } from "./templateConfig/firstClickCb"; import { firstClickCb } from "./templateConfig/firstClickCb";
Template.set24ADSControls(); Template.set24ADSControls();
window.setupConfig = async function (config) { window.setupConfig = async function (config) {
Template.initConfig({ Template.initConfig({
templateType: TemplateType["3d"], templateType: TemplateType["3d"],
redirectOptions: {}, redirectOptions: {},
ticker: Template3d.ticker, ticker: Template3d.ticker,
debug: { debug: {
physics: true, physics: false,
// set true if you want to enable physics debugger // set true if you want to enable physics debugger
logger: true // set true if you want to enable logger logger: false // set true if you want to enable logger
} }
}).init({ }).init({
config: config || formConfigForPlayable(formConfigUI({ config: config || formConfigForPlayable(formConfigUI({
@@ -0,0 +1,9 @@
import { ConvertToBase64WhenRelease } from "@24tools/ads_common";
// HUD image URLs. In a release build each is inlined as base64 (like the loot
// icon). Paths are relative to this file. Usable directly in <img src> or CSS url().
export const zombieHeadUrl = ConvertToBase64WhenRelease("./Icon_Zombie_Head.webp"); // invasion bar + death end-card
export const woodPanelUrl = ConvertToBase64WhenRelease("./ResourceBackground_Wood.webp");
export const metalPanelUrl = ConvertToBase64WhenRelease("./ResourceBackground_Metal.webp");
export const toolPanelUrl = ConvertToBase64WhenRelease("./Tool_Backgtound.webp"); // weapon panel background
export const toolIconUrl = ConvertToBase64WhenRelease("./Tool_1.webp"); // weapon icon
+5 -21
View File
@@ -14,14 +14,7 @@ export class VfxManager {
this.batchRenderer = new BatchedRenderer(); this.batchRenderer = new BatchedRenderer();
this.loader = new QuarksLoader(); this.loader = new QuarksLoader();
ThreeC.addToScene(this.batchRenderer); ThreeC.addToScene(this.batchRenderer);
const updateDelegate = UpdateController.Instance.onUpdate.addDelegate(this.update.bind(this)); UpdateController.Instance.onUpdate.addDelegate(this.update.bind(this));
// GameTimer.onDelayGameEnd.addDelegate(() => UpdateController.Instance.onUpdate.removeListeners(updateDelegate));
// initTrailEffect();
}
static Remove(vfx: Object3D) {
vfx.removeFromParent();
vfx.parent = null;
} }
static update(delta: number) { static update(delta: number) {
@@ -36,7 +29,10 @@ export class VfxManager {
scale: Vector3 | null = null, scale: Vector3 | null = null,
renderOrder: number | null = null, renderOrder: number | null = null,
): Object3D { ): Object3D {
const resource = ResourcesC.getResource<{ obj: Object3D } | undefined>(VFX_RESOURCE_TYPE, name); const resource = ResourcesC.getResource<{ obj: Object3D } | undefined>(
VFX_RESOURCE_TYPE,
name,
);
if (!resource?.obj) return new Object3D(); if (!resource?.obj) return new Object3D();
const effect = resource.obj.clone(true); const effect = resource.obj.clone(true);
@@ -52,16 +48,4 @@ export class VfxManager {
return effect; return effect;
} }
static StopEmision(effect: Object3D) {
QuarksUtil.stop(effect);
} }
static Restart(effect: Object3D) {
QuarksUtil.play(effect);
}
static Pause(effect: Object3D) {
QuarksUtil.pause(effect);
}
}
+8 -4
View File
@@ -7,11 +7,15 @@ export const vfx_json: ConvertResourceType = {
resources: [ resources: [
{ {
name: "HitEffect", name: "HitEffect",
value: ConvertToBase64WhenRelease("resources/vfx/files/VFX_Lootable_Hit.json"), value: ConvertToBase64WhenRelease(
"resources/vfx/files/VFX_Lootable_Hit.json",
),
}, },
{ {
name: "DestroyEffect", name: "DestroyEffect",
value: ConvertToBase64WhenRelease("resources/vfx/files/VFX_Lootable_Destroy.json"), value: ConvertToBase64WhenRelease(
"resources/vfx/files/VFX_Lootable_Destroy.json",
),
}, },
{ {
name: "Test", name: "Test",
@@ -19,7 +23,7 @@ export const vfx_json: ConvertResourceType = {
}, },
], ],
loader: quarksLoader, loader: quarksLoader,
} };
export function quarksLoader(base64String: string) { export function quarksLoader(base64String: string) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -28,7 +32,7 @@ export function quarksLoader(base64String: string) {
JSON.parse(atob(base64String.split(",")[1])), JSON.parse(atob(base64String.split(",")[1])),
(obj) => { (obj) => {
resolve({ obj }); resolve({ obj });
} },
); );
} catch (error) { } catch (error) {
reject("Error loading vfx: " + error); reject("Error loading vfx: " + error);
+9 -5
View File
@@ -7,6 +7,8 @@ import { CombatC } from "../controllers/CombatC";
import { JoystickC, SoundC, Template } from "@24tools/playable_template"; import { JoystickC, SoundC, Template } from "@24tools/playable_template";
import { LootC } from "../controllers/LootC"; import { LootC } from "../controllers/LootC";
import { PayZoneC } from "../controllers/PayZoneC"; import { PayZoneC } from "../controllers/PayZoneC";
import { HudC } from "../controllers/HudC";
import { HealthBarC } from "../controllers/HealthBarC";
import { VfxManager } from "../resources/vfx/VfxManager"; import { VfxManager } from "../resources/vfx/VfxManager";
export const afterResourcesLoadedCb: (() => void) | undefined = async () => { export const afterResourcesLoadedCb: (() => void) | undefined = async () => {
@@ -39,9 +41,16 @@ export const afterResourcesLoadedCb: (() => void) | undefined = async () => {
FollowCameraC.init(TestSceneC.characterObject); FollowCameraC.init(TestSceneC.characterObject);
// HUD: screen overlay (invasion timer, resource counters, weapon, avatar).
// Built before LootC so the wood icon/count elements exist for the loot flight.
HudC.init();
// Crates: show one state + give each a solid collider. // Crates: show one state + give each a solid collider.
LootableC.init(TestSceneC.lootableGroup); LootableC.init(TestSceneC.lootableGroup);
// Health bars: floating bars above crates, cloned from the GLB "UI" prototype.
HealthBarC.init(TestSceneC.healthBarPrototype);
// Trigger system: start listening for player-vs-trigger overlaps. // Trigger system: start listening for player-vs-trigger overlaps.
TriggerC.init(PlayerC.getBody()); TriggerC.init(PlayerC.getBody());
@@ -57,10 +66,5 @@ export const afterResourcesLoadedCb: (() => void) | undefined = async () => {
// VFX: set up the quark particle renderer. // VFX: set up the quark particle renderer.
VfxManager.init(); VfxManager.init();
// if (import.meta.env.DEV) {
// const { CameraDebugUI } = await import("../controllers/CameraDebugUI");
// CameraDebugUI.init();
// }
Template.disableLoader(); Template.disableLoader();
}; };
+24
View File
@@ -0,0 +1,24 @@
import { Camera, Vector3 } from "three";
// Scratch vector reused across calls so projecting never allocates per frame.
const _ndc = new Vector3();
/**
* Project a world-space point to screen pixels.
*
* `camera.project()` gives Normalized Device Coordinates (NDC): the visible area
* maps to -1..1 on both axes, with +Y up. We remap that to CSS pixels and offset
* by the canvas position on the page (`canvasRect`), flipping Y because the DOM
* grows downward. Used to fly HTML elements (loot, planks) to/from 3D objects.
*/
export function worldToScreen(
world: Vector3,
camera: Camera,
canvasRect: DOMRect,
): { x: number; y: number } {
_ndc.copy(world).project(camera);
return {
x: canvasRect.left + (_ndc.x * 0.5 + 0.5) * canvasRect.width,
y: canvasRect.top + (-_ndc.y * 0.5 + 0.5) * canvasRect.height,
};
}
+4949
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -10,7 +10,7 @@
"noEmit": false, "noEmit": false,
"removeComments": true, "removeComments": true,
"noUnusedLocals": false, "noUnusedLocals": false,
"noUnusedParameters": false, "noUnusedParameters": true,
"noImplicitAny": false, "noImplicitAny": false,
"allowJs": true, "allowJs": true,
"types": ["vite/client"] "types": ["vite/client"]
+15 -1
View File
@@ -6,10 +6,24 @@ const rootDev = "src";
const rootBuild = "src"; const rootBuild = "src";
export default defineConfig((config) => { export default defineConfig((config) => {
return defineConfigTemplate({ // The template builds the full config (plugins, build, base server.hmr…).
const templateConfig = defineConfigTemplate({
rootDev, rootDev,
rootBuild, rootBuild,
config, config,
dependenciesArr: Object.keys(dependencies), dependenciesArr: Object.keys(dependencies),
}); });
return {
...templateConfig,
server: {
...templateConfig.server, // keep the template's settings (hmr, etc.)
// host:true → listen on 0.0.0.0 (all network interfaces), not just
// localhost. Vite then prints a "Network:" URL you can open on a phone
// on the same Wi-Fi. Needs the PC firewall to allow incoming port 5173.
host: true,
port: 5173,
strictPort: true, // fail loudly instead of silently hopping to another port
},
};
}); });