feat:
-Add UI (Health bars, Resources UI, HUD Interface, Weapon U, Zoombie Invasion progress Indicator) -Refactor code structure for improved readability and maintainability
This commit is contained in:
+3
-2
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<number> | null = null;
|
||||
let orbitOverlay: HTMLDivElement | null = null;
|
||||
|
||||
const orbitProxy = { enabled: false };
|
||||
gui
|
||||
.add(orbitProxy, "enabled")
|
||||
.name("🔭 Free camera (OrbitControls)")
|
||||
.onChange((enabled: boolean) => {
|
||||
if (enabled) {
|
||||
FollowCameraC.paused = true;
|
||||
|
||||
orbitOverlay = createOrbitOverlay();
|
||||
document.body.appendChild(orbitOverlay);
|
||||
|
||||
orbitControls = new OrbitControls(CameraC_internal.camera!, orbitOverlay);
|
||||
orbitControls.update();
|
||||
|
||||
orbitDelegate = UpdateController.Instance.onUpdate.addDelegate(() => {
|
||||
orbitControls?.update();
|
||||
});
|
||||
} else {
|
||||
if (orbitDelegate !== null) {
|
||||
UpdateController.Instance.onUpdate.removeListeners(orbitDelegate);
|
||||
orbitDelegate = null;
|
||||
}
|
||||
orbitControls?.dispose();
|
||||
orbitControls = null;
|
||||
|
||||
orbitOverlay?.remove();
|
||||
orbitOverlay = null;
|
||||
|
||||
FollowCameraC.paused = false;
|
||||
FollowCameraC.snapToTarget();
|
||||
}
|
||||
});
|
||||
|
||||
gui
|
||||
.add(
|
||||
{
|
||||
log: () => {
|
||||
const cam = CameraC_internal.camera!;
|
||||
const s = TestSceneC.characterObject.scale.x;
|
||||
|
||||
const p = cam.position;
|
||||
// Convert radians to degrees for config
|
||||
const rx = Math.round(cam.rotation.x * (180 / Math.PI));
|
||||
const ry = Math.round(cam.rotation.y * (180 / Math.PI));
|
||||
const rz = Math.round(cam.rotation.z * (180 / Math.PI));
|
||||
|
||||
console.log(
|
||||
`%c[CameraDebug] Paste into globalSettings.ts:
|
||||
camera_position: x=${p.x.toFixed(2)}, y=${p.y.toFixed(2)}, z=${p.z.toFixed(2)}
|
||||
camera_rotation: x=${rx}°, y=${ry}°, z=${rz}°
|
||||
charScale: ${s.toFixed(2)}`,
|
||||
"color: #7cf; font-weight: bold"
|
||||
);
|
||||
},
|
||||
},
|
||||
"log"
|
||||
)
|
||||
.name("📋 Print values to console");
|
||||
}
|
||||
}
|
||||
+16
-16
@@ -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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { Box3, DoubleSide, Group, Material, Mesh, Object3D, Vector3 } from "three";
|
||||
import * as TWEEN from "@tweenjs/tween.js";
|
||||
import { UpdateController, CameraC_internal } from "@24tools/playable_template";
|
||||
import { ThreeC } from "./ThreeC";
|
||||
import type { Crate } from "./LootableC";
|
||||
|
||||
// --- Placement ---
|
||||
const HEIGHT_ABOVE = 1.4; // world Y offset of the bar above the crate origin
|
||||
const BAR_SCALE = 1.2; // world scale of the cloned bar (prototype is ~1m wide)
|
||||
|
||||
// --- Fill animation ---
|
||||
const FOREGROUND_MS = 120; // Foreground (current health) drops fast — animates first
|
||||
const MIDDLEGROUND_DELAY = 120; // Middleground starts a touch later…
|
||||
const MIDDLEGROUND_MS = 600; // …and catches up slowly behind it (the "lost chunk" sliver)
|
||||
|
||||
// --- Show / hide ---
|
||||
const FADE_IN_MS = 150;
|
||||
const FADE_OUT_MS = 700; // slow, soft fade-out
|
||||
const HOLD_S = 3.0; // stay visible this long after the last hit, then fade out
|
||||
|
||||
// One bar instance bound to a crate. The two fills slide their right edge while
|
||||
// keeping the left edge pinned: foreground leads (fast), middleground trails.
|
||||
interface Bar {
|
||||
group: Group; // billboarded container, positioned above the crate
|
||||
foreground: Mesh; // lead fill (snaps to the new health quickly)
|
||||
middleground: Mesh; // catch-up fill (trails behind the foreground)
|
||||
foregroundBaseX: number; // foreground resting local X
|
||||
foregroundMinX: number; // foreground geometry left edge (anchor for the shrink)
|
||||
middlegroundBaseX: number;
|
||||
middlegroundMinX: number;
|
||||
materials: Material[]; // all 3 cloned materials (driven together by the fade)
|
||||
alpha: number; // current fade level (0 hidden … 1 shown)
|
||||
idleSeconds: number; // time since the last hit (drives the auto fade-out)
|
||||
visible: boolean;
|
||||
fadeTween: TWEEN.Tween<{ alpha: number }> | null; // live fade (stopped before re-firing)
|
||||
foregroundTween: TWEEN.Tween<{ scaleX: number }> | null; // live fill tweens (stopped before re-firing)
|
||||
middlegroundTween: TWEEN.Tween<{ scaleX: number }> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-world health bars above crates, cloned from the GLB "UI" prototype
|
||||
* (Background track + Foreground + Middleground). A bar appears on the first hit
|
||||
* and fades out when the crate is left alone. On each hit the Foreground snaps to
|
||||
* the new health quickly and the Middleground trails behind it.
|
||||
*/
|
||||
export class HealthBarC {
|
||||
private static protoNode: Object3D | null = null;
|
||||
private static bars = new Map<Crate, Bar>();
|
||||
private static tweens = new TWEEN.Group();
|
||||
|
||||
static init(prototype: Object3D | null) {
|
||||
if (!prototype) {
|
||||
console.warn("[HealthBar] no prototype (UI node) found — bars disabled");
|
||||
return;
|
||||
}
|
||||
this.protoNode = prototype;
|
||||
UpdateController.Instance.onUpdate.addDelegate((delta: number) => this.update(delta));
|
||||
}
|
||||
|
||||
/** Show/refresh the bar for a crate at the given health fraction (0..1). */
|
||||
static showDamage(crate: Crate, fraction: number) {
|
||||
if (!this.protoNode) return;
|
||||
let bar = this.bars.get(crate);
|
||||
if (!bar) {
|
||||
const built = this.build();
|
||||
if (!built) return;
|
||||
bar = built;
|
||||
this.bars.set(crate, bar);
|
||||
}
|
||||
|
||||
bar.idleSeconds = 0;
|
||||
if (!bar.visible) { bar.visible = true; this.fade(bar, 1, FADE_IN_MS); }
|
||||
|
||||
const health = Math.max(0, Math.min(1, fraction));
|
||||
// Foreground first (fast), Middleground catching up behind it (slower, delayed).
|
||||
bar.foregroundTween = this.tweenFill(
|
||||
bar.foregroundTween, bar.foreground, bar.foregroundBaseX, bar.foregroundMinX, health, FOREGROUND_MS, 0);
|
||||
bar.middlegroundTween = this.tweenFill(
|
||||
bar.middlegroundTween, bar.middleground, bar.middlegroundBaseX, bar.middlegroundMinX, health, MIDDLEGROUND_MS, MIDDLEGROUND_DELAY);
|
||||
}
|
||||
|
||||
/** Crate gone (broken): fade the bar out and drop it. */
|
||||
static hide(crate: Crate) {
|
||||
const bar = this.bars.get(crate);
|
||||
if (!bar) return;
|
||||
this.bars.delete(crate);
|
||||
this.fade(bar, 0, FADE_OUT_MS, () => {
|
||||
ThreeC.removeFromScene(bar.group);
|
||||
bar.materials.forEach(material => material.dispose());
|
||||
});
|
||||
}
|
||||
|
||||
// Clone the prototype, make its materials fade-able, center it, and add it to the
|
||||
// scene. The bar is positioned over its crate every frame in update(), so build
|
||||
// itself needs nothing from the crate.
|
||||
private static build(): Bar | null {
|
||||
const group = new Group();
|
||||
const ui = this.protoNode!.clone(true);
|
||||
ui.quaternion.identity(); // drop the prototype's authored tilt — we billboard instead
|
||||
ui.visible = true;
|
||||
ui.traverse(o => { o.visible = true; });
|
||||
|
||||
const foreground = ui.getObjectByName("UI_Foreground") as Mesh | undefined;
|
||||
const middleground = ui.getObjectByName("UI_Middleground") as Mesh | undefined;
|
||||
const background = ui.getObjectByName("UI_Background") as Mesh | undefined;
|
||||
if (!foreground || !middleground || !background) {
|
||||
console.warn("[HealthBar] prototype missing UI_Foreground/Middleground/Background");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Keep the GLB's original look: clone each mesh's authored (textured, unlit)
|
||||
// material and only make it fade-able + force the layer order (Foreground on
|
||||
// top of Middleground on top of Background). depthTest off so the bar always
|
||||
// draws over the scene; DoubleSide so it shows no matter how it's billboarded.
|
||||
const materials: Material[] = [];
|
||||
const prepMaterial = (mesh: Mesh, renderOrder: number) => {
|
||||
const material = (mesh.material as Material).clone();
|
||||
material.transparent = true;
|
||||
material.opacity = 0; // start hidden; the fade-in brings it up
|
||||
material.depthTest = false;
|
||||
material.depthWrite = false;
|
||||
material.side = DoubleSide;
|
||||
mesh.material = material;
|
||||
mesh.renderOrder = renderOrder;
|
||||
materials.push(material);
|
||||
};
|
||||
prepMaterial(background, 0);
|
||||
prepMaterial(middleground, 1);
|
||||
prepMaterial(foreground, 2);
|
||||
|
||||
// Anchor data so the fills shrink from the right (left edge stays put).
|
||||
const leftEdgeX = (mesh: Mesh) => { mesh.geometry.computeBoundingBox(); return mesh.geometry.boundingBox!.min.x; };
|
||||
const foregroundMinX = leftEdgeX(foreground), middlegroundMinX = leftEdgeX(middleground);
|
||||
const foregroundBaseX = foreground.position.x, middlegroundBaseX = middleground.position.x;
|
||||
|
||||
// Center the bar content on the group origin (the prototype meshes sit offset).
|
||||
ui.updateMatrixWorld(true);
|
||||
const center = new Box3().setFromObject(ui).getCenter(new Vector3());
|
||||
ui.position.sub(center);
|
||||
|
||||
group.add(ui);
|
||||
group.scale.setScalar(BAR_SCALE);
|
||||
ThreeC.addToScene(group);
|
||||
|
||||
return {
|
||||
group, foreground, middleground,
|
||||
foregroundBaseX, foregroundMinX, middlegroundBaseX, middlegroundMinX,
|
||||
materials, alpha: 0, idleSeconds: 0, visible: false,
|
||||
fadeTween: null, foregroundTween: null, middlegroundTween: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Animate one fill to a fraction, keeping its left edge fixed (right edge moves).
|
||||
private static tweenFill(
|
||||
prev: TWEEN.Tween<{ scaleX: number }> | null,
|
||||
mesh: Mesh, baseX: number, minX: number, targetFraction: number, ms: number, delay: number,
|
||||
) {
|
||||
prev?.stop();
|
||||
const state = { scaleX: mesh.scale.x };
|
||||
return new TWEEN.Tween(state, this.tweens)
|
||||
.to({ scaleX: targetFraction }, ms)
|
||||
.delay(delay)
|
||||
.easing(TWEEN.Easing.Quadratic.Out)
|
||||
.onUpdate(({ scaleX }) => {
|
||||
mesh.scale.x = scaleX;
|
||||
mesh.position.x = baseX + minX * (1 - scaleX); // pin the left edge as it shrinks
|
||||
})
|
||||
.start();
|
||||
}
|
||||
|
||||
// Fade all of a bar's materials to a target alpha. Stops any in-flight fade
|
||||
// first so a re-show mid-fade-out doesn't fight it (fixes the flicker on the
|
||||
// next hit right after the bar started disappearing).
|
||||
private static fade(bar: Bar, to: number, ms: number, onDone?: () => void) {
|
||||
bar.fadeTween?.stop();
|
||||
const state = { alpha: bar.alpha };
|
||||
bar.fadeTween = new TWEEN.Tween(state, this.tweens)
|
||||
.to({ alpha: to }, ms)
|
||||
.easing(TWEEN.Easing.Quadratic.Out)
|
||||
.onUpdate(({ alpha }) => {
|
||||
bar.alpha = alpha;
|
||||
bar.materials.forEach(material => { material.opacity = alpha; });
|
||||
})
|
||||
.onComplete(() => { bar.fadeTween = null; onDone?.(); })
|
||||
.start();
|
||||
}
|
||||
|
||||
// Each frame: billboard active bars to the camera, keep them above their crate,
|
||||
// fade out idle bars, and advance the tweens.
|
||||
private static update(delta: number) {
|
||||
this.tweens.update();
|
||||
const camera = CameraC_internal.camera;
|
||||
|
||||
for (const [crate, bar] of this.bars) {
|
||||
crate.root.getWorldPosition(bar.group.position);
|
||||
bar.group.position.y += HEIGHT_ABOVE;
|
||||
if (camera) bar.group.quaternion.copy(camera.quaternion);
|
||||
|
||||
if (bar.visible) {
|
||||
bar.idleSeconds += delta;
|
||||
if (bar.idleSeconds >= HOLD_S) { bar.visible = false; this.fade(bar, 0, FADE_OUT_MS); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { UpdateController } from "@24tools/playable_template";
|
||||
import { ensurePassionOne } from "../fonts/passionOne";
|
||||
import { PlayerC } from "./PlayerC";
|
||||
import * as TWEEN from "@tweenjs/tween.js";
|
||||
import {
|
||||
zombieHeadUrl,
|
||||
woodPanelUrl,
|
||||
metalPanelUrl,
|
||||
toolPanelUrl,
|
||||
toolIconUrl,
|
||||
} from "../resources/OnbordingUI/onboardingUI";
|
||||
|
||||
// Onboarding/invasion pacing.
|
||||
const MOVE_TO_HURRY_S = 5; // after the player starts moving, wait this long…
|
||||
const HURRY_DURATION_S = 2.5; // …then show "HURRY UP" for this long, then the invasion bar
|
||||
const INVASION_DURATION_S = 60; // the invasion countdown drains over this many seconds
|
||||
const LOW_FRACTION = 0.25; // below this the bar turns red
|
||||
const HALF_FRACTION = 0.5; // at/below this the zombie icon grows + shakes
|
||||
const DEATH_FLY_DELAY_MS = 1000; // pause after death before the zombie icon flies to center
|
||||
|
||||
// The onboarding → invasion → death state machine.
|
||||
enum Phase {
|
||||
WaitMove,
|
||||
Moving,
|
||||
Hurry,
|
||||
Invasion,
|
||||
Dead,
|
||||
}
|
||||
|
||||
/**
|
||||
* The screen-space HUD (HTML overlay): an onboarding flow ("DRAG TO MOVE", then
|
||||
* "HURRY UP" → invasion countdown — both inside the invasion container, since one
|
||||
* replaces the other), wood/metal counters and a weapon panel. When the invasion
|
||||
* timer runs out the player dies and a restart end-card flies in. The wood
|
||||
* panel/count carry stable ids so LootC can fly loot into them.
|
||||
*
|
||||
* Markup classes follow the BEM scheme defined in css/ui.scss: blocks are
|
||||
* `.hud-<block>`, children `__element`, state `--modifier`.
|
||||
*/
|
||||
export class HudC {
|
||||
private static phase = Phase.WaitMove;
|
||||
private static timer = 0; // seconds spent in the current phase
|
||||
private static remaining = INVASION_DURATION_S;
|
||||
|
||||
private static root: HTMLElement | null = null;
|
||||
private static tutorial: HTMLElement | null = null;
|
||||
private static invasion: HTMLElement | null = null;
|
||||
private static invasionFill: HTMLElement | null = null;
|
||||
private static invasionHead: HTMLElement | null = null;
|
||||
private static invasionHeadWrap: HTMLElement | null = null; // rides the fill's edge
|
||||
private static tweens = new TWEEN.Group();
|
||||
private static buttonTween: TWEEN.Tween<{ x: number; y: number }> | null = null;
|
||||
static init() {
|
||||
ensurePassionOne();
|
||||
|
||||
const hud = document.createElement("div");
|
||||
hud.id = "hud";
|
||||
hud.innerHTML = this.markup();
|
||||
document.body.appendChild(hud);
|
||||
this.root = hud;
|
||||
|
||||
this.tutorial = hud.querySelector(".hud-tutorial");
|
||||
this.invasion = hud.querySelector(".hud-invasion");
|
||||
this.invasionFill = hud.querySelector(".hud-invasion__fill");
|
||||
this.invasionHead = hud.querySelector(".hud-invasion__head");
|
||||
this.invasionHeadWrap = hud.querySelector(".hud-invasion__head-wrap");
|
||||
|
||||
// Start with the "DRAG TO MOVE" hint up and the invasion container hidden.
|
||||
// Pre-arm the hurry phase while hidden so revealing it later shows only
|
||||
// "HURRY UP" (never a flash of the bar before it).
|
||||
this.showTutorial("DRAG TO MOVE");
|
||||
this.invasion?.classList.add("hud-invasion--hidden", "hud-invasion--hurry-phase");
|
||||
|
||||
UpdateController.Instance.onUpdate.addDelegate((delta: number) => {
|
||||
this.tick(delta);
|
||||
this.tweens.update();
|
||||
});
|
||||
}
|
||||
|
||||
// Whole HUD as one HTML string — simpler than building each node by hand.
|
||||
// "HURRY UP" lives inside the invasion container: it shows first, then the bar
|
||||
// takes its place (toggled by the .hud-invasion--hurry-phase modifier).
|
||||
private static markup(): string {
|
||||
return `
|
||||
<div class="hud-tutorial"></div>
|
||||
|
||||
<div class="hud-invasion">
|
||||
<div class="hud-invasion__hurry">HURRY UP!</div>
|
||||
<div class="hud-invasion__title">ZOMBIE INVASION</div>
|
||||
<div class="hud-invasion__row">
|
||||
<div class="hud-invasion__bar">
|
||||
<div class="hud-invasion__track"><div class="hud-invasion__fill"></div></div>
|
||||
<div class="hud-invasion__head-wrap"><img class="hud-invasion__head" src="${zombieHeadUrl}" /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hud-resources">
|
||||
<div id="wood-ui" class="hud-resources__panel" style="background-image:url(${woodPanelUrl})">
|
||||
<div id="wood-count" class="hud-resources__count">0</div>
|
||||
</div>
|
||||
<div class="hud-resources__panel" style="background-image:url(${metalPanelUrl})">
|
||||
<div class="hud-resources__count">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hud-weapon" style="background-image:url(${toolPanelUrl})">
|
||||
<img class="hud-weapon__icon" src="${toolIconUrl}" />
|
||||
<div class="hud-weapon__lvl">LVL 0</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private static showTutorial(text: string) {
|
||||
if (!this.tutorial) return;
|
||||
this.tutorial.textContent = text;
|
||||
this.tutorial.classList.add("hud-tutorial--show");
|
||||
}
|
||||
private static hideTutorial() {
|
||||
this.tutorial?.classList.remove("hud-tutorial--show");
|
||||
}
|
||||
|
||||
// Onboarding → invasion → death.
|
||||
private static tick(delta: number) {
|
||||
switch (this.phase) {
|
||||
case Phase.WaitMove:
|
||||
// Wait until the player drags to move, then drop the hint.
|
||||
if (PlayerC.isMoving()) {
|
||||
this.hideTutorial();
|
||||
this.phase = Phase.Moving;
|
||||
this.timer = 0;
|
||||
}
|
||||
break;
|
||||
|
||||
case Phase.Moving:
|
||||
// A few seconds into play, show "HURRY UP" inside the invasion container.
|
||||
this.timer += delta;
|
||||
if (this.timer >= MOVE_TO_HURRY_S) {
|
||||
// --hurry-phase was pre-set in init → fade the container in showing
|
||||
// only HURRY UP.
|
||||
this.invasion?.classList.remove("hud-invasion--hidden");
|
||||
this.phase = Phase.Hurry;
|
||||
this.timer = 0;
|
||||
}
|
||||
break;
|
||||
|
||||
case Phase.Hurry:
|
||||
// After the nudge, swap "HURRY UP" out for the countdown bar (same box).
|
||||
this.timer += delta;
|
||||
if (this.timer >= HURRY_DURATION_S) {
|
||||
this.invasion?.classList.remove("hud-invasion--hurry-phase");
|
||||
this.phase = Phase.Invasion;
|
||||
this.remaining = INVASION_DURATION_S;
|
||||
}
|
||||
break;
|
||||
|
||||
case Phase.Invasion: {
|
||||
this.remaining = Math.max(0, this.remaining - delta);
|
||||
const frac = this.remaining / INVASION_DURATION_S; // time LEFT (1 → 0)
|
||||
const progress = 1 - frac; // invasion FILLS as time elapses (0 → 1)
|
||||
if (this.invasionFill) {
|
||||
this.invasionFill.style.width = `${progress * 100}%`;
|
||||
this.invasionFill.classList.toggle("hud-invasion__fill--low", frac <= LOW_FRACTION);
|
||||
}
|
||||
// The zombie icon rides the filling edge, advancing as the bar fills up.
|
||||
if (this.invasionHeadWrap)
|
||||
this.invasionHeadWrap.style.left = `${progress * 100}%`;
|
||||
// Half time left: grow + shake. Red zone (low): shake harder + bigger.
|
||||
this.invasionHead?.classList.toggle("hud-invasion__head--alert", frac <= HALF_FRACTION);
|
||||
this.invasionHead?.classList.toggle("hud-invasion__head--alert-strong", frac <= LOW_FRACTION);
|
||||
if (this.remaining <= 0) {
|
||||
PlayerC.die();
|
||||
this.onDeath();
|
||||
this.phase = Phase.Dead;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case Phase.Dead:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// End-card: the zombie icon flies from the bar to screen center while growing,
|
||||
// then a restart button fades in beneath it.
|
||||
private static onDeath() {
|
||||
if (!this.root) return;
|
||||
|
||||
// Where the small zombie icon sits now (fly start), and the screen center (end).
|
||||
const r = this.invasionHead?.getBoundingClientRect();
|
||||
const sx = r ? r.left + r.width / 2 : window.innerWidth / 2;
|
||||
const sy = r ? r.top + r.height / 2 : 0;
|
||||
const cx = window.innerWidth / 2;
|
||||
const cy = window.innerHeight / 2;
|
||||
|
||||
this.invasion?.classList.add("hud-invasion--hidden"); // hide the bar + its small icon
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "hud-endcard";
|
||||
card.innerHTML = `<img class="hud-endcard__zombie" src="${zombieHeadUrl}" />`;
|
||||
this.root.appendChild(card);
|
||||
|
||||
const zombie = card.querySelector(".hud-endcard__zombie") as HTMLElement;
|
||||
|
||||
// Start the icon small, at the bar's position; then transition to centered
|
||||
// full size (CSS animates the transform; the button fades in near the end).
|
||||
zombie.style.transform = `translate(${sx - cx}px, ${sy - cy}px) scale(0.35)`;
|
||||
void zombie.offsetWidth; // force reflow so the next change animates
|
||||
// Hold for a beat (the death animation plays), then fly the icon to center.
|
||||
setTimeout(() => {
|
||||
zombie.style.transform = "translate(0, 0) scale(1)";
|
||||
card.classList.add("hud-endcard--show");
|
||||
|
||||
const btn = document.querySelector(".sudHolder_right") as HTMLElement;
|
||||
if (btn) {
|
||||
const startRect = btn.getBoundingClientRect();
|
||||
btn.style.position = "fixed";
|
||||
btn.style.zIndex = "20";
|
||||
btn.style.width = "auto";
|
||||
btn.style.left = `${startRect.left}px`;
|
||||
btn.style.top = `${startRect.top}px`;
|
||||
btn.style.transform = "none";
|
||||
|
||||
// Target = centered just under where the zombie ENDS UP (screen center),
|
||||
// using its final CSS height. Reading its live rect here would catch it
|
||||
// mid-flight (still small / off-center) and put the button in the wrong spot.
|
||||
const zh = parseFloat(getComputedStyle(zombie).height) || 0;
|
||||
const targetX = cx - btn.offsetWidth / 2;
|
||||
const targetY = cy + zh / 2 + 16;
|
||||
|
||||
this.buttonTween?.stop();
|
||||
const state = { x: startRect.left, y: startRect.top };
|
||||
this.buttonTween = new TWEEN.Tween(state, this.tweens)
|
||||
.to({ x: targetX, y: targetY }, 800)
|
||||
.delay(150)
|
||||
.easing(TWEEN.Easing.Quadratic.Out)
|
||||
.onUpdate(({ x, y }) => {
|
||||
btn.style.left = `${x}px`;
|
||||
btn.style.top = `${y}px`;
|
||||
})
|
||||
.start();
|
||||
}
|
||||
}, DEATH_FLY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
+136
-129
@@ -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<any>[] = []; // every hop/pop tween (so we can stop them early)
|
||||
let bouncesMs = 0; // total duration of all the hop arcs
|
||||
|
||||
for (let k = 0; k < hops; k++) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<n> suffix.
|
||||
const statesGroup = crate.children.find(c => c.name.includes("_States"));
|
||||
const statesGroup = crate.children.find(child => child.name.includes("_States"));
|
||||
const statesByLevel: (Object3D | null)[] = [null, null, null];
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
+38
-41
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+37
-94
@@ -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<number>;
|
||||
|
||||
constructor(threeObj: Object3D, physicsObj: Body) {
|
||||
this.threeObj = threeObj;
|
||||
this.physicsObj = physicsObj;
|
||||
this.destroyed = false;
|
||||
|
||||
this.delegateId = UpdateController.Instance.onUpdate.addDelegate(() => {
|
||||
this.update();
|
||||
});
|
||||
}
|
||||
|
||||
update() {
|
||||
if (this.destroyed) return;
|
||||
|
||||
if (this.threeObj && this.physicsObj) {
|
||||
// @ts-ignore
|
||||
this.threeObj.position.copy(this.physicsObj.position);
|
||||
// @ts-ignore
|
||||
this.threeObj.quaternion.copy(this.physicsObj.quaternion);
|
||||
}
|
||||
Physics_internal.physicsWorld?.removeBody(this.body);
|
||||
}
|
||||
}
|
||||
|
||||
+69
-32
@@ -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 {
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,37 +15,10 @@ export class ThreeC extends ThreeC_internal {
|
||||
Template.getValue<number>("global", "light_intensity")
|
||||
}
|
||||
|
||||
static setupDirectionalLightFromScene(dirLightObj:DirectionalLight) {
|
||||
if (!dirLightObj) return;
|
||||
|
||||
ThreeC.removeFromScene(this.defaultDirectionalLight);
|
||||
|
||||
let light = dirLightObj;
|
||||
|
||||
let d = 15;
|
||||
light.shadow.camera.left = -10;
|
||||
light.shadow.camera.right = d;
|
||||
light.shadow.camera.top = d;
|
||||
light.shadow.camera.bottom = -2;
|
||||
|
||||
light.castShadow = true;
|
||||
light.shadow.normalBias = 0.04;
|
||||
light.intensity = 1;
|
||||
|
||||
light.shadow.mapSize.width = 1024;
|
||||
light.shadow.mapSize.height = 1024;
|
||||
|
||||
const ambLight = new AmbientLight(0xffffff, 0.4); // soft white light
|
||||
this.addToScene(ambLight);
|
||||
this.addToScene(light);
|
||||
|
||||
this.defaultDirectionalLight = light;
|
||||
}
|
||||
|
||||
static setupDirectionalLight() {
|
||||
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
|
||||
|
||||
+5
-2
@@ -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 {
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
#ui {
|
||||
/* flex-basis: 60%; */
|
||||
flex-grow: 1;
|
||||
}
|
||||
+363
@@ -0,0 +1,363 @@
|
||||
@use "sass:math";
|
||||
|
||||
#ui {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
HUD — screen-space overlay built by HudC.
|
||||
|
||||
Structure follows BEM: each visual block (invasion bar, resources,
|
||||
weapon, tutorial hint, death end-card) is a `.hud-<block>` class with
|
||||
`&__element` children and `&--modifier` states. SCSS nesting (`&`) keeps
|
||||
each block's rules in one place so we never repeat the prefix.
|
||||
|
||||
The whole HUD is viewport-relative: lengths use vw via the fluid helpers
|
||||
below, so it scales as one piece with the device.
|
||||
============================================================ */
|
||||
|
||||
// Core fluid helper: a clamp() that grows linearly with the viewport from $min
|
||||
// (at $vp-min) to $max (at $vp-max), and stays flat outside that range.
|
||||
//
|
||||
// The unit it scales against is the CSS variable --fluid-unit, set on #hud:
|
||||
// 1vw in portrait (scale with width) and 1vh in landscape (scale with the
|
||||
// limiting height). Landscape screens are wide but short, so scaling by width
|
||||
// there blows the HUD up — height keeps it proportional.
|
||||
@function fluid-clamp($min, $max, $vp-min: 320px, $vp-max: 900px) {
|
||||
$slope: math.div($max - $min, $vp-max - $vp-min); // px/px → unitless
|
||||
$intercept: $min - $slope * $vp-min; // px
|
||||
@return clamp(#{$min}, #{$intercept} + #{$slope * 100} * var(--fluid-unit, 1vw), #{$max});
|
||||
}
|
||||
|
||||
// Fluid font-size from a px range.
|
||||
@mixin fluid($min, $max) {
|
||||
font-size: fluid-clamp($min, $max);
|
||||
}
|
||||
|
||||
// Fluid image size from px ranges. Pass 2 args for a square image (height reuses
|
||||
// the width range) or 4 args for a non-square one (separate height range).
|
||||
@mixin fluid-img($wmin, $wmax, $hmin: null, $hmax: null) {
|
||||
width: fluid-clamp($wmin, $wmax);
|
||||
@if $hmin == null {
|
||||
height: fluid-clamp($wmin, $wmax); // square: reuse the width range
|
||||
} @else if $hmax == null {
|
||||
height: fluid-clamp($hmin, $wmax); // only a min height given: cap at the width max
|
||||
} @else {
|
||||
height: fluid-clamp($hmin, $hmax); // full height range given
|
||||
}
|
||||
}
|
||||
|
||||
// Fluid value for any single property from a px range — bounds it between
|
||||
// $min/$max while it scales with the viewport (e.g. to cap the invasion bar).
|
||||
@mixin fluid-prop($prop, $min, $max) {
|
||||
#{$prop}: fluid-clamp($min, $max);
|
||||
}
|
||||
|
||||
#hud {
|
||||
position: fixed;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding-inline: 16px;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
font-family: "PassionOne", "Roboto", sans-serif;
|
||||
|
||||
// Portrait: every fluid size scales with viewport width.
|
||||
--fluid-unit: 1vw;
|
||||
|
||||
@media screen and (min-width: 500px) {
|
||||
padding-inline: 24px;
|
||||
}
|
||||
|
||||
// Landscape: scale with height instead (short screens → keep the HUD small).
|
||||
@media (orientation: landscape) {
|
||||
--fluid-unit: 1vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Zombie Invasion bar (top) ----------------------------
|
||||
"HURRY UP" and the countdown share this one container and crossfade: the
|
||||
--hurry-phase modifier shows the hurry text and hides the bar; dropping it
|
||||
reveals the bar. The zombie head rides the fill's leading edge. */
|
||||
.hud-invasion {
|
||||
position: absolute;
|
||||
min-height: 80px;
|
||||
padding-inline: 64px;
|
||||
top: 0.5rem;
|
||||
left: 50%;
|
||||
transform: translate(-50%);
|
||||
width: 100%;
|
||||
max-width: 768px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
transition: opacity 0.4s ease;
|
||||
|
||||
@media (orientation: landscape) {
|
||||
padding-inline: 80px;
|
||||
}
|
||||
|
||||
// Fully hidden (before the onboarding reaches the invasion stage, and after death).
|
||||
&--hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
@include fluid(24px, 48px);
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
letter-spacing: 1px;
|
||||
-webkit-text-stroke: 1px #000;
|
||||
-webkit-text-fill-color: #fff;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// Holds the track + the head that rides the fill's moving edge.
|
||||
&__bar {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__track {
|
||||
width: 100%;
|
||||
@include fluid-prop(height, 20px, 32px); // bound the bar thickness so it can't blow up
|
||||
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.45));
|
||||
border: 2px solid #000;
|
||||
border-radius: 1000px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__fill {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
// glossy green: highlight on top → base → darker bottom (width set per-frame in JS)
|
||||
background: linear-gradient(to bottom, #c2f57a 0%, #7fd23a 45%, #4f9e18 100%);
|
||||
|
||||
// Final stretch (low time left): glossy red.
|
||||
&--low {
|
||||
background: linear-gradient(to bottom, #ff9a6a 0%, #f0512c 45%, #c5301a 100%);
|
||||
}
|
||||
}
|
||||
|
||||
// The zombie head sits on the fill's leading edge; `left` is set per-frame in
|
||||
// JS (0%..100% of the bar). The transform here only centers it — the shake
|
||||
// animation lives on the inner <img>, so the two transforms don't fight.
|
||||
&__head-wrap {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 100%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&__head {
|
||||
@include fluid-img(44px, 70px);
|
||||
display: block; // kill the inline-image descender gap so it centers on the track
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 0.5vw 0.8vw rgba(0, 0, 0, 0.5));
|
||||
flex: none;
|
||||
transform-origin: center;
|
||||
|
||||
// Half time left: the icon grows and shakes to draw attention.
|
||||
&--alert {
|
||||
animation: hud-zombie-shake 0.45s ease-in-out infinite;
|
||||
}
|
||||
// Red zone: shake harder + bigger. Declared after --alert so it wins when both are set.
|
||||
&--alert-strong {
|
||||
animation: hud-zombie-shake-strong 0.28s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
// "HURRY UP" overlays the bar in the same box; absolute + inset:0 centers it
|
||||
// over the bar regardless of the container's side padding.
|
||||
&__hurry {
|
||||
@include fluid(48px, 64px);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
letter-spacing: 1px;
|
||||
-webkit-text-stroke: 1px #000;
|
||||
opacity: 0;
|
||||
transition: opacity 0.35s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Crossfade between the two states (both transition opacity).
|
||||
&__title,
|
||||
&__row {
|
||||
transition: opacity 0.35s ease;
|
||||
}
|
||||
&--hurry-phase &__hurry {
|
||||
opacity: 1;
|
||||
}
|
||||
&--hurry-phase &__title,
|
||||
&--hurry-phase &__row {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Resources (top-right) -------------------------------- */
|
||||
.hud-resources {
|
||||
position: absolute;
|
||||
top: 20%;
|
||||
right: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
align-items: flex-end;
|
||||
|
||||
@media screen and (min-width: 500px) {
|
||||
right: 24px;
|
||||
}
|
||||
|
||||
@media (orientation: landscape) {
|
||||
top: 30%;
|
||||
}
|
||||
|
||||
// One resource panel. The background art already includes the icon, so the
|
||||
// whole panel is both the loot fly-target and the thing that pulses on collect.
|
||||
&__panel {
|
||||
@include fluid-img(64px, 96px, 30px, 46px);
|
||||
position: relative;
|
||||
background-size: 100% 100%;
|
||||
background-repeat: no-repeat;
|
||||
transition: transform 0.12s ease-out;
|
||||
}
|
||||
|
||||
&__count {
|
||||
@include fluid(13px, 22px);
|
||||
position: absolute;
|
||||
left: 6%;
|
||||
right: 28%; // keep the number clear of the colored tab on the right
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-family: Roboto, sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Weapon panel (bottom-left) --------------------------- */
|
||||
.hud-weapon {
|
||||
@include fluid-img(64px, 80px);
|
||||
position: absolute;
|
||||
bottom: 25%;
|
||||
background-size: 100% 100%;
|
||||
background-repeat: no-repeat;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&__icon {
|
||||
width: 70%;
|
||||
height: 70%;
|
||||
position: relative;
|
||||
top: -5px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
&__lvl {
|
||||
@include fluid(14px, 18px);
|
||||
font-family: Roboto, sans-serif;
|
||||
font-weight: 600;
|
||||
position: absolute;
|
||||
bottom: 1%;
|
||||
left: 62%;
|
||||
transform: translateX(-50%);
|
||||
color: #fff;
|
||||
letter-spacing: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Onboarding hint -------------------------------------- */
|
||||
/* Centered hint ("DRAG TO MOVE") that fades + scales in/out. */
|
||||
.hud-tutorial {
|
||||
@include fluid(30px, 56px);
|
||||
position: absolute;
|
||||
top: 63%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
letter-spacing: 1px;
|
||||
-webkit-text-stroke: 1px #000;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.25s ease,
|
||||
transform 0.25s ease;
|
||||
white-space: nowrap;
|
||||
|
||||
&--show {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Death end-card --------------------------------------- */
|
||||
/* The zombie icon flies to center (smooth ease-out + fade), then the button. */
|
||||
.hud-endcard {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
|
||||
&__zombie {
|
||||
@include fluid-img(150px, 300px);
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 1vw 2vw rgba(0, 0, 0, 0.6));
|
||||
opacity: 0;
|
||||
transition:
|
||||
transform 1s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
opacity 0.6s ease;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
// Revealed once the death animation has played (set in JS).
|
||||
&--show .hud-endcard__zombie {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hud-zombie-shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1.25) rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: scale(1.25) rotate(-12deg);
|
||||
}
|
||||
75% {
|
||||
transform: scale(1.25) rotate(12deg);
|
||||
}
|
||||
}
|
||||
@keyframes hud-zombie-shake-strong {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1.45) rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: scale(1.45) rotate(-22deg);
|
||||
}
|
||||
75% {
|
||||
transform: scale(1.45) rotate(22deg);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export enum VFXType {
|
||||
HitEffect,
|
||||
DestroyEffect
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,20 @@
|
||||
import { ConvertToBase64WhenRelease } from "@24tools/ads_common";
|
||||
|
||||
// Heavy display font used for the HUD labels (matches the REF look). Inlined as
|
||||
// base64 on release. We inject an @font-face at runtime so it works in dev too.
|
||||
const passionOneUrl = ConvertToBase64WhenRelease("./PassionOne-Black.otf");
|
||||
|
||||
export const PASSION_ONE = "PassionOne";
|
||||
|
||||
let injected = false;
|
||||
|
||||
/** Register the PassionOne font once. Safe to call multiple times. */
|
||||
export function ensurePassionOne() {
|
||||
if (injected) return;
|
||||
injected = true;
|
||||
const style = document.createElement("style");
|
||||
style.textContent =
|
||||
`@font-face{font-family:'${PASSION_ONE}';` +
|
||||
`src:url(${passionOneUrl}) format('opentype');font-weight:900;font-display:swap;}`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
|
||||
<link href="./css/loader.css" rel="stylesheet" />
|
||||
<link href="./css/main.css" rel="stylesheet" />
|
||||
<link href="./css/ui.css" rel="stylesheet" />
|
||||
<link href="./css/ui.scss" rel="stylesheet" />
|
||||
<style id="animations"></style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+4
-2
@@ -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({
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ConvertToBase64WhenRelease } from "@24tools/ads_common";
|
||||
|
||||
// HUD image URLs. In a release build each is inlined as base64 (like the loot
|
||||
// icon). Paths are relative to this file. Usable directly in <img src> or CSS url().
|
||||
export const zombieHeadUrl = ConvertToBase64WhenRelease("./Icon_Zombie_Head.webp"); // invasion bar + death end-card
|
||||
export const woodPanelUrl = ConvertToBase64WhenRelease("./ResourceBackground_Wood.webp");
|
||||
export const metalPanelUrl = ConvertToBase64WhenRelease("./ResourceBackground_Metal.webp");
|
||||
export const toolPanelUrl = ConvertToBase64WhenRelease("./Tool_Backgtound.webp"); // weapon panel background
|
||||
export const toolIconUrl = ConvertToBase64WhenRelease("./Tool_1.webp"); // weapon icon
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,7 +32,7 @@ export function quarksLoader(base64String: string) {
|
||||
JSON.parse(atob(base64String.split(",")[1])),
|
||||
(obj) => {
|
||||
resolve({ obj });
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
reject("Error loading vfx: " + error);
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Camera, Vector3 } from "three";
|
||||
|
||||
// Scratch vector reused across calls so projecting never allocates per frame.
|
||||
const _ndc = new Vector3();
|
||||
|
||||
/**
|
||||
* Project a world-space point to screen pixels.
|
||||
*
|
||||
* `camera.project()` gives Normalized Device Coordinates (NDC): the visible area
|
||||
* maps to -1..1 on both axes, with +Y up. We remap that to CSS pixels and offset
|
||||
* by the canvas position on the page (`canvasRect`), flipping Y because the DOM
|
||||
* grows downward. Used to fly HTML elements (loot, planks) to/from 3D objects.
|
||||
*/
|
||||
export function worldToScreen(
|
||||
world: Vector3,
|
||||
camera: Camera,
|
||||
canvasRect: DOMRect,
|
||||
): { x: number; y: number } {
|
||||
_ndc.copy(world).project(camera);
|
||||
return {
|
||||
x: canvasRect.left + (_ndc.x * 0.5 + 0.5) * canvasRect.width,
|
||||
y: canvasRect.top + (-_ndc.y * 0.5 + 0.5) * canvasRect.height,
|
||||
};
|
||||
}
|
||||
+4949
File diff suppressed because one or more lines are too long
+1
-1
@@ -10,7 +10,7 @@
|
||||
"noEmit": false,
|
||||
"removeComments": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitAny": false,
|
||||
"allowJs": true,
|
||||
"types": ["vite/client"]
|
||||
|
||||
+16
-2
@@ -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
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user