diff --git a/package.json b/package.json index 216614a..c3439f1 100644 --- a/package.json +++ b/package.json @@ -21,13 +21,14 @@ "cannon-es-debugger": "^1.0.0", "howler": "^2.2.4", "nipplejs": "^1.0.4", - "three": "^0.184.0" + "three": "^0.184.0", + "three.quarks": "^0.16.0" }, "devDependencies": { "@types/howler": "^2.2.13", "@types/three": "^0.184.1", - "lil-gui": "^0.21.0", "rollup": "^4.61.0", + "sass": "^1.100.0", "typescript": "^6.0.3", "vite": "^6.4.3" } diff --git a/src/controllers/CameraC.ts b/src/controllers/CameraC.ts index 80096da..a5fbd34 100644 --- a/src/controllers/CameraC.ts +++ b/src/controllers/CameraC.ts @@ -5,7 +5,7 @@ export class CameraC extends CameraC_internal { static setCamera(portraitOrientation: boolean) { const CATEGORY = Template.getCategory("global"); if (this.camera !== null) { - let position = portraitOrientation + const position = portraitOrientation ? Helper.returnVectorCamera(CATEGORY["camera_position_p"] as number[]) : Helper.returnVectorCamera(CATEGORY["camera_position_l"] as number[]); const rotation = portraitOrientation diff --git a/src/controllers/CameraDebugUI.ts b/src/controllers/CameraDebugUI.ts deleted file mode 100644 index 821a6cb..0000000 --- a/src/controllers/CameraDebugUI.ts +++ /dev/null @@ -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 [1–15]"); - - const charFolder = gui.addFolder("Character"); - const scaleProxy = { scale: TestSceneC.characterObject.scale.x }; - charFolder - .add(scaleProxy, "scale", 0.1, 3, 0.01) - .name("Scale [0.1–3]") - .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 | 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"); - } -} diff --git a/src/controllers/CombatC.ts b/src/controllers/CombatC.ts index fa4ea62..0ec5c30 100644 --- a/src/controllers/CombatC.ts +++ b/src/controllers/CombatC.ts @@ -7,9 +7,9 @@ import { Trigger } from "./TriggerC"; const ATTACK_DAMAGE = 10; // damage per bat-tip touch const CONTACT_DIST = 0.8; // bat tip β†’ crate distance that counts as a touch -const _tmp = new Vector3(); -const _center = new Vector3(); -const _tip = new Vector3(); +const _scratch = new Vector3(); // reused crate-position holder (no per-frame allocation) +const _center = new Vector3(); // averaged centre of the crates in reach +const _tip = new Vector3(); // current bat-tip world position /** * Auto-attack: while the player stands near crates, swing the bat and damage a @@ -24,9 +24,9 @@ export class CombatC { static init() { // Proximity trigger around every crate β†’ decides which crates are in reach. for (const crate of LootableC.crates) { - crate.root.getWorldPosition(_tmp); + crate.root.getWorldPosition(_scratch); 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 }, { onEnter: () => this.inRange.add(crate), @@ -56,9 +56,9 @@ export class CombatC { // Face the centre of the crates in reach and keep swinging. _center.set(0, 0, 0); - for (const c of this.inRange) { - c.root.getWorldPosition(_tmp); - _center.add(_tmp); + for (const crate of this.inRange) { + crate.root.getWorldPosition(_scratch); + _center.add(_scratch); } _center.divideScalar(this.inRange.size); PlayerC.setAttacking(true, _center); @@ -80,12 +80,12 @@ export class CombatC { const tip = PlayerC.getBatTip(_tip); if (!tip) return; - for (const c of [...this.inRange]) { - if (c.broken || this.hitThisSwing.has(c)) continue; - c.root.getWorldPosition(_tmp); - if (Math.hypot(tip.x - _tmp.x, tip.z - _tmp.z) <= CONTACT_DIST) { - this.hitThisSwing.add(c); - LootableC.damageCrate(c, ATTACK_DAMAGE); + for (const crate of [...this.inRange]) { + if (crate.broken || this.hitThisSwing.has(crate)) continue; + crate.root.getWorldPosition(_scratch); + if (Math.hypot(tip.x - _scratch.x, tip.z - _scratch.z) <= CONTACT_DIST) { + this.hitThisSwing.add(crate); + LootableC.damageCrate(crate, ATTACK_DAMAGE); } } this.pruneBroken(); @@ -97,8 +97,8 @@ export class CombatC { } private static pruneBroken() { - for (const c of this.inRange) { - if (c.broken) { this.inRange.delete(c); this.hitThisSwing.delete(c); } + for (const crate of this.inRange) { + if (crate.broken) { this.inRange.delete(crate); this.hitThisSwing.delete(crate); } } } } diff --git a/src/controllers/FollowCameraC.ts b/src/controllers/FollowCameraC.ts index f4b8b10..e7efd16 100644 --- a/src/controllers/FollowCameraC.ts +++ b/src/controllers/FollowCameraC.ts @@ -44,13 +44,6 @@ export class FollowCameraC { 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) { if (this.paused) return; const camera = CameraC_internal.camera; @@ -62,11 +55,11 @@ export class FollowCameraC { // target.rotation.y is the mesh Y-axis rotation set by PlayerC. // When the player stops, the lerp keeps drifting toward the last // facing direction β€” the "settle after stop" effect comes for free. - const ry = this.target.rotation.y; + const yaw = this.target.rotation.y; _lookAheadTarget.set( - Math.sin(ry) * this.lookAheadStrength, + Math.sin(yaw) * this.lookAheadStrength, 0, - Math.cos(ry) * this.lookAheadStrength, + Math.cos(yaw) * this.lookAheadStrength, ); this._lookAheadCurrent.lerp(_lookAheadTarget, Math.min(1, this.lookAheadLerpSpeed * delta)); diff --git a/src/controllers/HealthBarC.ts b/src/controllers/HealthBarC.ts new file mode 100644 index 0000000..07b35a8 --- /dev/null +++ b/src/controllers/HealthBarC.ts @@ -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(); + 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); } + } + } + } +} diff --git a/src/controllers/HudC.ts b/src/controllers/HudC.ts new file mode 100644 index 0000000..e0a9939 --- /dev/null +++ b/src/controllers/HudC.ts @@ -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-`, 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 ` +
+ +
+
HURRY UP!
+
ZOMBIE INVASION
+
+
+
+
+
+
+
+ +
+
+
0
+
+
+
0
+
+
+ +
+ +
LVL 0
+
+ `; + } + + 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 = ``; + 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); + } +} diff --git a/src/controllers/LootC.ts b/src/controllers/LootC.ts index 8c3b842..b7e3ffc 100644 --- a/src/controllers/LootC.ts +++ b/src/controllers/LootC.ts @@ -4,6 +4,7 @@ import { UpdateController, CameraC_internal } from "@24tools/playable_template"; import { ThreeC } from "./ThreeC"; import { TestSceneC } from "./TestSceneC"; // for groundY (ground level) import { woodIconUrl } from "../resources/images/woodIcon"; +import { worldToScreen } from "../utils/screen"; // Tunables β€” tweak here 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_TIME = 0.6; // each bounce is shorter in time const BOUNCE_FORWARD = 0.5; // each bounce covers this fraction of the previous horizontal step -const FLIGHT_STRETCH = 0.35; // vertical stretch in flight (scaled by hop height) -const LAND_SQUASH = 0.6; // squash on the final landing -const LAND_POP_MS = 160; // duration of the final "pop" +const FLIGHT_STRETCH = 0.22; // vertical stretch in flight (softer = smoother, less rubbery) +const LAND_SQUASH = 0.72; // squash on the final landing (gentler) +const LAND_POP_MS = 180; // duration of the final "pop" -// Collect (#8): delay after landing before flying to the corner, UI icon size, etc. -const COLLECT_DELAY_MS = 40; // almost immediately after the bounces (flows into the collect) -const UI_ICON_SIZE = 28; // wood UI icon size (px) β€” smaller than loot on the ground, but not tiny -const UI_RIGHT = 16; // offset from the right edge (px) -const UI_TOP = 110; // offset from the top (px) β€” lower, like in the REF -const SHRINK_MS = 250; // shrink to UI size before the flight -const FLY_MS = 500; // duration of the flight to the corner -const BLINK_MS = 120; // ramp-up duration of the white flash (fade-out is longer) +// Collect (#8): when/where the wood flies to the UI. +const COLLECT_LEAD_MS = 170; // start the collect this long BEFORE the bounces finish, + // so the shrink+flight flow out of the last bounce +const COLLECT_STAGGER_MS = 70; // extra per-piece delay so they stream in, not all at once +const UI_ICON_SIZE = 28; // wood arrival size (px) β€” about the wood plank on the panel +const UI_WOOD_X_FRAC = 0.82; // where the wood art sits across the panel (right-side plank) +const FLY_MS = 950; // flight duration β€” slower, calmer travel to the UI (size tracks it) +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 _topV = new Vector3(); +const _spriteTop = new Vector3(); // scratch: a sprite's top point, for measuring its screen size export class LootC { static pieces: Sprite[] = []; @@ -48,28 +49,10 @@ export class LootC { this.texture = new TextureLoader().load(woodIconUrl); this.texture.colorSpace = SRGBColorSpace; // correct color - // Wood UI icon in the top-right corner (HTML overlay). Loot flies into it. - const icon = document.createElement("img"); - icon.id = "wood-ui"; // stable id β†’ UI/counter hooks onto it, LootC reads its position - icon.src = woodIconUrl; - icon.style.cssText = - `position:fixed; top:${UI_TOP}px; right:${UI_RIGHT}px;` + - `width:${UI_ICON_SIZE}px; height:${UI_ICON_SIZE}px;` + - `z-index:1001; pointer-events:none; transition:transform 0.12s ease-out;`; - document.body.appendChild(icon); - this.uiIcon = icon; - - // 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; + // The wood icon/count live in the HUD (built by HudC). We just reference + // them: read the icon's screen position as the fly target, write the count. + this.uiIcon = document.getElementById("wood-ui") as HTMLImageElement | null; + this.countEl = document.getElementById("wood-count"); this.renderCount(); // ⚠️ Key: pump our group every frame, otherwise the tweens don't advance. @@ -81,9 +64,9 @@ export class LootC { return this.balance; } - /** Spend up to `n` wood; returns how much was actually taken (clamped to balance). */ - static spend(n: number): number { - const taken = Math.min(n, this.balance); + /** Spend up to `amount` wood; returns how much was actually taken (clamped to balance). */ + static spend(amount: number): number { + const taken = Math.min(amount, this.balance); this.balance -= taken; this.renderCount(); return taken; @@ -95,100 +78,115 @@ export class LootC { } 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. */ static spawn(origin: Vector3, count?: number) { - const n = count ?? (PIECES_MIN + Math.floor(Math.random() * (PIECES_MAX - PIECES_MIN + 1))); - const slice = (Math.PI * 2) / n; // each piece gets its own sector of the circle + const pieceCount = count ?? (PIECES_MIN + Math.floor(Math.random() * (PIECES_MAX - PIECES_MIN + 1))); + 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(); piece.position.copy(origin); // Even sector + a little jitter β†’ pieces spread out and don't clump. - const angle = i * slice + (Math.random() - 0.5) * slice * ANGLE_JITTER; - const dist = SCATTER_MIN + Math.random() * (SCATTER_MAX - SCATTER_MIN); + const angle = i * sectorAngle + (Math.random() - 0.5) * sectorAngle * ANGLE_JITTER; + const radius = SCATTER_MIN + Math.random() * (SCATTER_MAX - SCATTER_MIN); const landing = new Vector3( - origin.x + Math.cos(angle) * dist, - TestSceneC.groundY + PIECE_SIZE / 2, // sprite center above ground β†’ its bottom touches the ground - origin.z + Math.sin(angle) * dist, + origin.x + Math.cos(angle) * radius, + TestSceneC.groundY + PIECE_SIZE / 2, // sprite center above ground β†’ its bottom touches the ground + origin.z + Math.sin(angle) * radius, ); - this.animatePiece(piece, origin.clone(), landing); + this.animatePiece(piece, origin.clone(), landing, i); this.pieces.push(piece); } } /** Flat piece: a Sprite (billboard β€” always faces the camera) with the wood texture. */ private static createPiece(): Sprite { - const mat = new SpriteMaterial({ map: this.texture, transparent: true }); - const piece = new Sprite(mat); + const material = new SpriteMaterial({ map: this.texture, transparent: true }); + const piece = new Sprite(material); piece.scale.set(PIECE_SIZE, PIECE_SIZE, 1); ThreeC.addToScene(piece); return piece; } /** 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 - // One hop: an arc from (fx,fz) to (tx,tz), stretched while moving fast. - const hop = (fx: number, fz: number, tx: number, tz: number, peak: number, ms: number) => + // One hop: a parabolic arc from (fromX,fromZ) to (toX,toZ), stretched while + // 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) - .to({ t: 1 }, ms) + .to({ t: 1 }, durationMs) .easing(TWEEN.Easing.Linear.None) .onUpdate(({ t }) => { - piece.position.x = fx + (tx - fx) * t; - piece.position.z = fz + (tz - fz) * t; - piece.position.y = restY + peak * 4 * t * (1 - t); // parabolic arc + piece.position.x = fromX + (toX - fromX) * t; + piece.position.z = fromZ + (toZ - fromZ) * t; + piece.position.y = restY + peakHeight * 4 * t * (1 - t); // parabolic arc // |1-2t|: fast on the way up/down β†’ stretch; at the peak β†’ normal. // Scale the stretch by hop height (small bounces stretch less). - const s = 1 + FLIGHT_STRETCH * (peak / ARC_HEIGHT) * Math.abs(1 - 2 * t); - piece.scale.set(PIECE_SIZE / s, PIECE_SIZE * s, 1); + const stretch = 1 + FLIGHT_STRETCH * (peakHeight / ARC_HEIGHT) * Math.abs(1 - 2 * t); + piece.scale.set(PIECE_SIZE / stretch, PIECE_SIZE * stretch, 1); }); // Horizontal throw direction (target = the final resting spot). - const dx = to.x - from.x, dz = to.z - from.z; - const totalDist = Math.hypot(dx, dz) || 1e-4; - const dirX = dx / totalDist, dirZ = dz / totalDist; + const deltaX = to.x - from.x, deltaZ = to.z - from.z; + const totalDist = Math.hypot(deltaX, deltaZ) || 1e-4; + const dirX = deltaX / totalDist, dirZ = deltaZ / totalDist; - // Share the horizontal distance across the flight + bounces (so it also - // moves forward on each bounce, not just up). - const hops = BOUNCES + 1; - const series = (1 - Math.pow(BOUNCE_FORWARD, hops)) / (1 - BOUNCE_FORWARD); - let step = totalDist / series; - let cx = from.x, cz = from.z; - let peak = ARC_HEIGHT, ms = FLIGHT_MS; - let first: TWEEN.Tween<{ t: number }> | null = null; - let prev: TWEEN.Tween<{ t: number }> | null = null; + // Share the horizontal distance across the flight + bounces (so the piece + // also moves forward on each bounce, not just up). Each hop covers + // BOUNCE_FORWARDΓ— the previous one, so the steps form a geometric series + // whose sum we divide the total distance by to get the first step. + const hopCount = BOUNCES + 1; + const forwardSum = (1 - Math.pow(BOUNCE_FORWARD, hopCount)) / (1 - BOUNCE_FORWARD); + let stepDist = totalDist / forwardSum; + let curX = from.x, curZ = from.z; + 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[] = []; // 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++) { - const nx = cx + dirX * step, nz = cz + dirZ * step; - const h = hop(cx, cz, nx, nz, peak, ms); - if (!first) first = h; else prev!.chain(h); - prev = h; - cx = nx; cz = nz; - step *= BOUNCE_FORWARD; peak *= BOUNCE_HEIGHT; ms *= BOUNCE_TIME; + for (let i = 0; i < hopCount; i++) { + const nextX = curX + dirX * stepDist, nextZ = curZ + dirZ * stepDist; + const hopTween = hop(curX, curZ, nextX, nextZ, peakHeight, durationMs); + allTweens.push(hopTween); + if (!firstHop) firstHop = hopTween; else prevHop!.chain(hopTween); + prevHop = hopTween; + 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 pop = new TWEEN.Tween({ k: 0 }, this.tweens) - .to({ k: 1 }, LAND_POP_MS) + const pop = new TWEEN.Tween({ t: 0 }, this.tweens) + .to({ t: 1 }, LAND_POP_MS) .easing(TWEEN.Easing.Back.Out) - .onUpdate(({ k }) => { - const s = LAND_SQUASH + (1 - LAND_SQUASH) * k; // 0.6 β†’ 1 (with a slight overshoot) - piece.scale.set(PIECE_SIZE / s, PIECE_SIZE * s, 1); - piece.position.y = groundY + (PIECE_SIZE * s) / 2; // bottom stays on the ground - }) - .onComplete(() => { - setTimeout(() => this.collect(piece), COLLECT_DELAY_MS); // then fly to the UI + .onUpdate(({ t }) => { + const squash = LAND_SQUASH + (1 - LAND_SQUASH) * t; // 0.72 β†’ 1 (with a slight overshoot) + piece.scale.set(PIECE_SIZE / squash, PIECE_SIZE * squash, 1); + piece.position.y = groundY + (PIECE_SIZE * squash) / 2; // bottom stays on the ground }); - 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(); // Sprite center + size in screen pixels. - const center = this.toScreen(piece.position, cam, rect); - _topV.copy(piece.position); _topV.y += piece.scale.y / 2; - const sizePx = Math.max(8, Math.abs(center.y - this.toScreen(_topV, cam, rect).y) * 2); + const center = worldToScreen(piece.position, cam, rect); + _spriteTop.copy(piece.position); _spriteTop.y += piece.scale.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. this.remove(piece); @@ -214,7 +212,8 @@ export class LootC { flier.src = woodIconUrl; flier.style.cssText = `position:fixed; left:0; top:0; width:${sizePx}px; height:${sizePx}px;` + - `z-index:1000; pointer-events:none; transform:translate(-50%,-50%); will-change:left,top,width,height;`; + // 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); // 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.style.cssText = flier.style.cssText; flash.style.filter = "brightness(0) invert(1)"; // solid white silhouette - flash.style.zIndex = "1002"; + flash.style.zIndex = "10001"; document.body.appendChild(flash); const target = this.uiIconCenter(); - // Shared state. The flight, shrink and blink below all run at once, so they - // blend into one smooth motion instead of separate steps. - const st = { x: center.x, y: center.y, size: sizePx, o: 0 }; + // Curved flight path (quadratic BΓ©zier): start β†’ a lifted control point β†’ UI. + // The upward bow makes the wood swoop in an arc instead of a flat diagonal, + // 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) => { - el.style.left = `${st.x}px`; - el.style.top = `${st.y}px`; - el.style.width = `${st.size}px`; - el.style.height = `${st.size}px`; + const inv = 1 - anim.progress; // (1βˆ’t) term of the BΓ©zier + const x = inv * inv * startX + 2 * inv * anim.progress * ctrlX + anim.progress * anim.progress * endX; + const y = inv * inv * startY + 2 * inv * anim.progress * ctrlY + anim.progress * anim.progress * endY; + 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(); - // Flight (position) β€” the longest tween, so it owns the cleanup. - const fly = new TWEEN.Tween(st, this.tweens) - .to({ x: target.x, y: target.y }, FLY_MS) - .easing(TWEEN.Easing.Quadratic.In) + // Flight along the curve β€” owns the cleanup. Ease in AND out so it starts and + // arrives gently. + const fly = new TWEEN.Tween(anim, this.tweens) + .to({ progress: 1 }, FLY_MS) + .easing(TWEEN.Easing.Quadratic.InOut) .onUpdate(apply) .onComplete(() => { flier.remove(); flash.remove(); this.balance++; this.renderCount(); this.pulseUiIcon(); }); - // Shrink (size) β€” runs alongside the flight, eases out so it shrinks early. - const shrink = new TWEEN.Tween(st, this.tweens) - .to({ size: UI_ICON_SIZE }, SHRINK_MS) + // Shrink β€” SAME duration as the flight, so the two start AND finish together + // (no "shrink first"); Sinusoidal makes the size change extra smooth. + 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) .onUpdate(apply); - // Blink (opacity) β€” a quick glint that overlaps the start of the motion. - const flashIn = new TWEEN.Tween(st, this.tweens) - .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) + const flashOut = new TWEEN.Tween(anim, this.tweens) + .to({ glow: 0 }, BLINK_MS * 1.6) .easing(TWEEN.Easing.Quadratic.In) .onUpdate(apply); flashIn.chain(flashOut); - // Kick them all off together β†’ blended, fluid collect. + // Kick them off together β†’ one blended, fluid collect. fly.start(); shrink.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() { 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. */ @@ -289,9 +294,11 @@ export class LootC { 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) { ThreeC.removeFromScene(piece); + (piece.material as SpriteMaterial).dispose(); const i = this.pieces.indexOf(piece); if (i >= 0) this.pieces.splice(i, 1); } diff --git a/src/controllers/LootableC.ts b/src/controllers/LootableC.ts index 43ddaa8..2a7ec72 100644 --- a/src/controllers/LootableC.ts +++ b/src/controllers/LootableC.ts @@ -4,6 +4,7 @@ import { UpdateController } from "@24tools/playable_template"; import { PhysicsBody, PhysicsLayer } from "./PhysicsC"; import { Trigger } from "./TriggerC"; import { LootC } from "./LootC"; +import { HealthBarC } from "./HealthBarC"; import { VfxManager } from "../resources/vfx/VfxManager"; // Crate health, split evenly across 3 damage states (S1/S2/S3), broken at 0. @@ -31,7 +32,7 @@ export interface Crate { broken: boolean; baseScale: Vector3; // resting scale (tweens multiply this) 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) { // Map each authored damage state to its level via the _S 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]; if (statesGroup) { - for (const s of statesGroup.children) { - const m = s.name.match(/_S(\d)$/); - if (m) { - const lvl = parseInt(m[1], 10) - 1; // S1β†’0, S2β†’1, S3β†’2 - if (lvl >= 0 && lvl < LEVELS) statesByLevel[lvl] = s; + for (const stateNode of statesGroup.children) { + const match = stateNode.name.match(/_S(\d)$/); + if (match) { + const level = parseInt(match[1], 10) - 1; // S1β†’0, S2β†’1, S3β†’2 + if (level >= 0 && level < LEVELS) statesByLevel[level] = stateNode; } } } // 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; - 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). const health = CRATE_MAX_HEALTH * (LEVELS - startLevel) / LEVELS; // The per-crate collider proxy β†’ static box, then hide it (physics only). - const proxy = crate.children.find(c => c.name.startsWith("BoxCollider")); + const proxy = crate.children.find(child => child.name.startsWith("BoxCollider")); if (!proxy) continue; const collider = new PhysicsBody( @@ -114,12 +115,15 @@ export class LootableC { this.punchCrate(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. let level = Math.floor((1 - crate.health / crate.maxHealth) * LEVELS); level = Math.max(crate.startLevel, Math.min(LEVELS - 1, level)); if (level !== crate.level) { crate.level = level; - crate.statesByLevel.forEach((s, lvl) => { if (s) s.visible = lvl === level; }); + crate.statesByLevel.forEach((state, lvl) => { if (state) state.visible = lvl === level; }); // Loot drops on every state change, not only on destruction. LootC.spawn(crate.root.getWorldPosition(new Vector3())); } @@ -130,6 +134,9 @@ export class LootableC { if (crate.broken) return; crate.broken = true; + // Drop the floating health bar. + HealthBarC.hide(crate); + // Stop the hit-punch so it doesn't fight the break animation. crate.hitTween?.stop(); crate.hitTween = null; @@ -143,17 +150,17 @@ export class LootableC { LootC.spawn(crate.root.getWorldPosition(new Vector3())); // Shrink to nothing while spinning, then hide the meshes. - const s = { k: 0 }; - new TWEEN.Tween(s, this.tweens) - .to({ k: 1 }, BREAK_MS) + const anim = { progress: 0 }; + new TWEEN.Tween(anim, this.tweens) + .to({ progress: 1 }, BREAK_MS) .easing(TWEEN.Easing.Back.In) - .onUpdate(({ k }) => { - const sc = Math.max(0, 1 - k); - crate.root.scale.set(crate.baseScale.x * sc, crate.baseScale.y * sc, crate.baseScale.z * sc); - crate.root.rotation.y = crate.baseRotY + k * BREAK_SPIN; + .onUpdate(({ progress }) => { + const scale = Math.max(0, 1 - progress); + crate.root.scale.set(crate.baseScale.x * scale, crate.baseScale.y * scale, crate.baseScale.z * scale); + crate.root.rotation.y = crate.baseRotY + progress * BREAK_SPIN; }) .onComplete(() => { - crate.statesByLevel.forEach(st => { if (st) st.visible = false; }); + crate.statesByLevel.forEach(state => { if (state) state.visible = false; }); }) .start(); @@ -165,16 +172,16 @@ export class LootableC { // Squash the crate, then spring back (with a small overshoot pop). private static punchCrate(crate: Crate) { crate.hitTween?.stop(); // drop the previous punch so rapid hits don't stack - const s = { k: 0 }; - crate.hitTween = new TWEEN.Tween(s, this.tweens) - .to({ k: 1 }, HIT_PUNCH_MS) + const anim = { progress: 0 }; + crate.hitTween = new TWEEN.Tween(anim, this.tweens) + .to({ progress: 1 }, HIT_PUNCH_MS) .easing(TWEEN.Easing.Back.Out) - .onUpdate(({ k }) => { - const q = (1 - k) * HIT_PUNCH; // HIT_PUNCH (squashed) β†’ 0 (rest) + .onUpdate(({ progress }) => { + const squash = (1 - progress) * HIT_PUNCH; // HIT_PUNCH (squashed) β†’ 0 (rest) crate.root.scale.set( - crate.baseScale.x * (1 + q), - crate.baseScale.y * (1 - q), - crate.baseScale.z * (1 + q), + crate.baseScale.x * (1 + squash), + crate.baseScale.y * (1 - squash), + crate.baseScale.z * (1 + squash), ); }) .onComplete(() => { @@ -196,30 +203,30 @@ export class LootableC { if (!state) return; if (!this.flashedStates.has(state)) { - state.traverse(o => { - const mesh = o as Mesh; + state.traverse(node => { + const mesh = node as Mesh; if (!mesh.isMesh) return; mesh.material = Array.isArray(mesh.material) - ? mesh.material.map(m => m.clone()) + ? mesh.material.map(material => material.clone()) : (mesh.material as any).clone(); }); this.flashedStates.add(state); } - const mats: any[] = []; - state.traverse(o => { - const mesh = o as Mesh; + const materials: any[] = []; + state.traverse(node => { + const mesh = node as Mesh; 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 }; - new TWEEN.Tween(s, this.tweens) - .to({ k: 0 }, FLASH_MS) + const anim = { intensity: 1 }; + new TWEEN.Tween(anim, this.tweens) + .to({ intensity: 0 }, FLASH_MS) .easing(TWEEN.Easing.Quadratic.Out) - .onUpdate(({ k }) => { mats.forEach(m => { if (m.emissive) m.emissiveIntensity = k; }); }) - .onComplete(() => { mats.forEach(m => { if (m.emissive) m.emissiveIntensity = 0; }); }) + .onUpdate(({ intensity }) => { materials.forEach(material => { if (material.emissive) material.emissiveIntensity = intensity; }); }) + .onComplete(() => { materials.forEach(material => { if (material.emissive) material.emissiveIntensity = 0; }); }) .start(); } } diff --git a/src/controllers/PayZoneC.ts b/src/controllers/PayZoneC.ts index 734b3f7..722a41c 100644 --- a/src/controllers/PayZoneC.ts +++ b/src/controllers/PayZoneC.ts @@ -5,6 +5,7 @@ import { Trigger } from "./TriggerC"; import { TestSceneC } from "./TestSceneC"; import { LootC } from "./LootC"; import { woodIconUrl } from "../resources/images/woodIcon"; +import { worldToScreen } from "../utils/screen"; // --- Tunables --- 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 PULSE_MS = 160; // per-plank pulse time -const _v = new Vector3(); -const _ndc = new Vector3(); +const _scratch = new Vector3(); // reused so positioning never allocates /** * Pay zone: the player stands on the UI_Tool_Zone pad and the wood collected in @@ -33,13 +33,13 @@ export class PayZoneC { private static trigger: Trigger | null = null; private static tweens = new TWEEN.Group(); // our tween group, pumped each frame - private static inside = false; // is the player standing on the pad - private static done = false; // already paid in full - private static paid = 0; // wood delivered so far - private static payCd = 0; // cooldown until the next plank + private static inside = false; // is the player standing on the pad + private static done = false; // already paid in full + private static paid = 0; // wood delivered so far + 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 pulseTween: TWEEN.Tween<{ k: number }> | null = null; + private static pulseTween: TWEEN.Tween<{ progress: number }> | null = null; static init() { 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 // 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); target.x += ZONE_OFFSET.x; target.z += ZONE_OFFSET.z; @@ -75,7 +75,7 @@ export class PayZoneC { { 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) { @@ -83,14 +83,14 @@ export class PayZoneC { if (this.done || !this.inside) return; // One plank at a time: wait out the cooldown, stop if paid or out of wood. - this.payCd -= delta; - if (this.payCd > 0) return; + this.payCooldown -= delta; + if (this.payCooldown > 0) return; if (this.paid >= COST || LootC.getBalance() <= 0) return; // Spend one wood and fly it into the zone. LootC.spend(1); this.paid++; - this.payCd = PAY_INTERVAL; + this.payCooldown = PAY_INTERVAL; this.flyOnePlank(); 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. private static flyOnePlank() { - const src = LootC.uiIconScreenCenter(); - const tgt = this.zoneScreenCenter(); - if (!tgt) return; + const from = LootC.uiIconScreenCenter(); + const to = this.zoneScreenCenter(); + if (!to) return; const img = document.createElement("img"); img.src = woodIconUrl; @@ -109,13 +109,13 @@ export class PayZoneC { `z-index:1000; pointer-events:none; transform:translate(-50%,-50%); will-change:left,top;`; document.body.appendChild(img); - const st = { t: 0 }; - new TWEEN.Tween(st, this.tweens) - .to({ t: 1 }, FLY_MS) + const anim = { progress: 0 }; + new TWEEN.Tween(anim, this.tweens) + .to({ progress: 1 }, FLY_MS) .easing(TWEEN.Easing.Quadratic.In) - .onUpdate(({ t }) => { - const x = src.x + (tgt.x - src.x) * t; - const y = src.y + (tgt.y - src.y) * t - Math.sin(Math.PI * t) * FLY_ARC_PX; // arc + .onUpdate(({ progress }) => { + const x = from.x + (to.x - from.x) * progress; + 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.top = `${y}px`; }) @@ -128,15 +128,15 @@ export class PayZoneC { const zone = this.zone; 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 - const s = { k: 0 }; - this.pulseTween = new TWEEN.Tween(s, this.tweens) - .to({ k: 1 }, PULSE_MS) + const anim = { progress: 0 }; + this.pulseTween = new TWEEN.Tween(anim, this.tweens) + .to({ progress: 1 }, PULSE_MS) .easing(TWEEN.Easing.Back.Out) - .onUpdate(({ k }) => { - const g = 1 + fill + (1 - k) * 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); + .onUpdate(({ progress }) => { + const scale = 1 + grow + (1 - progress) * 0.12; // settle at fill size with a small punch + zone.scale.set(this.baseScale.x * scale, this.baseScale.y * scale, this.baseScale.z * scale); }) .onComplete(() => { this.pulseTween = null; }) .start(); @@ -153,16 +153,17 @@ export class PayZoneC { const zone = this.zone; 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) - .to({ k: 1 }, 180) + const grow = new TWEEN.Tween({ progress: 0 }, this.tweens) + .to({ progress: 1 }, 180) .easing(TWEEN.Easing.Back.Out) - .onUpdate(({ k }) => setScale(1 + 0.45 * k)); - const vanish = new TWEEN.Tween({ k: 0 }, this.tweens) - .to({ k: 1 }, 260) + .onUpdate(({ progress }) => setScale(1 + 0.45 * progress)); + const vanish = new TWEEN.Tween({ progress: 0 }, this.tweens) + .to({ progress: 1 }, 260) .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(() => { zone.visible = false; if (TestSceneC.payZoneIcon) TestSceneC.payZoneIcon.visible = false; // hide the icon too @@ -191,11 +192,7 @@ export class PayZoneC { const zone = this.zone; if (!cam || !canvas || !zone) return null; const rect = canvas.getBoundingClientRect(); - _v.copy(zone.position); _v.y += 0.3; - _ndc.copy(_v).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, - }; + _scratch.copy(zone.position); _scratch.y += 0.3; // aim a little above the pad's base + return worldToScreen(_scratch, cam, rect); } } diff --git a/src/controllers/PhysicsC.ts b/src/controllers/PhysicsC.ts index 32d6ccc..b4f684b 100644 --- a/src/controllers/PhysicsC.ts +++ b/src/controllers/PhysicsC.ts @@ -1,11 +1,10 @@ -import { - Delegate, - Physics_internal, - UpdateController, -} from "@24tools/playable_template"; +import { Physics_internal } from "@24tools/playable_template"; import { Box3, Object3D, Vector3 } from "three"; 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 { Player = 1, Wall = 2, @@ -13,117 +12,61 @@ export enum PhysicsLayer { 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 { private body: Body; - private pair: PhysicsObjPair | null = null; constructor( - threeObj: Object3D, - trigger: boolean, + object: Object3D, + isTrigger: boolean, mass: number, - col_group: PhysicsLayer, - col_mask: PhysicsLayer, - player_sphere: number = 0.3 + collisionGroup: PhysicsLayer, + collisionMask: PhysicsLayer, + sphereRadius = 0.3, ) { - let isPlayer = col_group === PhysicsLayer.Player; + const isPlayer = collisionGroup === PhysicsLayer.Player; - let oldQuaternion = threeObj.quaternion.clone(); - - let nullQuaternion = new Quaternion(); - threeObj.quaternion.copy(nullQuaternion); - - let bbox = new Box3().setFromObject(threeObj); - - 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); + // Measure the bounding box with rotation temporarily zeroed, so the box + // half-extents match the object's un-rotated size; the body's own + // quaternion (set below) then applies the real orientation. + const savedRotation = object.quaternion.clone(); + object.quaternion.copy(new Quaternion()); + const size = new Box3().setFromObject(object).getSize(new Vector3()); + object.quaternion.copy(savedRotation); this.body = new Body({ - isTrigger: trigger, - mass: mass, - //shape: shape, + isTrigger, + mass, shape: isPlayer - ? new Sphere(player_sphere) + ? new Sphere(sphereRadius) : new Box(new Vec3(size.x / 2, size.y / 2, size.z / 2)), - collisionFilterGroup: col_group, - collisionFilterMask: col_mask, + collisionFilterGroup: collisionGroup, + 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.quaternion.setFromEuler( - threeObj.rotation.x, - threeObj.rotation.y, - threeObj.rotation.z, - "XYZ" + object.rotation.x, + object.rotation.y, + object.rotation.z, + "XYZ", ); - // if you need sync three obj and physics 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; + Physics_internal.physicsWorld?.addBody(this.body); } - disablePhysicsPair() { - if (this.pair) { - this.pair.destroyed = true; - } - } - - getPhysicsBody() { + /** The underlying cannon body (for direct velocity/position control). */ + getPhysicsBody(): Body { return this.body; } + /** Remove the body from the physics world (e.g. when a crate breaks). */ destroy() { - if (!Physics_internal.physicsWorld) return; - - Physics_internal.physicsWorld.removeBody(this.body); - - (this.body as any) = null; - } -} - -export class PhysicsObjPair { - threeObj: Object3D; - physicsObj: Body; - destroyed: boolean; - delegateId: null | Delegate; - - 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); - } + Physics_internal.physicsWorld?.removeBody(this.body); } } diff --git a/src/controllers/PlayerC.ts b/src/controllers/PlayerC.ts index b0fd78e..4d0d616 100644 --- a/src/controllers/PlayerC.ts +++ b/src/controllers/PlayerC.ts @@ -1,5 +1,5 @@ import { CameraC_internal, JoystickC, ThreeC_internal, UpdateController } from "@24tools/playable_template"; -import { AnimationAction, AnimationMixer, Mesh, Object3D, Raycaster, Vector3 } from "three"; +import { AnimationAction, AnimationMixer, LoopOnce, Mesh, Object3D, Raycaster, Vector3 } from "three"; import { Body } from "cannon-es"; import { PhysicsBody, PhysicsLayer } from "./PhysicsC"; @@ -41,6 +41,9 @@ export class PlayerC { private static state = MoveState.Idle; 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). private static attacking = false; 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. */ 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 (this.attacking === active) return; this.attacking = active; @@ -139,6 +143,35 @@ export class PlayerC { 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 static setupWeapon() { @@ -195,16 +228,6 @@ export class PlayerC { return this.velocity.length() > 0.05; } - /** Unit vector the player currently faces (where the bat swings). */ - static getForward(out: Vector3): Vector3 { - return out.set(Math.sin(this.mesh.rotation.y), 0, Math.cos(this.mesh.rotation.y)); - } - - /** World position of the player's body (x/z used for hit-direction checks). */ - static getPosition(out: Vector3): Vector3 { - return out.set(this.body.position.x, this.body.position.y, this.body.position.z); - } - private static setupAnimations() { const gltf = ThreeC_internal.getMesh("character"); this.mixer = new AnimationMixer(this.mesh); @@ -262,6 +285,18 @@ export class PlayerC { } 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. _inputTarget.copy(this.inputDir).multiplyScalar(this.maxSpeed); this.velocity.lerp(_inputTarget, Math.min(1, this.acceleration * delta)); @@ -293,17 +328,17 @@ export class PlayerC { if (this.attacking) { 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). - const a = this.attackAction; - if (a) { - const dur = a.getClip().duration; - const phase = dur > 0 ? (a.time % dur) / dur : 0; // 0..1 within the clip - let wi = -1; + const action = this.attackAction; + if (action) { + const duration = action.getClip().duration; + const phase = duration > 0 ? (action.time % duration) / duration : 0; // 0..1 within the clip + let windowIndex = -1; 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 (wi !== -1) this._swingId++; // entered a new window β†’ new swing - this._activeWindow = wi; + if (windowIndex !== this._activeWindow) { + if (windowIndex !== -1) this._swingId++; // entered a new window β†’ new swing + this._activeWindow = windowIndex; } } if (this.finishing) { @@ -346,12 +381,13 @@ export class PlayerC { if (!this._batEndA || !this._batEndB) { if (!bat.geometry.boundingBox) bat.geometry.computeBoundingBox(); - const bb = bat.geometry.boundingBox!; - const cx = (bb.min.x + bb.max.x) / 2, cy = (bb.min.y + bb.max.y) / 2, cz = (bb.min.z + bb.max.z) / 2; - const sx = bb.max.x - bb.min.x, sy = bb.max.y - bb.min.y, sz = bb.max.z - bb.min.z; - if (sz >= sx && sz >= sy) { this._batEndA = new Vector3(cx, cy, bb.min.z); this._batEndB = new Vector3(cx, cy, bb.max.z); } - else if (sx >= sy) { this._batEndA = new Vector3(bb.min.x, cy, cz); this._batEndB = new Vector3(bb.max.x, cy, cz); } - else { this._batEndA = new Vector3(cx, bb.min.y, cz); this._batEndB = new Vector3(cx, bb.max.y, cz); } + const box = bat.geometry.boundingBox!; + 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 sizeX = box.max.x - box.min.x, sizeY = box.max.y - box.min.y, sizeZ = box.max.z - box.min.z; + // The bat's longest axis is its length β†’ its two ends are the box faces on that axis. + if (sizeZ >= sizeX && sizeZ >= sizeY) { this._batEndA = new Vector3(centerX, centerY, box.min.z); this._batEndB = new Vector3(centerX, centerY, box.max.z); } + 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); @@ -364,12 +400,13 @@ export class PlayerC { // Smoothly rotate the mesh's Y so it faces the given world x/z point. private static faceTowards(x: number, z: number, delta: number) { - const dx = x - this.mesh.position.x; - const dz = z - this.mesh.position.z; - if (dx * dx + dz * dz < 1e-4) return; - const targetAngle = Math.atan2(dx, dz); - const diff = ((targetAngle - this.mesh.rotation.y + Math.PI * 3) % (Math.PI * 2)) - Math.PI; - this.mesh.rotation.y += diff * Math.min(1, this.rotateSpeed * delta); + const deltaX = x - this.mesh.position.x; + const deltaZ = z - this.mesh.position.z; + if (deltaX * deltaX + deltaZ * deltaZ < 1e-4) return; + const targetAngle = Math.atan2(deltaX, deltaZ); + // Shortest signed turn into the -Ο€..Ο€ range so we never spin the long way round. + 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 { diff --git a/src/controllers/TestSceneC.ts b/src/controllers/TestSceneC.ts index 9d083e2..89e9850 100644 --- a/src/controllers/TestSceneC.ts +++ b/src/controllers/TestSceneC.ts @@ -4,18 +4,18 @@ import { Box3, Mesh, Object3D, Vector3 } from "three"; import { Body, Box, Vec3 } from "cannon-es"; import { PhysicsBody, PhysicsLayer } from "./PhysicsC"; - export class TestSceneC { static mapObject: Object3D; static characterObject: Object3D; // Top-level groups the artist authored inside the "Map" node of the GLB. - static environment: Object3D | null = null; // Ground_ β€” visual ground/road/borders, the only thing rendered - static colliderGroup: Object3D | null = null; // Colliders β€” invisible BoxCollider proxies β†’ cannon bodies - static lootableGroup: Object3D | null = null; // Lootable β€” interactive crates, hidden until activated per-crate - 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 environment: Object3D | null = null; // Ground_ β€” visual ground/road/borders, the only thing rendered + static colliderGroup: Object3D | null = null; // Colliders β€” invisible BoxCollider proxies β†’ cannon bodies + static lootableGroup: Object3D | null = null; // Lootable β€” interactive crates, hidden until activated per-crate + 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 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). static groundY = 0; @@ -38,13 +38,15 @@ export class TestSceneC { this.mapObject = ThreeC.getObject("scene"); // Resolve the named groups baked into the GLB. - this.environment = this.mapObject.getObjectByName("Ground_") ?? null; + this.environment = this.mapObject.getObjectByName("Ground_") ?? null; this.colliderGroup = this.mapObject.getObjectByName("Colliders") ?? null; this.lootableGroup = this.mapObject.getObjectByName("Lootable") ?? null; - this.payZone = this.mapObject.getObjectByName("UI_Tool_Zone") ?? null; - this.payZoneIcon = this.mapObject.getObjectByName("UI_Wood") ?? null; - this.interactiveZone = this.mapObject.getObjectByName("UI_Interactive_Zone_02") ?? null; - const uiGroup = this.mapObject.getObjectByName("UI") ?? null; + this.payZone = this.mapObject.getObjectByName("UI_Tool_Zone") ?? null; + this.payZoneIcon = this.mapObject.getObjectByName("UI_Wood") ?? null; + this.interactiveZone = + 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 // proxies' world positions depend on the full parent chain), then hide @@ -58,23 +60,26 @@ export class TestSceneC { this.buildBoundaryWalls(); // …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). - 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 // payZone + its icon are placed and shown by PayZoneC. if (this.payZone) this.payZone.visible = false; if (this.payZoneIcon) this.payZoneIcon.visible = false; // Hide the unused duplicate wood icon (three.js drops the dot β†’ "UI_Wood001"). - const strayWoodIcon = this.mapObject.getObjectByName("UI_Wood001") - ?? this.mapObject.getObjectByName("UI_Wood.001"); + const strayWoodIcon = + this.mapObject.getObjectByName("UI_Wood001") ?? + this.mapObject.getObjectByName("UI_Wood.001"); if (strayWoodIcon) strayWoodIcon.visible = false; // Only the environment is rendered with shadows. if (this.environment) { ThreeC.setShadowsStateForChildren(this.environment, true, true); } 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. private static buildMapPhysics() { 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; } - this.colliderGroup.traverse(child => { + this.colliderGroup.traverse((child) => { if (!(child instanceof Mesh)) return; const body = new PhysicsBody( child, - false, // not a trigger - 0, // mass 0 β†’ static + false, // not a trigger + 0, // mass 0 β†’ static 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); }); @@ -109,16 +116,19 @@ export class TestSceneC { private static alignFloorToGround() { 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; - for (const pb of this.mapBodies) { - const body = pb.getPhysicsBody(); - const half = (body.shapes[0] as Box).halfExtents.y; - const top = body.position.y + half; + for (const physicsBody of this.mapBodies) { + const body = physicsBody.getPhysicsBody(); + const halfHeight = (body.shapes[0] as Box).halfExtents.y; + const top = body.position.y + halfHeight; 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. @@ -135,35 +145,37 @@ export class TestSceneC { const max = bounds.max; const sizeX = max.x - min.x; const sizeZ = max.z - min.z; - const cx = (min.x + max.x) / 2; - const cz = (min.z + max.z) / 2; + const floorCenterX = (min.x + max.x) / 2; + const floorCenterZ = (min.z + max.z) / 2; - const t = 0.5; // wall thickness - const h = 3; // wall height - const midY = max.y + h / 2; // sits on top of the floor + const thickness = 0.5; + const height = 3; + const midY = max.y + height / 2; // sits on top of the floor - // [centerX, centerZ, halfX, halfZ] β€” height is shared. Thickness overlaps - // at corners (+t) so there are no gaps. - const specs: [number, number, number, number][] = [ - [cx, max.z + t / 2, sizeX / 2 + t, t / 2], // +Z - [cx, min.z - t / 2, sizeX / 2 + t, t / 2], // -Z - [max.x + t / 2, cz, t / 2, sizeZ / 2 + t], // +X - [min.x - t / 2, cz, t / 2, sizeZ / 2 + t], // -X + // Each spec is [centerX, centerZ, halfX, halfZ]; height is shared. The +thickness + // makes the walls overlap at the corners so there are no gaps. + const wallSpecs: [number, number, number, number][] = [ + [floorCenterX, max.z + thickness / 2, sizeX / 2 + thickness, thickness / 2], // +Z + [floorCenterX, min.z - thickness / 2, sizeX / 2 + thickness, thickness / 2], // -Z + [max.x + thickness / 2, floorCenterZ, thickness / 2, sizeZ / 2 + thickness], // +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({ mass: 0, - shape: new Box(new Vec3(hx, h / 2, hz)), + shape: new Box(new Vec3(halfX, height / 2, halfZ)), collisionFilterGroup: PhysicsLayer.Wall, collisionFilterMask: PhysicsLayer.Player, }); - body.position.set(px, midY, pz); + body.position.set(wallX, midY, wallZ); Physics_internal.physicsWorld.addBody(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() { @@ -199,7 +211,10 @@ export class TestSceneC { for (const name of ["Character_Pistol", "Bullet"]) { const obj = this.characterObject.getObjectByName(name); 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`, + ); } } } diff --git a/src/controllers/ThreeC.ts b/src/controllers/ThreeC.ts index ad7f48c..6854ba8 100644 --- a/src/controllers/ThreeC.ts +++ b/src/controllers/ThreeC.ts @@ -15,37 +15,10 @@ export class ThreeC extends ThreeC_internal { Template.getValue("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() { - 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); // Real-time shadows are disabled on purpose. The character ships its own diff --git a/src/css/main.css b/src/css/main.css index 2c585fa..75f5dc2 100644 --- a/src/css/main.css +++ b/src/css/main.css @@ -38,8 +38,11 @@ html { } canvas { - width: 100%; - height: 100%; + /* !important beats the renderer's inline px size so the canvas always fills + 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 { diff --git a/src/css/ui.css b/src/css/ui.css deleted file mode 100644 index a30a41c..0000000 --- a/src/css/ui.css +++ /dev/null @@ -1,4 +0,0 @@ -#ui { - /* flex-basis: 60%; */ - flex-grow: 1; -} diff --git a/src/css/ui.scss b/src/css/ui.scss new file mode 100644 index 0000000..c101bde --- /dev/null +++ b/src/css/ui.scss @@ -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-` 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 , 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); + } +} diff --git a/src/enums/ResourcesType.ts b/src/enums/ResourcesType.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/enums/VFXType.ts b/src/enums/VFXType.ts deleted file mode 100644 index 0bea72c..0000000 --- a/src/enums/VFXType.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum VFXType { - HitEffect, - DestroyEffect -} \ No newline at end of file diff --git a/src/fonts/PassionOne-Black.otf b/src/fonts/PassionOne-Black.otf new file mode 100644 index 0000000..d5fb875 Binary files /dev/null and b/src/fonts/PassionOne-Black.otf differ diff --git a/src/fonts/passionOne.ts b/src/fonts/passionOne.ts new file mode 100644 index 0000000..55ee2dc --- /dev/null +++ b/src/fonts/passionOne.ts @@ -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); +} diff --git a/src/index.html b/src/index.html index 9f66cfa..f47b8ac 100644 --- a/src/index.html +++ b/src/index.html @@ -11,7 +11,7 @@ - + diff --git a/src/index.ts b/src/index.ts index ef118f9..0f32294 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,16 +9,18 @@ import { customFont } from "./fonts/customFont"; import { configUIParams } from "./configUIParams/configUIParams"; import { Template, Template3d } from "@24tools/playable_template"; import { firstClickCb } from "./templateConfig/firstClickCb"; + Template.set24ADSControls(); + window.setupConfig = async function (config) { Template.initConfig({ templateType: TemplateType["3d"], redirectOptions: {}, ticker: Template3d.ticker, debug: { - physics: true, + physics: false, // 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({ config: config || formConfigForPlayable(formConfigUI({ diff --git a/src/resources/OnbordingUI/onboardingUI.ts b/src/resources/OnbordingUI/onboardingUI.ts new file mode 100644 index 0000000..1f06511 --- /dev/null +++ b/src/resources/OnbordingUI/onboardingUI.ts @@ -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 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 diff --git a/src/resources/vfx/VfxManager.ts b/src/resources/vfx/VfxManager.ts index 766c611..f4b46b5 100644 --- a/src/resources/vfx/VfxManager.ts +++ b/src/resources/vfx/VfxManager.ts @@ -7,61 +7,45 @@ import { ThreeC } from "../../controllers/ThreeC"; const VFX_RESOURCE_TYPE = "vfx_json"; export class VfxManager { - static batchRenderer: BatchedRenderer; - static loader: QuarksLoader; + static batchRenderer: BatchedRenderer; + static loader: QuarksLoader; - static init() { - this.batchRenderer = new BatchedRenderer(); - this.loader = new QuarksLoader(); - ThreeC.addToScene(this.batchRenderer); - const updateDelegate = UpdateController.Instance.onUpdate.addDelegate(this.update.bind(this)); - // GameTimer.onDelayGameEnd.addDelegate(() => UpdateController.Instance.onUpdate.removeListeners(updateDelegate)); - // initTrailEffect(); - } + static init() { + this.batchRenderer = new BatchedRenderer(); + this.loader = new QuarksLoader(); + ThreeC.addToScene(this.batchRenderer); + UpdateController.Instance.onUpdate.addDelegate(this.update.bind(this)); + } - static Remove(vfx: Object3D) { - vfx.removeFromParent(); - vfx.parent = null; - } + static update(delta: number) { + this.batchRenderer.update(delta); + } - static update(delta: number) { - this.batchRenderer.update(delta); - } + static Play( + name: string, + parent: Object3D | null = null, + position: Vector3 | null = null, + rotation: Euler | null = null, + scale: Vector3 | null = null, + renderOrder: number | null = null, + ): Object3D { + const resource = ResourcesC.getResource<{ obj: Object3D } | undefined>( + VFX_RESOURCE_TYPE, + name, + ); + if (!resource?.obj) return new Object3D(); - static Play( - name: string, - parent: Object3D | null = null, - position: Vector3 | null = null, - rotation: Euler | null = null, - scale: Vector3 | null = null, - renderOrder: number | null = null, - ): Object3D { - const resource = ResourcesC.getResource<{ obj: Object3D } | undefined>(VFX_RESOURCE_TYPE, name); - if (!resource?.obj) return new Object3D(); + const effect = resource.obj.clone(true); + QuarksUtil.setAutoDestroy(effect, true); + QuarksUtil.addToBatchRenderer(effect, this.batchRenderer); - const effect = resource.obj.clone(true); - QuarksUtil.setAutoDestroy(effect, true); - QuarksUtil.addToBatchRenderer(effect, this.batchRenderer); - - if (parent) parent.add(effect); - else ThreeC.addToScene(effect); - if (position) effect.position.copy(position); - if (rotation) effect.rotation.copy(rotation); - if (scale) effect.scale.copy(scale); - if (renderOrder != null) effect.renderOrder = renderOrder; - - return effect; - } - - static StopEmision(effect: Object3D) { - QuarksUtil.stop(effect); - } - static Restart(effect: Object3D) { - QuarksUtil.play(effect); - } - static Pause(effect: Object3D) { - QuarksUtil.pause(effect); - } + if (parent) parent.add(effect); + else ThreeC.addToScene(effect); + if (position) effect.position.copy(position); + if (rotation) effect.rotation.copy(rotation); + if (scale) effect.scale.copy(scale); + if (renderOrder != null) effect.renderOrder = renderOrder; + return effect; + } } - diff --git a/src/resources/vfx/vfx_json.ts b/src/resources/vfx/vfx_json.ts index 37e20cd..1d776d4 100644 --- a/src/resources/vfx/vfx_json.ts +++ b/src/resources/vfx/vfx_json.ts @@ -7,19 +7,23 @@ export const vfx_json: ConvertResourceType = { resources: [ { name: "HitEffect", - value: ConvertToBase64WhenRelease("resources/vfx/files/VFX_Lootable_Hit.json"), + value: ConvertToBase64WhenRelease( + "resources/vfx/files/VFX_Lootable_Hit.json", + ), }, - { + { name: "DestroyEffect", - value: ConvertToBase64WhenRelease("resources/vfx/files/VFX_Lootable_Destroy.json"), + value: ConvertToBase64WhenRelease( + "resources/vfx/files/VFX_Lootable_Destroy.json", + ), }, - { + { name: "Test", value: ConvertToBase64WhenRelease("resources/vfx/files/test.json"), }, ], loader: quarksLoader, -} +}; export function quarksLoader(base64String: string) { return new Promise((resolve, reject) => { @@ -28,10 +32,10 @@ export function quarksLoader(base64String: string) { JSON.parse(atob(base64String.split(",")[1])), (obj) => { resolve({ obj }); - } + }, ); } catch (error) { reject("Error loading vfx: " + error); } }); -} \ No newline at end of file +} diff --git a/src/templateConfig/afterResourcesLoadedCb.ts b/src/templateConfig/afterResourcesLoadedCb.ts index e5a75f4..53e678a 100644 --- a/src/templateConfig/afterResourcesLoadedCb.ts +++ b/src/templateConfig/afterResourcesLoadedCb.ts @@ -7,6 +7,8 @@ import { CombatC } from "../controllers/CombatC"; import { JoystickC, SoundC, Template } from "@24tools/playable_template"; import { LootC } from "../controllers/LootC"; import { PayZoneC } from "../controllers/PayZoneC"; +import { HudC } from "../controllers/HudC"; +import { HealthBarC } from "../controllers/HealthBarC"; import { VfxManager } from "../resources/vfx/VfxManager"; export const afterResourcesLoadedCb: (() => void) | undefined = async () => { @@ -39,9 +41,16 @@ export const afterResourcesLoadedCb: (() => void) | undefined = async () => { 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. 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. TriggerC.init(PlayerC.getBody()); @@ -57,10 +66,5 @@ export const afterResourcesLoadedCb: (() => void) | undefined = async () => { // VFX: set up the quark particle renderer. VfxManager.init(); - // if (import.meta.env.DEV) { - // const { CameraDebugUI } = await import("../controllers/CameraDebugUI"); - // CameraDebugUI.init(); - // } - Template.disableLoader(); }; diff --git a/src/utils/screen.ts b/src/utils/screen.ts new file mode 100644 index 0000000..6a9437a --- /dev/null +++ b/src/utils/screen.ts @@ -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, + }; +} diff --git a/stats.html b/stats.html new file mode 100644 index 0000000..2ac8330 --- /dev/null +++ b/stats.html @@ -0,0 +1,4949 @@ + + + + + + + + Rollup Visualizer + + + +
+ + + + + diff --git a/tsconfig.json b/tsconfig.json index 31121c9..e08706f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ "noEmit": false, "removeComments": true, "noUnusedLocals": false, - "noUnusedParameters": false, + "noUnusedParameters": true, "noImplicitAny": false, "allowJs": true, "types": ["vite/client"] diff --git a/vite.config.js b/vite.config.js index 2a36d68..00db309 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,15 +1,29 @@ import { defineConfig } from "vite"; -import { defineConfigTemplate} from "@24tools/ads_common"; +import { defineConfigTemplate } from "@24tools/ads_common"; import { dependencies } from "./package.json"; const rootDev = "src"; const rootBuild = "src"; export default defineConfig((config) => { - return defineConfigTemplate({ + // The template builds the full config (plugins, build, base server.hmr…). + const templateConfig = defineConfigTemplate({ rootDev, rootBuild, config, 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 + }, + }; });