some changes

This commit is contained in:
Oleksandr Vlasiuk
2026-08-17 12:36:56 +03:00
parent 1f72420da0
commit 1d38990d7f
24 changed files with 3498 additions and 674 deletions
+3 -1
View File
@@ -35,7 +35,9 @@ export const gameplay: ConfigUiParamsCategories[] = [
id: "zombie_invasion_timer_s",
name: "Zombie Invasion timer (s)",
type: "int",
values: [5, 300, 120],
// Also how long the player gets before the zombie wave spawns — the
// bar filling is what triggers it (see ZombieC.startWave).
values: [5, 300, 30],
},
],
},
+34
View File
@@ -0,0 +1,34 @@
// Bat level: a second, separate progression from the pistol's own (see
// PistolLevelC). Bat levels come from filling the wood Pay Zone (crates drop
// wood, see ResourceC / PayZoneC); pistol levels come from filling the
// zombie-part Pay Zone instead.
//
// Starts at 0 — that's the number the bottom-left octagon plate has always
// shown as a hardcoded placeholder (see WeaponHudC), so the first delivery
// reads as "LVL 0 -> LVL 1".
//
// Added to the bat's damage per crate hit (see PlayerC.updateCombat ->
// BreakablePropC.hit), on top of the debug panel's own base hit_damage.
const DAMAGE_PER_LEVEL = 10;
// State only, and deliberately imports nothing: PayZoneC raises it, WeaponHudC
// displays it, PlayerC reads its damage bonus, and any import back into those
// would cycle.
export class BatLevelC {
private static level = 0;
static getLevel(): number {
return this.level;
}
static getBonusDamage(): number {
return this.level * DAMAGE_PER_LEVEL;
}
// One filled wood Pay Zone = one bat level. There is no cap: the zone
// reopens in place every time it's filled, so this can keep climbing for as
// long as the run lasts.
static levelUp() {
this.level++;
}
}
+158 -9
View File
@@ -1,10 +1,10 @@
import { Euler, Mesh, MeshBasicMaterial, Object3D, Vector3 } from "three";
import { Box3, Euler, Mesh, MeshBasicMaterial, Object3D, Vector3 } from "three";
import { Easing, Tween } from "@tweenjs/tween.js";
import { TweenC } from "@hitplay/playable_template";
import { PhysicsBody, PhysicsLayer } from "./PhysicsC";
import { ResourceC } from "./ResourceC";
import { PropVfxC } from "./PropVfxC";
import { HealthBarC } from "./HealthBarC";
import { HealthBarC, HealthBarOptions } from "./HealthBarC";
import { DebugConfigC } from "./DebugConfigC";
import { chainAndRegisterTweens } from "../utils/tweenChain";
import { createFlashOverlayMaterial } from "../utils/flashOverlayMaterial";
@@ -19,6 +19,11 @@ import { createFlashOverlayMaterial } from "../utils/flashOverlayMaterial";
// Not every crate has all three stages — some start pre-damaged (S2/S3 only)
// or pre-broken (S3 only) as level-design variety, so "hit" always just
// advances to the next stage that exists, whatever that is.
// How far above the crate's own real bounding-box top (world units, not a
// guessed pixel offset) its health bar sits. Lived in HealthBarC while that
// file still knew what a crate was; now each owner supplies its own anchor.
const HEALTH_BAR_HEIGHT_OFFSET = 0.2;
interface StageVisual {
node: Object3D; // the "_S1"/"_S2"/"_S3" group — toggled visible per stage
mesh: Mesh | null; // the actual textured mesh inside it, for the hit-flash
@@ -32,6 +37,10 @@ export interface BreakableProp {
root: Object3D;
colliderNode: Object3D;
baseRotation: Euler;
// Resting transform, captured once at build time — both the destroy sink and
// the respawn rise move the root off it, so neither can be used to derive
// "where does this crate belong" once it has been broken even once.
basePosition: Vector3;
stages: StageVisual[];
stageIndex: number;
// HP is the authoritative damage/destroy state — stageIndex above is a
@@ -41,6 +50,10 @@ export interface BreakableProp {
// Always MAX_HP for a fresh (S1-start) crate; lower for ones that start
// pre-damaged (S2/S3-only) — see buildProp for the derivation.
maxHp: number;
// The HP this particular crate opened the run with (below maxHp for the
// pre-damaged ones) — what a respawn restores it to, so a crate that started
// as rubble doesn't come back as a pristine full-HP box.
startHp: number;
// How many global damage stages a FRESH version of this crate would have
// (derived from its own last stage's "_SN" number, which is always the
// deepest/final rubble stage — see stageNumber) and which one of those
@@ -49,8 +62,11 @@ export interface BreakableProp {
// (possibly truncated) stages array.
totalPossibleStages: number;
startStageNumber: number;
// Recreated on every respawn, since destroy() removes the old one from the
// physics world outright.
physicsBody: PhysicsBody;
hitTween: Tween<{ t: number }> | null;
respawnTimeout: ReturnType<typeof setTimeout> | null;
}
const STAGE_NUMBER_PATTERN = /_S(\d+)$/i;
@@ -89,19 +105,41 @@ const DESTROY_SINK_MS = 250;
const DESTROY_SINK_DEPTH = 1.2; // world units — enough to fully submerge below the visible ground
const DESTROY_TILT_ANGLE = 0.6; // radians, ~34° — a pronounced topple, not a subtle lean
// A broken crate comes back, whole, after this long — the map holds ~18 of
// them, so without this a long run eventually strips it bare and the wood
// economy (and with it the Pay Zone / weapon levelling) dries up.
const RESPAWN_DELAY_MS = 30000;
// Rises back out of the ground it sank into, mirroring the destroy sink, with
// the hit-flash overlay fading off over the same window so it reads as
// materialising rather than as an elevator.
const RESPAWN_RISE_MS = 350;
const RESPAWN_FLASH_MAX_OPACITY = 0.5;
// Safety margin for the rise's timeout fallback — tween onComplete is not
// reliable enough in this project to be the only thing restoring the resting
// transform (see CLAUDE.md).
const RESPAWN_CLEANUP_MARGIN_MS = 200;
export class BreakablePropC {
// Only the crates that are currently standing — a destroyed one is removed
// and put back by its respawn timer (see destroy/respawn).
private static byColliderNode = new Map<Object3D, BreakableProp>();
private static onDestroyed: ((colliderNode: Object3D) => void) | null = null;
private static onRespawned: ((colliderNode: Object3D) => void) | null = null;
// Returns the collider nodes it took ownership of, so the caller (which
// already built a generic /collider/i list for movement/camera raycasts)
// knows not to also give those the same treatment as plain walls.
// knows not to also give those the same treatment as plain walls. Crates come
// BACK after RESPAWN_DELAY_MS, so that set stays valid for the whole run —
// the same collider nodes are reused, never rebuilt.
static init(
lootableRoot: Object3D | undefined,
onDestroyed: (colliderNode: Object3D) => void
onDestroyed: (colliderNode: Object3D) => void,
onRespawned: (colliderNode: Object3D) => void
): Set<Object3D> {
this.byColliderNode.clear();
this.onDestroyed = onDestroyed;
this.onRespawned = onRespawned;
if (lootableRoot) {
for (const crateRoot of lootableRoot.children) {
@@ -118,16 +156,37 @@ export class BreakablePropC {
}
// Every hit takes the debug panel's configured damage (default 10, see
// DebugConfigC) off currentHp; the visual stage is derived from the
// remaining HP fraction (see below), and reaching 0 HP destroys it.
// DebugConfigC) plus `bonusDamage` (the bat's own level bonus — see
// BatLevelC.getBonusDamage, injected by the caller rather than imported
// here to avoid a cycle) off currentHp; the visual stage is derived from
// the remaining HP fraction (see below), and reaching 0 HP destroys it.
// Every hit plays feedback + updates HealthBarC; a stage advance (or the
// destroying hit) also drops resources. `hitDirection` (attacker ->
// target, XZ, normalized) drives which way the crate leans/topples.
static hit(prop: BreakableProp, hitDirection: Vector3) {
const damage = DebugConfigC.getHitDamage();
// Crates never move, so the bar's anchor is sampled once (no `follow`) —
// HealthBarC only re-projects the fixed point as the camera moves. Real
// geometry, not a guessed offset: the crate's own world bounding box top,
// centred on X/Z, plus a small gap.
private static healthBarOptions(prop: BreakableProp): HealthBarOptions {
return {
variant: "prop",
maxHp: prop.maxHp,
getWorldTop: () => {
const box = new Box3().setFromObject(prop.root);
return new Vector3(
(box.min.x + box.max.x) / 2,
box.max.y + HEALTH_BAR_HEIGHT_OFFSET,
(box.min.z + box.max.z) / 2
);
},
};
}
static hit(prop: BreakableProp, hitDirection: Vector3, bonusDamage: number = 0) {
const damage = DebugConfigC.getHitDamage() + bonusDamage;
const previousHp = prop.currentHp;
prop.currentHp = Math.max(0, prop.currentHp - damage);
HealthBarC.onHit(prop, previousHp, prop.currentHp, damage);
HealthBarC.onHit(prop, this.healthBarOptions(prop), previousHp, prop.currentHp, damage);
// A bit above the root's ground-level pivot, so the burst visually
// comes from around the crate's body rather than the floor under it.
@@ -194,14 +253,17 @@ export class BreakablePropC {
root,
colliderNode,
baseRotation: root.rotation.clone(),
basePosition: root.position.clone(),
stages,
stageIndex: 0,
currentHp,
maxHp: MAX_HP,
startHp: currentHp,
totalPossibleStages,
startStageNumber,
physicsBody,
hitTween: null,
respawnTimeout: null,
};
}
@@ -286,6 +348,93 @@ export class BreakablePropC {
HealthBarC.onDestroyed(prop);
PropVfxC.spawnDestroy(prop.root.getWorldPosition(new Vector3()));
this.playDisappearAnimation(prop, hitDirection);
// Guarded: a crate can only be destroyed while it's standing, so there
// should never be a timer already pending here — but re-arming one would
// leak the old timeout and respawn the crate twice, and the second respawn
// would leave a second physics body in the world for the same collider.
if (prop.respawnTimeout !== null) clearTimeout(prop.respawnTimeout);
prop.respawnTimeout = setTimeout(() => this.respawn(prop), RESPAWN_DELAY_MS);
}
// Puts a destroyed crate back, whole, at its original spot: resting transform,
// starting HP, first stage visible, a fresh physics body, and back into the
// lookup so hits find it again. The caller-side obstacle list is restored
// through onRespawned (the mirror of onDestroyed).
private static respawn(prop: BreakableProp) {
prop.respawnTimeout = null;
// Position and rotation only — scale is never touched by the destroy path,
// and forcing it to 1 here would resize any crate the artist didn't author
// at unit scale.
prop.root.position.copy(prop.basePosition);
prop.root.rotation.copy(prop.baseRotation);
prop.root.visible = true;
// The transform above has to reach matrixWorld before anything measures the
// crate. Box3.setFromObject only refreshes the node it's given (using its
// parent's EXISTING matrixWorld), so the collider box PlayerC.addObstacle
// samples via onRespawned below would otherwise come out at the sunk,
// toppled transform this crate was left in when it broke.
prop.root.updateWorldMatrix(true, true);
prop.currentHp = prop.startHp;
prop.stageIndex = 0;
for (let i = 0; i < prop.stages.length; i++) {
prop.stages[i].node.visible = i === 0;
// The destroying hit's flash was cut off mid-fade (destroy() stops the
// tween), so any stage could still be carrying a stale opacity.
const flashMaterial = prop.stages[i].flashMesh?.material as MeshBasicMaterial | undefined;
if (flashMaterial) flashMaterial.opacity = 0;
}
prop.hitTween = null;
prop.physicsBody = new PhysicsBody(
prop.colliderNode,
false,
0,
PhysicsLayer.Wall,
PhysicsLayer.Player | PhysicsLayer.Enemy
);
this.byColliderNode.set(prop.colliderNode, prop);
// Before the rise animation below, not after: the crate is solid from the
// moment it's back, and the obstacle box is sampled from the resting
// transform that's already been restored above.
this.onRespawned?.(prop.colliderNode);
this.playRespawnAnimation(prop);
}
// Mirrors playDisappearAnimation: rises back out of the ground it sank into,
// with the hit-flash overlay fading off over the same window.
private static playRespawnAnimation(prop: BreakableProp) {
const root = prop.root;
const baseY = prop.basePosition.y;
const flashMaterial = prop.stages[prop.stageIndex].flashMesh?.material as MeshBasicMaterial | undefined;
const settle = () => {
root.position.y = baseY;
if (flashMaterial) flashMaterial.opacity = 0;
};
root.position.y = baseY - DESTROY_SINK_DEPTH;
const rise = new Tween({ t: 0 })
.to({ t: 1 }, RESPAWN_RISE_MS)
.easing(Easing.Quadratic.Out)
.onUpdate(({ t }) => {
root.position.y = baseY - DESTROY_SINK_DEPTH * (1 - t);
if (flashMaterial) flashMaterial.opacity = RESPAWN_FLASH_MAX_OPACITY * (1 - t);
})
.onComplete(settle);
TweenC.add(rise);
rise.start();
// Redundant safety net — a crate left frozen halfway underground (or lit up
// white) would be far worse than one that snaps the last few frames.
setTimeout(settle, RESPAWN_RISE_MS + RESPAWN_CLEANUP_MARGIN_MS);
}
// Fully gone, not just its collider — sinks straight down into the ground
+106
View File
@@ -0,0 +1,106 @@
import { Object3D, Vector3 } from "three";
import { UpdateController } from "@hitplay/playable_template";
import { ThreeC } from "./ThreeC";
// Fast enough that the round reads as a tracer rather than a thrown pebble,
// slow enough to actually be visible over the ~4-8 unit gap the player
// shoots across (~0.15s of flight).
const SPEED = 45;
// Safety net: a bullet whose target dies and stops being tracked mid-flight
// still has a fixed destination, but a mis-aimed one would otherwise fly
// forever. Nothing should ever reach this — it exists so a leak can't.
const MAX_LIFETIME_S = 2;
// Distance from the destination at which the bullet is considered to have
// landed. One frame's travel at SPEED is ~0.75 units at 60fps, so anything
// smaller than that would be stepped straight over every frame.
const HIT_DISTANCE = 0.8;
interface Bullet {
root: Object3D;
direction: Vector3;
distanceLeft: number;
elapsed: number;
onArrive: (() => void) | null;
}
// Flying pistol rounds, cloned from the `Bullet` rig that ships inside
// ZombiePunk_Character.glb (a bullet mesh + a tapering trail of circles + a
// glow, modelled pointing along +Z with the trail behind it — which is
// exactly what Object3D.lookAt orients, so no corrective rotation is needed).
//
// Purely a travelling visual with a callback on arrival: PlayerC hands over
// the damage as the `onArrive` payload, so a zombie visibly takes the hit
// when the round reaches it rather than the instant the trigger is pulled.
export class BulletC {
private static initialized = false;
private static template: Object3D | null = null;
private static bullets: Bullet[] = [];
// `template` is the `Bullet` node out of the character glb. It's detached
// from the character (not just hidden in place) so clones don't inherit the
// rig's own transform — every clone is positioned in world space instead.
static init(template: Object3D) {
if (this.initialized) return;
this.initialized = true;
template.removeFromParent();
template.visible = true;
this.template = template;
UpdateController.Instance.onUpdate.addDelegate((delta: number) => this.update(delta));
}
static spawn(from: Vector3, to: Vector3, onArrive: (() => void) | null = null) {
if (!this.template) return;
const direction = to.clone().sub(from);
const distance = direction.length();
if (distance < 1e-4) {
// Muzzle already on top of the target — nothing to fly, but the hit
// still has to land or the shot would silently do nothing.
onArrive?.();
return;
}
direction.divideScalar(distance);
const root = this.template.clone(true);
root.position.copy(from);
root.lookAt(to);
ThreeC.addToScene(root);
this.bullets.push({ root, direction, distanceLeft: distance, elapsed: 0, onArrive });
}
// Wipes every round in flight without firing their arrival callbacks — used
// when the run ends (player death), where a bullet landing after the fact
// would damage a zombie during the game-over screen.
static clear() {
for (const bullet of this.bullets) ThreeC.removeFromScene(bullet.root);
this.bullets.length = 0;
}
private static update(delta: number) {
// Iterated backwards: arrived bullets splice themselves out of this same
// array inside the loop.
for (let i = this.bullets.length - 1; i >= 0; i--) {
const bullet = this.bullets[i];
const step = SPEED * delta;
bullet.root.position.addScaledVector(bullet.direction, step);
bullet.distanceLeft -= step;
bullet.elapsed += delta;
const arrived = bullet.distanceLeft <= HIT_DISTANCE;
if (!arrived && bullet.elapsed < MAX_LIFETIME_S) continue;
// The lifetime expiry path deliberately still fires onArrive: the
// damage must land exactly once per shot, and dropping it silently
// would make shots randomly do nothing if anything ever stalls.
bullet.onArrive?.();
ThreeC.removeFromScene(bullet.root);
this.bullets.splice(i, 1);
}
}
}
+95
View File
@@ -0,0 +1,95 @@
// Seconds of red-veil screen before the run restarts. Counted down on
// screen so the restart never feels like a crash.
const RESTART_DELAY_S = 3;
// Red veil + countdown shown when the player dies, then a restart.
//
// Mounted into #ui (NOT #ui-safe-area): the veil has to cover the whole
// letterboxed game area edge to edge, and #ui-safe-area is deliberately inset
// from it by --ui-safe-margin, which would leave an unpainted frame around
// the outside.
export class GameOverC {
private static shown = false;
private static intervalId: ReturnType<typeof setInterval> | null = null;
static isShown(): boolean {
return this.shown;
}
static show() {
if (this.shown) return;
this.shown = true;
const overlay = document.createElement("div");
overlay.className = "game-over";
const title = document.createElement("div");
title.className = "game-over__title";
const titleText = document.createElement("span");
// .gamefont-x — the global font-width squeeze lives on an inline-block
// child everywhere else in this project (see main.css), same here.
titleText.className = "gamefont-x";
titleText.textContent = "YOU DIED";
title.appendChild(titleText);
const counter = document.createElement("div");
counter.className = "game-over__counter";
counter.appendChild(this.buildCounterText(`${RESTART_DELAY_S}`));
overlay.appendChild(title);
overlay.appendChild(counter);
const root = document.getElementById("ui") ?? document.body;
root.appendChild(overlay);
let remaining = RESTART_DELAY_S;
this.intervalId = setInterval(() => {
remaining--;
if (remaining > 0) {
// A brand-new element each tick, not just new textContent: a CSS
// animation only replays if its element is fresh (or its
// animation-name actually changes — see the two-elements note in
// ui.css), so re-texting in place would pop once for the first
// number and then sit still for the rest.
counter.replaceChildren(this.buildCounterText(`${remaining}`));
return;
}
if (this.intervalId !== null) clearInterval(this.intervalId);
this.intervalId = null;
this.restart();
}, 1000);
}
// Two nested elements, not one: .gamefont-x carries the project-wide font
// squeeze as a `transform`, and the pop animation below also animates
// `transform` — on a single element the animation would win and the digit
// would visibly un-squeeze for the length of every pop, then snap back.
// The outer wrapper animates, the inner span keeps the squeeze, and the two
// transforms compose. Same split as .zombie-invasion__title and
// .health-bar__damage.
private static buildCounterText(value: string): HTMLElement {
const wrapper = document.createElement("div");
wrapper.className = "game-over__counter-value";
const span = document.createElement("span");
span.className = "gamefont-x";
span.textContent = value;
wrapper.appendChild(span);
return wrapper;
}
// A full page reload, not a state reset: every controller in this project
// is a static singleton that builds its world once at init (crate stages,
// spawned resources, the invasion timer, the DOM widgets), so a real
// in-place reset would mean adding a teardown path to each one. Reloading
// re-runs the same inline bundle in the export, so it works there as well
// as in dev.
//
// ⚠️ If an ad network's container ever refuses the reload, this one line is
// the whole thing to swap out for a per-controller reset.
private static restart() {
window.location.reload();
}
}
+80 -70
View File
@@ -1,20 +1,15 @@
import { Box3, Camera, Vector3 } from "three";
import { Camera, Vector3 } from "three";
import { CameraC_internal, UpdateController } from "@hitplay/playable_template";
import { ThreeC } from "./ThreeC";
import type { BreakableProp } from "./BreakablePropC";
import { projectWorldPointToScreen } from "../utils/screenWorldProjection";
import { DebugConfigC } from "./DebugConfigC";
// If a bar's crate stops getting hit and the player walks away before it's
// destroyed, the bar lingers this long before fading out — reset to 0 on
// every hit, see onHit.
// Every bar (crate, zombie, the player's own) appears on its first hit and
// lingers this long after the last one before fading out — reset to 0 on
// every hit, see onHit. Nothing shows a bar at full, untouched HP.
const HIDE_DELAY_S = 2;
const FADE_OUT_MS = 250;
// How far above the crate's own real bounding-box top (world units, not a
// guessed pixel offset — see getOrCreateBar) the bar sits.
const BAR_HEIGHT_OFFSET = 0.2;
// Kept in sync with health-bar.css's health-bar-damage-punch/-float
// animation durations, so the popup element is removed right as whichever
// CSS animation is active finishes, not before/after (see
@@ -30,14 +25,32 @@ const DAMAGE_POPUP_FLOAT_LIFETIME_MS = 700;
const DAMAGE_ROTATE_MIN_DEG = 10;
const DAMAGE_ROTATE_MAX_DEG = 50;
// Drives the colour scheme only (see health-bar.css) — the numbers stay
// white on every variant.
// prop blue fill / white just-lost (crates)
// enemy red fill / yellow just-lost (zombies)
// player green fill / yellow just-lost
export type HealthBarVariant = "prop" | "enemy" | "player";
export interface HealthBarOptions {
variant: HealthBarVariant;
maxHp: number;
// World point the bar hovers over. Called once at creation for stationary
// owners (crates), and every frame when `follow` is set — a zombie or the
// player moves, so a cached point would leave its bar behind on the map.
getWorldTop: () => Vector3;
follow?: boolean;
}
interface BarInstance {
prop: BreakableProp;
key: object;
options: HealthBarOptions;
container: HTMLElement;
whiteEl: HTMLElement;
blueEl: HTMLElement;
// Cached once at bar creation, not recomputed per frame — crates never
// move once placed, so only the CAMERA'S projection of this fixed world
// point needs updating every frame, not the point itself.
lostEl: HTMLElement;
fillEl: HTMLElement;
// Recomputed per frame only when options.follow is set; otherwise sampled
// once at creation — crates never move, so only the CAMERA'S projection of
// this fixed world point needs updating every frame, not the point itself.
worldTop: Vector3;
idleTime: number;
hiding: boolean;
@@ -50,16 +63,21 @@ interface BarInstance {
damagePopupTimeout: ReturnType<typeof setTimeout> | null;
}
// World-tracking DOM health bars for breakable crates: a bordered
// rectangular bar (blue = current HP, black = already-lost HP, white = HP
// lost on the hit that just landed, catching down to black after a beat),
// plus a floating "-10" popup on every hit. Pure DOM+CSS (styling in
// World-tracking DOM health bars: a bordered rectangular bar (coloured fill =
// current HP, black = already-lost HP, a second contrasting fill = HP lost on
// the hit that just landed, catching down to black after a beat), plus a
// floating "-N" popup on every hit. Pure DOM+CSS (styling in
// src/css/health-bar.css) — only the per-frame world->screen repositioning
// and the hit-triggered width changes are JS.
//
// Keyed by an opaque `object` rather than a concrete type: crates
// (BreakableProp), zombies (Zombie) and the player all use the same bars, and
// this file has no reason to know any of those types — everything it needs
// arrives through HealthBarOptions.
export class HealthBarC {
private static initialized = false;
private static root: HTMLElement;
private static bars = new Map<BreakableProp, BarInstance>();
private static bars = new Map<object, BarInstance>();
static init() {
if (this.initialized) return;
@@ -70,39 +88,38 @@ export class HealthBarC {
}
// previousHp/currentHp are both already post-clamp (0..maxHp) — the
// caller (BreakablePropC.hit) applies the damage before calling this;
// this only drives the DISPLAY of what just happened.
static onHit(prop: BreakableProp, previousHp: number, currentHp: number, damage: number) {
const bar = this.getOrCreateBar(prop);
// caller applies the damage before calling this; this only drives the
// DISPLAY of what just happened.
static onHit(key: object, options: HealthBarOptions, previousHp: number, currentHp: number, damage: number) {
const bar = this.getOrCreateBar(key, options);
bar.idleTime = 0;
this.cancelHide(bar);
const previousFraction = (previousHp / prop.maxHp) * 100;
const currentFraction = (currentHp / prop.maxHp) * 100;
const previousFraction = (previousHp / options.maxHp) * 100;
const currentFraction = (currentHp / options.maxHp) * 100;
// The three-color effect is pure CSS transition timing, not a per-frame
// JS animation (see health-bar.css): white first SNAPS to the pre-hit
// value with no transition, then both white and blue are set to the
// post-hit value — white with a built-in transition-delay so it
// visibly lags behind blue for a moment (the "just lost, still white"
// beat) before catching down to become part of the black background
// underneath. Blue is layered on top of white so it never gets hidden
// by white's lag.
bar.whiteEl.style.transitionDuration = "0s";
bar.whiteEl.style.transitionDelay = "0s";
bar.whiteEl.style.width = `${previousFraction}%`;
void bar.whiteEl.offsetWidth; // force reflow: commit the instant snap above before re-enabling the transition below
// The three-colour effect is pure CSS transition timing, not a per-frame
// JS animation (see health-bar.css): the "just lost" layer first SNAPS to
// the pre-hit value with no transition, then both it and the fill are set
// to the post-hit value — the lost layer with a built-in transition-delay
// so it visibly lags behind (the "just lost, still bright" beat) before
// catching down to become part of the black background underneath. The
// fill is layered on top of it so it never gets hidden by that lag.
bar.lostEl.style.transitionDuration = "0s";
bar.lostEl.style.transitionDelay = "0s";
bar.lostEl.style.width = `${previousFraction}%`;
void bar.lostEl.offsetWidth; // force reflow: commit the instant snap above before re-enabling the transition below
bar.whiteEl.style.transitionDuration = "";
bar.whiteEl.style.transitionDelay = "";
bar.whiteEl.style.width = `${currentFraction}%`;
bar.blueEl.style.width = `${currentFraction}%`;
bar.lostEl.style.transitionDuration = "";
bar.lostEl.style.transitionDelay = "";
bar.lostEl.style.width = `${currentFraction}%`;
bar.fillEl.style.width = `${currentFraction}%`;
this.spawnDamagePopup(bar, damage);
}
static onDestroyed(prop: BreakableProp) {
const bar = this.bars.get(prop);
static onDestroyed(key: object) {
const bar = this.bars.get(key);
if (!bar) return;
this.removeBar(bar);
}
@@ -114,6 +131,7 @@ export class HealthBarC {
const canvasRect = ThreeC.renderer.domElement.getBoundingClientRect();
for (const bar of this.bars.values()) {
if (bar.options.follow) bar.worldTop.copy(bar.options.getWorldTop());
this.positionBar(bar, camera, canvasRect);
if (bar.hiding) continue;
@@ -128,12 +146,12 @@ export class HealthBarC {
bar.container.style.top = `${y}px`;
}
private static getOrCreateBar(prop: BreakableProp): BarInstance {
const existing = this.bars.get(prop);
private static getOrCreateBar(key: object, options: HealthBarOptions): BarInstance {
const existing = this.bars.get(key);
if (existing) return existing;
const container = document.createElement("div");
container.className = "health-bar";
container.className = `health-bar health-bar--${options.variant}`;
const border = document.createElement("div");
border.className = "health-bar__border";
@@ -141,33 +159,25 @@ export class HealthBarC {
const track = document.createElement("div");
track.className = "health-bar__track";
const white = document.createElement("div");
white.className = "health-bar__white";
const lost = document.createElement("div");
lost.className = "health-bar__lost";
const blue = document.createElement("div");
blue.className = "health-bar__blue";
const fill = document.createElement("div");
fill.className = "health-bar__fill";
track.appendChild(white);
track.appendChild(blue);
track.appendChild(lost);
track.appendChild(fill);
border.appendChild(track);
container.appendChild(border);
this.root.appendChild(container);
// Real geometry, not a guessed offset — the crate's own world bounding
// box top, centered on X/Z, plus a small fixed gap above it.
const box = new Box3().setFromObject(prop.root);
const worldTop = new Vector3((box.min.x + box.max.x) / 2, box.max.y + BAR_HEIGHT_OFFSET, (box.min.z + box.max.z) / 2);
const fraction = (prop.currentHp / prop.maxHp) * 100;
white.style.width = `${fraction}%`;
blue.style.width = `${fraction}%`;
const bar: BarInstance = {
prop,
key,
options,
container,
whiteEl: white,
blueEl: blue,
worldTop,
lostEl: lost,
fillEl: fill,
worldTop: options.getWorldTop().clone(),
idleTime: 0,
hiding: false,
removeTimeout: null,
@@ -175,13 +185,12 @@ export class HealthBarC {
damagePopupTimeout: null,
};
this.bars.set(prop, bar);
this.bars.set(key, bar);
// Positioned synchronously right away — otherwise the container sits at
// CSS's default position:fixed placement (top-left of the viewport,
// since .health-bar sets no left/top) until the next update() tick, and
// flashes there for a frame before jumping to its real spot above the
// crate.
// flashes there for a frame before jumping to its real spot.
this.positionBar(bar, CameraC_internal.getCamera(), ThreeC.renderer.domElement.getBoundingClientRect());
return bar;
@@ -206,8 +215,9 @@ export class HealthBarC {
private static removeBar(bar: BarInstance) {
if (bar.removeTimeout !== null) clearTimeout(bar.removeTimeout);
if (bar.damagePopupTimeout !== null) clearTimeout(bar.damagePopupTimeout);
bar.container.remove();
this.bars.delete(bar.prop);
this.bars.delete(bar.key);
}
private static spawnDamagePopup(bar: BarInstance, damage: number) {
+264 -48
View File
@@ -2,9 +2,9 @@ import { Box3, Mesh, MeshBasicMaterial, Object3D, PlaneGeometry, RingGeometry, V
import { CameraC_internal, TweenC, UpdateController } from "@hitplay/playable_template";
import { Easing, Tween } from "@tweenjs/tween.js";
import { ThreeC } from "./ThreeC";
import { RESOURCE_ICON_BASE_SCALE, ResourceC } from "./ResourceC";
import { PlayerC } from "./PlayerC";
import { PICKUP_SETTLED_SCALE } from "../utils/pickupFlight";
import { perspectiveCompensationScale } from "../utils/perspectiveScale";
import { projectWorldPointToScreen } from "../utils/screenWorldProjection";
import { createFlashOverlayMaterial } from "../utils/flashOverlayMaterial";
import { DebugConfigC } from "./DebugConfigC";
@@ -25,14 +25,37 @@ const DEPOSIT_FLIGHT_DURATION_MS = 280;
const DEPOSIT_FLIGHT_CLEANUP_MARGIN_MS = 200;
const PAY_ZONE_SHRINK_MS = 400;
const PAY_ZONE_GROW_MS = 450;
// Extra multiplier on top of RESOURCE_ICON_BASE_SCALE, local to this deposit
// Filling the zone raises its level and reopens the SAME zone in place — the
// gap between it shrinking away and reappearing is deliberate — a beat of
// empty ground makes it read as "that round's done" rather than an instant
// refill.
const REOPEN_DELAY_MS = PAY_ZONE_SHRINK_MS + 300;
// The reward pedestal in the middle of the zone: a static copy of whatever
// weapon this zone's currency levels up (see PlayerC.createPistolDisplay /
// createBatDisplay), floating and slowly turning, with a "+1 LVL" label
// under it — built from the character rig's own mesh, so no new art is
// needed.
const REWARD_ICON_SIZE = 0.75; // largest axis, world units
const REWARD_ICON_HEIGHT = 1.05; // above the zone's ground level
const REWARD_ICON_SPIN_SPEED = 1.1; // radians/second
const REWARD_ICON_BOB_SPEED = 2.2;
const REWARD_ICON_BOB_HEIGHT = 0.07;
// A slight lean so the spin shows the prop's profile rather than an edge-on
// silhouette all the way round.
const REWARD_ICON_TILT = 0.35;
// Where the DOM label is projected from, relative to the zone's ground.
const REWARD_LABEL_HEIGHT = 0.4;
// Extra multiplier on top of PICKUP_SETTLED_SCALE, local to this deposit
// flight only. This flight now starts at the player's own head, roughly the
// same camera distance as its destination (the camera rigidly follows the
// player), so perspectiveCompensationScale barely changes its value over
// the flight — unlike when this flight used to start at the HUD anchor
// right next to the camera, where that term grew a lot on its own. Retune
// this constant (not RESOURCE_ICON_BASE_SCALE) if only this flight's token
// this constant (not PICKUP_SETTLED_SCALE) if only this flight's token
// needs to look bigger/smaller.
const DEPOSIT_FLIGHT_SCALE_MULTIPLIER = 1.3;
@@ -66,39 +89,97 @@ const FILL_OPACITY = 0.55;
// Sits just above the zone's own ground-level quad to avoid z-fighting.
const FILL_Y_OFFSET = 0.02;
// Reuses the map's existing "UI_Interactive_Zone_02" marker as both the pay
// zone's location and its visual. ResourceC.getCollectedCount() counts
// resources the moment they're picked up, unrelated to this zone; this
// controller separately drains that total into the zone, one at a time,
// only while the player is standing inside it.
export interface PayZoneOptions {
// The zone's marker/visual node — undefined leaves this instance entirely
// inert (no fill mesh, no reward pedestal, no deposits), same fallback the
// old static version had for a missing "UI_Interactive_Zone_02".
node: Object3D | undefined;
target: Object3D;
// Where a deposit flight launches from — the player's own head, re-sampled
// per deposit rather than derived from `target` here, so this file doesn't
// need to know the player's height (see PlayerC.getHeadWorldPosition).
getDepositOrigin: () => Vector3;
// This zone's own resource's lifetime collected count (see
// ResourceC.getCollectedCount / ZombiePartC.getCollectedCount) — the
// backlog waiting to be deposited is this minus getDepositedCount().
getCollectedCount: () => number;
// A fresh visual for one flying deposit token — see
// ResourceC.createVisual / ZombiePartC.createVisual.
createDepositVisual: () => Object3D;
// The zone filled up — raise whichever level this currency buys (see
// BatLevelC.levelUp / PistolLevelC.levelUp).
onFilled: () => void;
// Builds the reward pedestal's floating prop at the given target size, or
// null if the source mesh isn't available (see
// PlayerC.createBatDisplay / createPistolDisplay).
buildRewardIcon: (targetSize: number) => Object3D | null;
}
// One deliverable zone: stand inside it and whatever this instance's resource
// backlog is (see options.getCollectedCount) drains in a steady trickle,
// filling a ground overlay. A full zone raises this instance's own level
// (options.onFilled), then shrinks away, pauses, and grows back **in the same
// spot** — there is no relocation and no randomness; TestSceneC owns where
// each instance's zone actually sits (see its own PAY_ZONE_SCALE/
// PAY_ZONE_GAP).
//
// Two independent instances exist (see TestSceneC): one draining wood into
// BatLevelC, one draining zombie-part chunks into PistolLevelC.
export class PayZoneC {
private static payZoneNode: Object3D | null = null;
private static target: Object3D | null = null;
private payZoneNode: Object3D | null;
private target: Object3D;
private getDepositOrigin: () => Vector3;
private getCollectedCount: () => number;
private createDepositVisual: () => Object3D;
private onFilled: () => void;
private buildRewardIcon: (targetSize: number) => Object3D | null;
// The zone's real world-space XZ footprint, computed from its actual
// geometry rather than a guessed radius.
private static zoneBounds: Box3 | null = null;
private static closed = false;
private static deposited = 0;
private static depositCooldown = 0;
private static fillMesh: Mesh | null = null;
private zoneBounds: Box3 | null = null;
private closed = false;
// Lifetime total of resources delivered into THIS zone — the matching HUD's
// live balance is `collected - deposited`, so this can never be reset
// without the counter jumping. Per-round progress is measured against
// zoneBaseline below instead.
private deposited = 0;
private zoneBaseline = 0;
private depositCooldown = 0;
private fillMesh: Mesh | null = null;
// The reward-pedestal prop in the middle of the zone, and its "+1 LVL" DOM
// label. Null when there was no source mesh to build it from.
private rewardIcon: Object3D | null = null;
private rewardLabel: HTMLElement | null = null;
private rewardElapsed = 0;
// The node's resting local scale, captured at init — what a reopened zone
// grows back up to.
private baseScale = new Vector3(1, 1, 1);
static init(payZoneNode: Object3D | undefined, target: Object3D) {
this.payZoneNode = payZoneNode ?? null;
this.target = target;
constructor(options: PayZoneOptions) {
this.payZoneNode = options.node ?? null;
this.target = options.target;
this.getDepositOrigin = options.getDepositOrigin;
this.getCollectedCount = options.getCollectedCount;
this.createDepositVisual = options.createDepositVisual;
this.onFilled = options.onFilled;
this.buildRewardIcon = options.buildRewardIcon;
}
init() {
this.zoneBounds = this.payZoneNode ? new Box3().setFromObject(this.payZoneNode) : null;
this.closed = false;
this.deposited = 0;
this.depositCooldown = 0;
this.fillMesh = this.zoneBounds ? this.buildFillMesh(this.zoneBounds) : null;
this.baseScale = this.payZoneNode ? this.payZoneNode.scale.clone() : new Vector3(1, 1, 1);
if (this.zoneBounds) this.buildFillMesh();
this.updateFillVisual();
this.buildRewardDisplay();
UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta));
}
// A unit quad, scaled/positioned per-frame in updateFillVisual — fixed
// width (full X span of the zone), depth (world Z) grows from 0 to the
// zone's full Z span as `deposited` approaches RESOURCES_TO_CLOSE_ZONE.
private static buildFillMesh(bounds: Box3): Mesh {
// zone's full Z span as this zone's own progress approaches
// RESOURCES_TO_CLOSE_ZONE.
private buildFillMesh() {
const mesh = new Mesh(
new PlaneGeometry(1, 1),
new MeshBasicMaterial({
@@ -109,20 +190,27 @@ export class PayZoneC {
})
);
mesh.rotation.x = -Math.PI / 2; // lie flat on the ground, facing up
mesh.position.y = bounds.min.y + FILL_Y_OFFSET;
mesh.position.x = (bounds.min.x + bounds.max.x) / 2;
mesh.scale.x = bounds.max.x - bounds.min.x;
ThreeC.addToScene(mesh);
return mesh;
this.fillMesh = mesh;
this.placeFillMesh();
}
// Pins the fill quad's fixed dimensions (width, ground height) to the zone's
// current footprint.
private placeFillMesh() {
if (!this.fillMesh || !this.zoneBounds) return;
this.fillMesh.position.y = this.zoneBounds.min.y + FILL_Y_OFFSET;
this.fillMesh.position.x = (this.zoneBounds.min.x + this.zoneBounds.max.x) / 2;
this.fillMesh.scale.x = this.zoneBounds.max.x - this.zoneBounds.min.x;
}
// Grows the fill quad from the zone's min-Z edge toward its max-Z edge —
// scaling a centered quad also moves its center, so the Z position is
// re-derived each time to keep that near edge pinned instead of drifting.
private static updateFillVisual() {
private updateFillVisual() {
if (!this.fillMesh || !this.zoneBounds) return;
const fraction = this.closed ? 0 : Math.min(this.deposited / RESOURCES_TO_CLOSE_ZONE, 1);
const fraction = this.closed ? 0 : Math.min(this.zoneProgress() / RESOURCES_TO_CLOSE_ZONE, 1);
const depth = this.zoneBounds.max.z - this.zoneBounds.min.z;
const filledDepth = depth * fraction;
@@ -130,16 +218,23 @@ export class PayZoneC {
this.fillMesh.position.z = this.zoneBounds.min.z + filledDepth / 2;
}
// HudC reads this (via an injected getter, not a direct import, to avoid
// a HudC <-> PayZoneC import cycle) to show a live balance that rises on
// collection and falls as resources leave for the Pay Zone.
static getDepositedCount(): number {
// The matching HUD reads this (via an injected getter, not a direct import)
// to show a live balance that rises on collection and falls as resources
// leave for this Pay Zone.
getDepositedCount(): number {
return this.deposited;
}
// How much has gone into the CURRENT round, as opposed to the lifetime
// total above — reset by reopening (see reopen), which advances the
// baseline instead of zeroing the total.
private zoneProgress(): number {
return this.deposited - this.zoneBaseline;
}
// XZ containment against the zone's real footprint (ignores Y — the
// zone lies flat on the ground).
static isPlayerInside(playerPosition: Vector3): boolean {
private isPlayerInside(playerPosition: Vector3): boolean {
if (!this.zoneBounds) return false;
return (
playerPosition.x >= this.zoneBounds.min.x &&
@@ -149,33 +244,70 @@ export class PayZoneC {
);
}
private static update(delta: number) {
private update(delta: number) {
// Runs even while the zone is closed (it hides the pedestal in that
// case), so it can't be left mid-spin over empty ground during the
// close/reopen beat.
this.updateRewardDisplay(delta);
if (this.closed || !this.payZoneNode || !this.target) return;
if (this.depositCooldown > 0) this.depositCooldown -= delta;
const backlog = ResourceC.getCollectedCount() - this.deposited;
const backlog = this.getCollectedCount() - this.deposited;
if (backlog <= 0) return;
if (this.depositCooldown > 0) return;
if (!this.isPlayerInside(this.target.position)) return;
this.depositCooldown = DEPOSIT_INTERVAL;
this.deposited++;
this.playDepositFlight(PlayerC.getHeadWorldPosition());
this.playDepositFlight(this.getDepositOrigin());
this.updateFillVisual();
if (this.deposited >= RESOURCES_TO_CLOSE_ZONE) {
this.closed = true;
this.playDisappear(this.payZoneNode);
}
if (this.zoneProgress() >= RESOURCES_TO_CLOSE_ZONE) this.close();
}
// The zone is full: pay out this instance's own level immediately (the
// "+1 LVL" on the pedestal has been promising it the whole time), shrink
// the zone away, and reopen it in the same spot.
private close() {
if (!this.payZoneNode) return;
this.closed = true;
this.onFilled();
this.playDisappear(this.payZoneNode);
// A plain timeout, not the shrink tween's onComplete: tween completion is
// not dependable enough in this project to hang it on (see CLAUDE.md).
// The shrink's own onComplete only hides things.
setTimeout(() => this.reopen(), REOPEN_DELAY_MS);
}
// Reopens the SAME zone in place — no relocation, no randomness. The
// lifetime `deposited` total is untouched — only the baseline this round
// measures its own progress against moves up, so the HUD's live balance
// can't jump (see zoneProgress).
private reopen() {
if (!this.payZoneNode) return;
this.zoneBaseline = this.deposited;
this.closed = false;
this.depositCooldown = 0;
this.placeFillMesh();
this.updateFillVisual();
if (this.fillMesh) this.fillMesh.visible = true;
this.payZoneNode.visible = true;
this.playAppear(this.payZoneNode);
}
// One resource, flying from the player's head into the zone's real
// geometric center — the player only needs to be standing in the zone to
// trigger it, they're also the flight's origin.
private static playDepositFlight(from: Vector3) {
private playDepositFlight(from: Vector3) {
if (!this.zoneBounds) return;
const mesh = ResourceC.createVisual();
const mesh = this.createDepositVisual();
// The zone's real geometric center, not the marker node's own pivot
// (not guaranteed to sit exactly in the middle of the footprint).
const to = new Vector3(
@@ -201,7 +333,7 @@ export class PayZoneC {
mesh.position.y += Math.sin(t * Math.PI) * DEPOSIT_FLIGHT_ARC_HEIGHT;
const perspectiveScale = perspectiveCompensationScale(cameraPosition, mesh.position, startDistance);
mesh.scale.setScalar(perspectiveScale * RESOURCE_ICON_BASE_SCALE * DEPOSIT_FLIGHT_SCALE_MULTIPLIER);
mesh.scale.setScalar(perspectiveScale * PICKUP_SETTLED_SCALE * DEPOSIT_FLIGHT_SCALE_MULTIPLIER);
})
.onComplete(() => {
ThreeC.removeFromScene(mesh);
@@ -216,7 +348,7 @@ export class PayZoneC {
setTimeout(() => ThreeC.removeFromScene(mesh), DEPOSIT_FLIGHT_DURATION_MS + DEPOSIT_FLIGHT_CLEANUP_MARGIN_MS);
}
private static playLandingRipple(position: Vector3) {
private playLandingRipple(position: Vector3) {
const mesh = new Mesh(rippleGeometry, createFlashOverlayMaterial());
mesh.rotation.x = -Math.PI / 2; // lie flat on the ground, facing up
mesh.position.copy(position);
@@ -239,9 +371,93 @@ export class PayZoneC {
ripple.start();
}
// The floating reward prop + "+1 LVL" label advertising what this zone pays
// out. Built once, never rebuilt (there's no relocation to rebuild for).
private buildRewardDisplay() {
if (!this.zoneBounds) return;
this.rewardIcon = this.buildRewardIcon(REWARD_ICON_SIZE);
if (this.rewardIcon) {
this.rewardIcon.rotation.z = REWARD_ICON_TILT;
ThreeC.addToScene(this.rewardIcon);
}
// Positioned every frame from a projected world point (see
// updateRewardDisplay), the same technique the health bars use — styling
// lives in ui.css (.pay-zone-reward).
const label = document.createElement("div");
label.className = "pay-zone-reward";
// Text in its own inline-block child (.gamefont-x, see main.css) so the
// global font-width squeeze doesn't touch this element's own positioning.
const labelText = document.createElement("span");
labelText.className = "gamefont-x";
labelText.textContent = "+1 LVL";
label.appendChild(labelText);
(document.getElementById("ui-safe-area") ?? document.body).appendChild(label);
this.rewardLabel = label;
}
// Spin + bob the prop over the zone's center and keep the label pinned
// under it. Both are hidden outright while the zone is closed, i.e. for the
// beat between it filling up and reopening.
private updateRewardDisplay(delta: number) {
if (!this.rewardIcon && !this.rewardLabel) return;
const visible = !this.closed && this.zoneBounds !== null;
if (this.rewardIcon) this.rewardIcon.visible = visible;
if (this.rewardLabel) this.rewardLabel.style.display = visible ? "" : "none";
if (!visible || !this.zoneBounds) return;
this.rewardElapsed += delta;
const center = this.zoneBounds.getCenter(new Vector3());
const groundY = this.zoneBounds.min.y;
if (this.rewardIcon) {
this.rewardIcon.position.set(
center.x,
groundY + REWARD_ICON_HEIGHT + Math.sin(this.rewardElapsed * REWARD_ICON_BOB_SPEED) * REWARD_ICON_BOB_HEIGHT,
center.z
);
this.rewardIcon.rotation.y += delta * REWARD_ICON_SPIN_SPEED;
}
if (this.rewardLabel) {
const canvasRect = ThreeC.renderer.domElement.getBoundingClientRect();
const worldPoint = new Vector3(center.x, groundY + REWARD_LABEL_HEIGHT, center.z);
const { x, y } = projectWorldPointToScreen(CameraC_internal.getCamera(), canvasRect, worldPoint);
this.rewardLabel.style.left = `${x}px`;
this.rewardLabel.style.top = `${y}px`;
}
}
// Grows the reopened zone back up to its resting scale — the mirror of
// playDisappear below. The fill quad is left out of it: it's empty at this
// point (zero depth), so there is nothing to scale.
private playAppear(node: Object3D) {
const base = this.baseScale;
node.scale.set(0, 0, 0);
const grow = new Tween({ s: 0 })
.to({ s: 1 }, PAY_ZONE_GROW_MS)
.easing(Easing.Back.Out)
.onUpdate(({ s }) => node.scale.set(base.x * s, base.y * s, base.z * s))
.onComplete(() => node.scale.copy(base));
TweenC.add(grow);
grow.start();
// Safety net, same reasoning as everywhere else in this file: a zone left
// frozen at a fraction of its size would be both wrong and hard to spot.
setTimeout(() => node.scale.copy(base), PAY_ZONE_GROW_MS + DEPOSIT_FLIGHT_CLEANUP_MARGIN_MS);
}
// Shrinks the zone's own marker AND the fill overlay together, as one
// piece, toward their own centers in lockstep.
private static playDisappear(node: Object3D) {
private playDisappear(node: Object3D) {
const fillMesh = this.fillMesh;
const fillBaseScaleX = fillMesh?.scale.x ?? 0;
const fillBaseScaleY = fillMesh?.scale.y ?? 0;
+98
View File
@@ -0,0 +1,98 @@
import { UpdateController } from "@hitplay/playable_template";
import { weaponHudBgSrc } from "../resources/images/images";
// Flat single-colour silhouette, generated as an SVG data URI at runtime
// rather than a shipped asset — no dedicated pistol icon exists yet (compare
// weapon_icon_bat.webp, the bat's own), and a plain silhouette reads cleanly
// at this size without one.
const PISTOL_ICON_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">' +
'<g fill="#e8e8e8">' +
'<rect x="14" y="25" width="38" height="9" rx="2"/>' + // slide/barrel
'<rect x="45" y="19" width="7" height="7" rx="1"/>' + // rear sight
'<rect x="16" y="17" width="7" height="7" rx="1"/>' + // front sight
'<path d="M20 34 L29 34 L29 52 L23 52 L18 45 Z"/>' + // grip
'<path d="M29 34 L35 34 L35 42 L31 47 L27 47 Z"/>' + // trigger guard
"</g>" +
"</svg>";
const pistolIconSrc = `data:image/svg+xml;utf8,${encodeURIComponent(PISTOL_ICON_SVG)}`;
// Bottom-left "pistol level" plate, stacked directly BELOW the bat's own
// octagon (see WeaponHudC) — the pistol's progression is its own currency
// (zombie-part chunks, see ZombiePartC / PistolLevelC), separate from the
// wood-fed bat level the plate above it shows.
//
// Reuses WeaponHudC's own octagon background art: it's a generic frame, not
// bat-specific, so the pair reads as two matched medallions rather than two
// unrelated widgets.
export class PistolHudC {
private static initialized = false;
private static plateEl: HTMLElement;
private static levelTextEl: HTMLElement;
private static lastDisplayedLevel = -1;
private static levelGetter: (() => number) | null = null;
// Injected rather than importing PistolLevelC directly, matching the rest
// of the HUD widgets in this project (WeaponHudC.setLevelGetter).
static setLevelGetter(getLevel: () => number) {
this.levelGetter = getLevel;
}
static init() {
if (this.initialized) return;
this.initialized = true;
this.buildDom();
UpdateController.Instance.onUpdate.addDelegate(() => this.update());
}
// Layout/styling lives in src/css/ui.css (.pistol-hud/__plate/__icon/__level)
// — same two-nested-divs split as WeaponHudC, and for the same reason: the
// outer element owns the one-shot mount animation, the inner plate owns the
// per-upgrade bump.
private static buildDom() {
const holder = document.createElement("div");
holder.className = "pistol-hud";
const plate = document.createElement("div");
plate.className = "pistol-hud__plate";
plate.style.backgroundImage = `url(${weaponHudBgSrc})`;
const icon = document.createElement("img");
icon.className = "pistol-hud__icon";
icon.src = pistolIconSrc;
const level = document.createElement("span");
level.className = "pistol-hud__level";
const levelText = document.createElement("span");
levelText.className = "gamefont-x";
levelText.textContent = "LVL 0";
level.appendChild(levelText);
plate.appendChild(icon);
plate.appendChild(level);
holder.appendChild(plate);
const uiRoot = document.getElementById("ui-safe-area") ?? document.body;
uiRoot.appendChild(holder);
this.plateEl = plate;
this.levelTextEl = levelText;
}
private static update() {
if (!this.levelGetter) return;
const level = this.levelGetter();
if (level === this.lastDisplayedLevel) return;
const isFirstPaint = this.lastDisplayedLevel === -1;
this.lastDisplayedLevel = level;
this.levelTextEl.textContent = `LVL ${level}`;
if (isFirstPaint) return;
this.plateEl.classList.remove("is-upgrading");
void this.plateEl.offsetWidth;
this.plateEl.classList.add("is-upgrading");
}
}
+33
View File
@@ -0,0 +1,33 @@
// Pistol level: a second, separate progression from the bat's own (see
// BatLevelC). Pistol levels come from filling the zombie-part Pay Zone
// (zombies drop the parts, see ZombiePartC / PayZoneC); bat levels come from
// filling the wood Pay Zone instead.
//
// Starts at 0, matching the bottom-left "LVL 0" placeholder convention shared
// with BatLevelC/WeaponHudC.
//
// Added to the pistol's base damage per shot (see PlayerC.fireAt) and drives
// the in-world upgrade glow on the drawn pistol (see WeaponAuraC).
const DAMAGE_PER_LEVEL = 10;
// State only, and deliberately imports nothing: PayZoneC raises it, PistolHudC
// displays it, WeaponAuraC reads it for its glow intensity, PlayerC reads its
// damage bonus, and any import back into those would cycle.
export class PistolLevelC {
private static level = 0;
static getLevel(): number {
return this.level;
}
static getBonusDamage(): number {
return this.level * DAMAGE_PER_LEVEL;
}
// One filled zombie-part Pay Zone = one pistol level. Uncapped, same
// reasoning as BatLevelC (WeaponAuraC caps the visual glow instead, see its
// own MAX_EFFECT_LEVEL).
static levelUp() {
this.level++;
}
}
+92
View File
@@ -0,0 +1,92 @@
import { Vector3 } from "three";
import { CameraC_internal, UpdateController } from "@hitplay/playable_template";
import { ThreeC } from "./ThreeC";
import { resourceCounterBgSrc } from "../resources/images/images";
import { projectScreenPointToWorldRay } from "../utils/screenWorldProjection";
// See HudC's identical constant — same reasoning, this is a second instance
// of the same anchor technique for a second counter.
const ANCHOR_DISTANCE = 2.5;
// Second resource counter, stacked directly below HudC's own wood counter
// (see .resource-hud--pistol in ui.css). The zombie-part chunks dropped by
// kills (see ZombiePartC, spent on the pistol's own progression — see
// PistolLevelC / its Pay Zone) get the exact same fly-to-a-HUD-counter
// treatment wood does, just their own plate and their own live balance.
//
// Reuses HudC's plate art rather than shipping a second bespoke asset — a
// CSS hue-rotate on the plate (see ui.css) is what reads it as a different
// currency.
export class PistolResourceHudC {
private static initialized = false;
private static plateEl: HTMLElement;
private static countTextEl: HTMLElement;
private static lastDisplayedCount = -1;
private static balanceGetter: (() => number) | null = null;
static init() {
if (this.initialized) return;
this.initialized = true;
this.buildDom();
UpdateController.Instance.onUpdate.addDelegate(() => this.update());
}
// Same technique as HudC.getWorldAnchorPosition — see its own comment.
static getWorldAnchorPosition(): Vector3 {
const canvasRect = ThreeC.renderer.domElement.getBoundingClientRect();
const plateRect = this.plateEl.getBoundingClientRect();
const screenX = plateRect.right;
const screenY = plateRect.top + plateRect.height / 2;
const camera = CameraC_internal.getCamera();
const { origin, direction } = projectScreenPointToWorldRay(camera, canvasRect, screenX, screenY);
return origin.addScaledVector(direction, ANCHOR_DISTANCE);
}
// Injected rather than importing ZombiePartC/PayZoneC directly, matching
// HudC.setBalanceGetter.
static setBalanceGetter(getBalance: () => number) {
this.balanceGetter = getBalance;
}
private static buildDom() {
const holder = document.createElement("div");
holder.className = "resource-hud resource-hud--pistol";
const plate = document.createElement("div");
plate.className = "resource-hud__plate";
plate.style.backgroundImage = `url(${resourceCounterBgSrc})`;
const count = document.createElement("span");
count.className = "resource-hud__count";
const countText = document.createElement("span");
countText.className = "gamefont-x";
countText.textContent = "0";
count.appendChild(countText);
plate.appendChild(count);
holder.appendChild(plate);
const uiRoot = document.getElementById("ui-safe-area") ?? document.body;
uiRoot.appendChild(holder);
this.plateEl = plate;
this.countTextEl = countText;
}
private static update() {
if (!this.balanceGetter) return;
const count = this.balanceGetter();
if (count === this.lastDisplayedCount) return;
this.lastDisplayedCount = count;
this.countTextEl.textContent = String(count);
this.plateEl.classList.remove("is-bumping");
void this.plateEl.offsetWidth;
this.plateEl.classList.add("is-bumping");
}
}
+511 -17
View File
@@ -3,7 +3,9 @@ import {
AnimationClip,
AnimationMixer,
Box3,
LoopOnce,
LoopRepeat,
Mesh,
Object3D,
Quaternion,
Vector2,
@@ -13,6 +15,12 @@ import { JoystickC, UpdateController } from "@hitplay/playable_template";
import { ThreeC } from "./ThreeC";
import { BreakableProp, BreakablePropC } from "./BreakablePropC";
import { WeaponTrailC } from "./WeaponTrailC";
import { Zombie, ZombieC } from "./ZombieC";
import { BulletC } from "./BulletC";
import { HealthBarC, HealthBarOptions } from "./HealthBarC";
import { GameOverC } from "./GameOverC";
import { BatLevelC } from "./BatLevelC";
import { PistolLevelC } from "./PistolLevelC";
const MOVE_SPEED = 4; // world units per second
const START_POSITION = { x: 1, y: 0, z: -8 }; // start of the road, the other end, feet on the ground
@@ -77,9 +85,64 @@ const INTERACTION_REACH = 0.6; // +50%
// Tune these against the actual clip if the impacts don't line up visually.
const HIT_TIME_FRACTIONS = [0.40,0.60];
// Radius around the player inside which an approaching zombie makes him pull
// the pistol and open fire. Smaller than ZombieC's MIN_SPAWN_DISTANCE so
// nothing can ever spawn already inside it — the approach is always visible
// before the draw triggers.
const SHOOT_RANGE = 8;
// Per the brief: 5 shots to kill a 100 HP zombie at pistol level 0. Every
// pistol level adds a flat bonus on top (see PistolLevelC.getBonusDamage), so
// this is the floor, not the whole number — see fireAt().
const PISTOL_DAMAGE = 20;
// Point in each firing cycle (fraction of the 0.76s PistolShoot clip, which
// is what sets the cadence) where the round actually leaves the barrel. The
// cadence is deliberately independent of whichever clip is currently playing:
// while walking, the animation is one of the PistolBack/Forward/Left/Right
// aim-walk cycles instead, and the fire rate must not change just because the
// player started moving.
const SHOT_TIME_FRACTION = 0.3;
// The character's max HP.
const PLAYER_BASE_MAX_HP = 100;
// How far above the character's head its own health bar floats. Well clear of
// the head rather than resting on it — this bar sits over the one thing on
// screen that's always in the middle of the action.
const HEALTH_BAR_HEIGHT_OFFSET = 0.9;
// Both weapon-swap clips run at this multiple of their authored speed. The
// draw is 1.12s and the put-away 1.2s as authored, which is a long time to
// stand there while a wave closes in. Applied as timeScale on the actions AND
// divided out of the durations the state machine waits on (see
// updateGunCombat) — change it in one place and both stay in step.
const WEAPON_SWAP_SPEED = 2;
// How far along the line of fire, from the hand attachment point, rounds are
// spawned. Has to clear the character's own silhouette: the Bullet rig trails
// ~1 unit of tapering circles BEHIND the round itself, so spawning right at
// the hand drapes that trail across the body and reads as the shot coming out
// of the character rather than the gun. The pistol is a skinned mesh with no
// usable node transform of its own (see CLAUDE.md), so a hand node plus this
// offset is the available handle.
const MUZZLE_FORWARD_OFFSET = 0.75;
const UP_AXIS = new Vector3(0, 1, 0);
const FORWARD_AXIS = new Vector3(0, 0, 1);
// stowed — pistol holstered, normal idle/walk/melee behaviour
// drawing — WeaponTakeOut playing, not yet firing
// shooting — firing on a fixed cadence; the clip underneath is PistolShoot
// when standing still and one of the PistolBack/Forward/Left/
// Right aim-walk cycles when moving
// holstering— WeaponPutDown playing after the last target left the zone
//
// Movement does NOT interrupt any of this: the whole point of the four
// directional aim clips is that the character keeps shooting while walking.
// Only losing the target ends a firing run.
type GunState = "stowed" | "drawing" | "shooting" | "holstering";
interface Obstacle {
node: Object3D;
box: Box3;
@@ -111,8 +174,32 @@ export class PlayerC {
private static idleAction: AnimationAction;
private static walkAction: AnimationAction;
private static attackAction: AnimationAction;
private static takeOutAction: AnimationAction;
private static shootAction: AnimationAction;
private static putDownAction: AnimationAction;
private static deathAction: AnimationAction;
// Aim-walk cycles, indexed by which way the character is moving relative to
// the direction it's aiming — see pickAimWalkAction().
private static aimWalkForwardAction: AnimationAction;
private static aimWalkBackAction: AnimationAction;
private static aimWalkLeftAction: AnimationAction;
private static aimWalkRightAction: AnimationAction;
private static currentAction: AnimationAction;
private static isAttacking = false;
private static currentHp = PLAYER_BASE_MAX_HP;
private static isDead = false;
// The bone rounds are spawned from — see MUZZLE_FORWARD_OFFSET.
private static gunHand: Object3D | null = null;
private static gunState: GunState = "stowed";
// Time spent in the current one-shot gun clip (drawing/holstering) — those
// two are driven by their own clip duration rather than an event.
private static gunElapsed = 0;
// Time since the current firing run started, and when the next shot lands.
private static shootElapsed = 0;
private static nextShotTime = 0;
private static shotsFired = 0;
private static attackElapsed = 0;
private static nextHitTime = 0;
// Counts up across the whole continuous attack (not reset per loop) —
@@ -144,22 +231,109 @@ export class PlayerC {
this.bindJoystick();
// No bar is created here on purpose — like the crates', it appears on the
// first hit taken (HealthBarC.onHit creates it lazily) and fades out again
// after HIDE_DELAY_S of not being hit. Nothing to show while at full HP.
UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta));
}
static isAlive(): boolean {
return !this.isDead;
}
// Called by ZombieC when one of them lands a hit (10 damage a second while
// it's on top of the player).
static takeDamage(amount: number) {
if (this.isDead) return;
const previousHp = this.currentHp;
this.currentHp = Math.max(0, this.currentHp - amount);
HealthBarC.onHit(this, this.healthBarOptions(), previousHp, this.currentHp, amount);
if (this.currentHp === 0) this.die();
}
private static die() {
this.isDead = true;
// Drop every in-flight bit of combat state at once: a queued melee swing,
// the gun state machine, the weapon trail, and any rounds still travelling
// (those would otherwise damage a zombie during the game-over screen).
this.isAttacking = false;
this.gunState = "stowed";
WeaponTrailC.setActive(false);
BulletC.clear();
// Stops the body from coasting on under the death animation.
this.moveInput.set(0, 0);
this.currentSpeed = 0;
this.playAction(this.deathAction, true);
GameOverC.show();
}
// `follow` — the anchor moves with the character, so it's re-sampled every
// frame rather than cached at creation the way a crate's is. Deliberately
// NOT `persistent`: the bar is only wanted while damage is actually coming
// in, so it auto-hides again like every other bar in the game.
private static healthBarOptions(): HealthBarOptions {
return {
variant: "player",
maxHp: PLAYER_BASE_MAX_HP,
follow: true,
getWorldTop: () =>
this.object.position.clone().add(new Vector3(0, PLAYER_HEIGHT + HEALTH_BAR_HEIGHT_OFFSET, 0)),
};
}
// Called when a breakable prop's collider is torn down (see
// BreakablePropC.destroy) so it stops blocking movement.
static removeObstacle(node: Object3D) {
this.obstacles = this.obstacles.filter((obstacle) => obstacle.node !== node);
}
// The other half of removeObstacle: a destroyed crate that has respawned (see
// BreakablePropC's respawn timer) blocks movement again.
//
// The box is sampled NOW and cached, exactly like init() does — so the caller
// must have already put the crate back at its resting transform before
// calling this, even if a rise-out-of-the-ground animation is still playing
// over it. That's deliberate: the crate is solid from the instant it's back,
// not once the animation finishes.
static addObstacle(node: Object3D) {
if (this.obstacles.some((obstacle) => obstacle.node === node)) return;
const box = new Box3().setFromObject(node);
if (box.max.y - box.min.y < MIN_OBSTACLE_HEIGHT) return;
this.obstacles.push({ node, box });
}
// Whether anything solid stands inside `bounds` — used by PayZoneC to keep a
// relocated Pay Zone off crates and walls. Reuses this list precisely because
// it's already filtered down to real obstacles (the map's thin ground slab
// excluded, see MIN_OBSTACLE_HEIGHT) and kept live as crates break and
// respawn.
static isAreaBlocked(bounds: Box3): boolean {
return this.obstacles.some((obstacle) => obstacle.box.intersectsBox(bounds));
}
private static createCharacter() {
const character = ThreeC.getObject("character");
// "Bullet" is a separate rig bundled in the same file for the (not yet
// implemented) shooting animations — hide it until it's actually used.
// "Bullet" is a separate rig bundled in the same glb — a round plus its
// trail and glow. Handed to BulletC as the template to clone per shot,
// which also detaches it from the character (otherwise the prop would
// just sit floating at the rig's origin).
const bullet = character.getObjectByName("Bullet");
if (bullet) bullet.visible = false;
if (bullet) BulletC.init(bullet);
// Where fired rounds spawn from. "Weapon_Hand" is the rig's own in-hand
// weapon attachment point (it's what the bat prop Tool_1 is parented to),
// so it sits exactly where a held weapon does — closer to the real muzzle
// than the wrist joint, which is a hand's width further back up the arm.
// The pistol itself is skinned and has no node transform to read.
this.gunHand = character.getObjectByName("Weapon_Hand") ?? character.getObjectByName("Wrist_R") ?? null;
ThreeC.setShadowsStateForChildren(character, true, false);
@@ -201,6 +375,58 @@ export class PlayerC {
return this.object.position.clone().add(new Vector3(0, PLAYER_HEIGHT, 0));
}
// A static, display-only copy of a weapon prop, for showing it outside the
// character's hands (see PayZoneC's reward pedestals — one per weapon's own
// Pay Zone). Not a clone() of the real thing: Character_Pistol is a
// SkinnedMesh (see CLAUDE.md), so a clone would stay bound to the
// character's skeleton and follow HIS hand around the map. This rebuilds it
// as a plain Mesh from the source's own geometry instead — safe for the bat
// props too, even though they aren't skinned themselves.
//
// Two fixes the raw geometry needs: skinned geometry is authored wherever the
// bind pose put it (nowhere near its own origin), hence center(); and its
// authored size has nothing to do with how big it should read as a floating
// prop, hence normalizing the largest axis to `targetSize`. The material is
// cloned because GLB materials are shared between nodes (CLAUDE.md) and this
// copy must not be able to affect the source mesh in the player's hands.
private static createWeaponDisplay(source: Object3D, targetSize: number): Object3D | null {
// The bat props may be a Group wrapping the actual mesh rather than a bare
// Mesh like the pistol — find whichever one it is.
const mesh = source instanceof Mesh ? source : source.getObjectByProperty("type", "Mesh");
if (!(mesh instanceof Mesh)) return null;
const geometry = mesh.geometry.clone();
geometry.center();
geometry.computeBoundingBox();
const material = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
const built = new Mesh(geometry, material.clone());
const size = geometry.boundingBox!.getSize(new Vector3());
const largestAxis = Math.max(size.x, size.y, size.z);
if (largestAxis > 1e-6) built.scale.setScalar(targetSize / largestAxis);
return built;
}
static createPistolDisplay(targetSize: number): Object3D | null {
return this.createWeaponDisplay(this.pistol, targetSize);
}
static createBatDisplay(targetSize: number): Object3D | null {
return this.createWeaponDisplay(this.batOnBack, targetSize);
}
// Where the weapon-upgrade glow sits (see WeaponAuraC), or null when it
// shouldn't show. Only while the pistol is actually drawn: holstered, it's
// skinned to the hip with no node transform of its own to track, so there is
// no honest position to put a glow at — and the upgrade is worth showing off
// when the gun is up anyway.
static getPistolAuraAnchor(): Vector3 | null {
if (this.gunState === "stowed" || !this.gunHand) return null;
return this.gunHand.getWorldPosition(new Vector3());
}
// Exactly one of {pistol, bat} is ever in hand, and the other is stowed —
// the pistol has no separate "in hand" prop, so its own single mesh just
// toggles at its holster spot instead.
@@ -224,10 +450,24 @@ export class PlayerC {
const idleClip = AnimationClip.findByName(clips, "Idle");
const walkClip = AnimationClip.findByName(clips, "SlowWalk");
const attackClip = AnimationClip.findByName(clips, "Loot");
const takeOutClip = AnimationClip.findByName(clips, "WeaponTakeOut");
const shootClip = AnimationClip.findByName(clips, "PistolShoot");
const putDownClip = AnimationClip.findByName(clips, "WeaponPutDown");
const deathClip = AnimationClip.findByName(clips, "Death");
const aimForwardClip = AnimationClip.findByName(clips, "PistolForward");
const aimBackClip = AnimationClip.findByName(clips, "PistolBack");
const aimLeftClip = AnimationClip.findByName(clips, "PistolLeft");
const aimRightClip = AnimationClip.findByName(clips, "PistolRight");
if (!idleClip || !walkClip || !attackClip) {
throw new Error("character mesh is missing the Idle/SlowWalk/Loot animation clips");
}
if (!takeOutClip || !shootClip || !putDownClip || !deathClip) {
throw new Error("character mesh is missing the WeaponTakeOut/PistolShoot/WeaponPutDown/Death animation clips");
}
if (!aimForwardClip || !aimBackClip || !aimLeftClip || !aimRightClip) {
throw new Error("character mesh is missing the PistolForward/Back/Left/Right animation clips");
}
this.idleAction = this.mixer.clipAction(idleClip);
this.walkAction = this.mixer.clipAction(walkClip);
@@ -235,20 +475,72 @@ export class PlayerC {
// Loops continuously while attacking — see updateCombat().
this.attackAction.setLoop(LoopRepeat, Infinity);
// Draw and put-away play exactly once and HOLD their last pose
// (clampWhenFinished) — without it the mixer snaps back to the clip's
// first frame the instant it ends, which would yank the pistol back to
// the holster for the frames between the draw finishing and the shooting
// loop's crossfade taking over.
this.takeOutAction = this.mixer.clipAction(takeOutClip);
this.takeOutAction.setLoop(LoopOnce, 1);
this.takeOutAction.clampWhenFinished = true;
this.takeOutAction.timeScale = WEAPON_SWAP_SPEED;
this.putDownAction = this.mixer.clipAction(putDownClip);
this.putDownAction.setLoop(LoopOnce, 1);
this.putDownAction.clampWhenFinished = true;
this.putDownAction.timeScale = WEAPON_SWAP_SPEED;
// Death holds its final pose too — the body must stay down under the
// game-over veil, not ping back to the clip's first frame.
this.deathAction = this.mixer.clipAction(deathClip);
this.deathAction.setLoop(LoopOnce, 1);
this.deathAction.clampWhenFinished = true;
// Loops for as long as a target stays in the zone — see updateGunCombat().
this.shootAction = this.mixer.clipAction(shootClip);
this.shootAction.setLoop(LoopRepeat, Infinity);
// The four aim-walk cycles: pistol up, walking in each direction relative
// to where the character is aiming. What makes shooting-while-moving
// possible at all — without them, moving would have to cancel the gun.
this.aimWalkForwardAction = this.mixer.clipAction(aimForwardClip);
this.aimWalkBackAction = this.mixer.clipAction(aimBackClip);
this.aimWalkLeftAction = this.mixer.clipAction(aimLeftClip);
this.aimWalkRightAction = this.mixer.clipAction(aimRightClip);
for (const action of [
this.aimWalkForwardAction,
this.aimWalkBackAction,
this.aimWalkLeftAction,
this.aimWalkRightAction,
]) {
action.setLoop(LoopRepeat, Infinity);
}
this.currentAction = this.idleAction;
this.currentAction.play();
}
private static playAction(action: AnimationAction) {
if (this.currentAction === action) return;
// `restart` re-triggers a clip that's already current — needed for the
// one-shot gun clips, which can legitimately be re-entered (draw ->
// holster -> draw) while still being the action last faded in. Without it
// the early-out below would leave the previous run's clamped final pose
// frozen on screen.
private static playAction(action: AnimationAction, restart = false) {
if (this.currentAction === action && !restart) return;
action.reset().fadeIn(ANIMATION_FADE).play();
this.currentAction.fadeOut(ANIMATION_FADE);
// Guarded: fading an action out against itself would cancel the fade-in
// just started above.
if (this.currentAction !== action) this.currentAction.fadeOut(ANIMATION_FADE);
this.currentAction = action;
}
private static bindJoystick() {
JoystickC.onJoysticMove.addDelegate(({ data }) => {
// Input is dropped outright once dead — update() already early-returns,
// but without this the stick would keep writing moveInput and the body
// would lurch back into motion the instant a restart re-enabled it.
if (this.isDead) return;
// nipplejs clamps the drag to a circle (not per-axis) whenever
// `follow: false` — see JoystickC.init in beforeResourcesLoadedCb —
// so `vector` is already length <= 1 and diagonals aren't faster.
@@ -261,14 +553,36 @@ export class PlayerC {
}
private static update(delta: number) {
// Everything below is frozen once dead — the Death clip is driven by the
// shared mixer (see ThreeC.addAnimMixer), not from here, so it still
// plays out over a body that no longer moves, aims or fires.
if (this.isDead) return;
// Zombies outrank crates: a live threat in the zone takes over both the
// facing lock and the combat slot, so the character can't be caught
// whacking a crate while one walks into him.
const zombieTarget = ZombieC.findNearestAlive(this.object.position, SHOOT_RANGE);
// Movement always runs, attacking or not — moving is what interrupts an
// attack (see updateCombat), so it can never be locked out.
const nearbyObstacle = this.findNearbyObstacle();
const stopped = this.updateMovement(delta, nearbyObstacle);
const stopped = this.updateMovement(delta, nearbyObstacle, zombieTarget);
this.updateCombat(delta, stopped);
// A swing already in progress is cut short the moment a zombie enters the
// zone. Without this the melee run would keep isAttacking latched (it
// only clears itself when the player moves or the crate dies), the draw
// below would stay blocked on it, and the character would happily keep
// whacking a crate while being surrounded.
if (zombieTarget && this.isAttacking) this.cancelMeleeAttack();
if (!this.isAttacking) {
this.updateGunCombat(delta, zombieTarget);
// Melee only gets a turn with the pistol fully stowed AND the zone clear
// — otherwise a crate in reach would fight the shooting loop for the same
// animation slot every frame.
if (this.gunState === "stowed" && !zombieTarget) this.updateCombat(delta, stopped);
if (!this.isAttacking && this.gunState === "stowed") {
if (this.currentSpeed > 0) {
// Match the walk cycle's playback speed to actual current speed, not
// just whether the stick is pushed — otherwise the animation would
@@ -288,9 +602,19 @@ export class PlayerC {
// it can't be the thing gated off during one). Returns whether the
// character is now fully stopped (no input AND speed decayed to zero),
// the bar updateCombat uses to allow an attack to start or continue.
private static updateMovement(delta: number, nearbyObstacle: NearbyObstacle | null): boolean {
private static updateMovement(
delta: number,
nearbyObstacle: NearbyObstacle | null,
zombieTarget: Zombie | null
): boolean {
const inputMagnitude = this.moveInput.length();
// While the pistol is out and something is being aimed at, facing belongs
// to the target, not the stick — that separation of "where I'm walking"
// from "where I'm pointing" is exactly what the four directional aim-walk
// clips exist to render.
const aiming = this.gunState !== "stowed" && zombieTarget !== null;
// Movement direction tracks the stick independently of facing below, so
// walking around/past an obstacle in range still works even though
// facing itself may be locked onto it once stopped.
@@ -305,8 +629,10 @@ export class PlayerC {
// the same mirror to keep "stick right" mean "screen right". Facing
// follows the raw stick immediately — it's intent, not momentum, so it
// shouldn't wait on the speed ramp below.
const angle = Math.atan2(-this.moveInput.x, this.moveInput.y);
this.facingRotation.setFromAxisAngle(UP_AXIS, angle);
if (!aiming) {
const angle = Math.atan2(-this.moveInput.x, this.moveInput.y);
this.facingRotation.setFromAxisAngle(UP_AXIS, angle);
}
}
// Ramp actual speed toward the stick's target instead of snapping to it:
@@ -329,8 +655,17 @@ export class PlayerC {
// Only take over facing once fully stopped (no input AND speed decayed
// to zero) — otherwise the face would snap onto the obstacle mid-stride
// or mid-coast while the body is still visibly heading somewhere else.
// A zombie being aimed at outranks a crate for the facing lock: the
// shooting stance has to actually point at what it's shooting. Re-read
// every frame (not latched at draw time) so the aim tracks a target
// that's still walking toward the player, and switches to the next one
// the moment the current one dies. Unlike the crate lock below, this one
// is NOT gated on being stopped — aiming while walking is the point.
const stopped = inputMagnitude === 0 && this.currentSpeed <= 0;
if (stopped && nearbyObstacle) {
if (aiming && zombieTarget) {
this.faceTowards(zombieTarget.root.position);
this.facingTarget = zombieTarget.root.position;
} else if (stopped && nearbyObstacle) {
this.faceTowards(nearbyObstacle.center);
this.facingTarget = nearbyObstacle.center;
} else {
@@ -380,15 +715,16 @@ export class PlayerC {
if (this.isAttacking) {
if (!stopped || zoneTargets.length === 0) {
this.isAttacking = false;
WeaponTrailC.setActive(false);
this.equipPistol();
this.cancelMeleeAttack();
return;
}
this.attackElapsed += delta;
if (this.attackElapsed >= this.nextHitTime) {
zoneTargets.forEach((target) => BreakablePropC.hit(target.prop, this.hitDirectionTo(target.center)));
const bonusDamage = BatLevelC.getBonusDamage();
zoneTargets.forEach((target) =>
BreakablePropC.hit(target.prop, this.hitDirectionTo(target.center), bonusDamage)
);
this.hitsThisAttack++;
this.nextHitTime = this.hitTimeFor(this.hitsThisAttack);
}
@@ -409,6 +745,164 @@ export class PlayerC {
this.playAction(this.attackAction);
}
// Drops out of the melee loop and stows the bat. Called both from the
// attack's own end conditions (moved / nothing left to hit) and from
// update() when a zombie takes priority over whatever crate was in reach.
// Leaves no animation of its own — the caller's next step picks one.
private static cancelMeleeAttack() {
this.isAttacking = false;
WeaponTrailC.setActive(false);
this.equipPistol();
}
// Pistol combat, run every frame ahead of the melee logic. `target` is the
// nearest living zombie inside SHOOT_RANGE (null if the zone is clear), and
// `stopped` is the same "fully at rest" bar melee uses — the character has
// to plant his feet to shoot, exactly like he does to swing.
//
// Note the asymmetry between the two ways firing can end: losing the target
// plays the put-away clip (a beat of "threat handled"), while the player
// moving drops the whole state instantly so walking never waits on an
// animation.
private static updateGunCombat(delta: number, target: Zombie | null) {
switch (this.gunState) {
case "stowed":
// isAttacking guards a mid-swing melee run: let it finish its own
// cancel path (see updateCombat) rather than yanking the bat out of
// the character's hands on the same frame.
if (target && !this.isAttacking) this.startDrawing();
return;
case "drawing":
if (!target) {
this.startHolstering();
return;
}
// Divided by WEAPON_SWAP_SPEED to match the action's own timeScale —
// the clip's authored duration is not how long it now takes to play.
this.gunElapsed += delta;
if (this.gunElapsed >= this.takeOutAction.getClip().duration / WEAPON_SWAP_SPEED) this.startShooting();
return;
case "shooting":
if (!target) {
this.startHolstering();
return;
}
this.shootElapsed += delta;
if (this.shootElapsed >= this.nextShotTime) {
this.fireAt(target);
this.shotsFired++;
this.nextShotTime = this.shotTimeFor(this.shotsFired);
}
this.updateAimAnimation();
return;
case "holstering":
// A fresh target arriving mid-put-away goes straight back to the
// draw instead of finishing the animation first — with a wave still
// coming, standing there completing a holster would be a free hit.
if (target) {
this.startDrawing();
return;
}
this.gunElapsed += delta;
if (this.gunElapsed >= this.putDownAction.getClip().duration / WEAPON_SWAP_SPEED) this.gunState = "stowed";
return;
}
}
// Spawns the round; the damage rides along and lands when it arrives (see
// BulletC), so a zombie visibly takes the hit as the bullet reaches it
// rather than the instant the trigger is pulled.
private static fireAt(target: Zombie) {
const to = ZombieC.getAimPoint(target);
// Damage is resolved HERE, at the muzzle, not inside the arrival callback:
// the level could tick up while the round is still in the air, and a shot
// should land the damage it was fired with.
const damage = PISTOL_DAMAGE + PistolLevelC.getBonusDamage();
BulletC.spawn(this.getMuzzleWorldPosition(to), to, () => ZombieC.damage(target, damage));
}
// The right wrist bone's live world position, nudged toward the target so
// rounds leave the gun rather than the forearm. Falls back to chest height
// on the character's own origin if the bone is ever missing — a slightly
// wrong muzzle is better than no bullets at all.
private static getMuzzleWorldPosition(target: Vector3): Vector3 {
if (!this.gunHand) return this.object.position.clone().add(new Vector3(0, PLAYER_HEIGHT * 0.75, 0));
const muzzle = this.gunHand.getWorldPosition(new Vector3());
const forward = target.clone().sub(muzzle);
if (forward.lengthSq() > 1e-6) muzzle.addScaledVector(forward.normalize(), MUZZLE_FORWARD_OFFSET);
return muzzle;
}
// Standing still -> the stationary PistolShoot stance. Moving -> whichever
// of the four aim-walk cycles matches the direction of travel RELATIVE TO
// where the character is aiming (it's facing the zombie, not its heading).
private static updateAimAnimation() {
if (this.currentSpeed <= 0) {
this.playAction(this.shootAction);
return;
}
this.playAction(this.pickAimWalkAction());
}
private static pickAimWalkAction(): AnimationAction {
// World-space heading, then rotated into the character's own frame by the
// inverse of its facing. The x negation matches the one in
// updateMovement's facing math — same camera-yaw mirror, and the two must
// agree or the chosen clip would be mirrored against actual travel.
const worldMove = new Vector3(-this.moveDirection.x, 0, this.moveDirection.y);
const localMove = worldMove.applyQuaternion(this.object.quaternion.clone().invert());
if (Math.abs(localMove.z) >= Math.abs(localMove.x)) {
return localMove.z > 0 ? this.aimWalkForwardAction : this.aimWalkBackAction;
}
// Facing +Z with +Y up makes the character's own right hand point at
// world -X (right = forward × up = Z × Y = -X), so a POSITIVE local x is
// movement to its left. If the strafe clips ever read mirrored on screen,
// this pair is the single thing to swap.
return localMove.x > 0 ? this.aimWalkLeftAction : this.aimWalkRightAction;
}
private static startDrawing() {
this.gunState = "drawing";
this.gunElapsed = 0;
// Bat away, pistol shown — the pistol mesh is skinned to the rig, so the
// draw clip carries it from the holster to the hand on its own.
this.equipPistol();
this.playAction(this.takeOutAction, true);
}
private static startShooting() {
this.gunState = "shooting";
this.shootElapsed = 0;
this.shotsFired = 0;
this.nextShotTime = this.shotTimeFor(0);
this.playAction(this.shootAction, true);
}
private static startHolstering() {
this.gunState = "holstering";
this.gunElapsed = 0;
this.playAction(this.putDownAction, true);
}
// Time (seconds into the firing run) the Nth shot lands — one per loop of
// the clip, same shape as hitTimeFor() below but with a single fraction
// instead of a cycle of them.
private static shotTimeFor(shotIndex: number): number {
const duration = this.shootAction.getClip().duration;
return duration * (shotIndex + SHOT_TIME_FRACTION);
}
// Time (seconds since the attack started) the Nth hit lands — cycles
// through HIT_TIME_FRACTIONS within each loop of the clip, advancing a
// full extra loop every time the cycle wraps.
+19 -261
View File
@@ -1,9 +1,5 @@
import { BoxGeometry, Mesh, MeshBasicMaterial, MeshStandardMaterial, Object3D, Vector3 } from "three";
import { CameraC_internal, UpdateController } from "@hitplay/playable_template";
import { ThreeC } from "./ThreeC";
import { SparkleFxC } from "./SparkleFxC";
import { perspectiveCompensationScale } from "../utils/perspectiveScale";
import { createFlashOverlayMaterial } from "../utils/flashOverlayMaterial";
import { BoxGeometry, Mesh, MeshStandardMaterial, Object3D, Vector3 } from "three";
import { PickupFlight } from "../utils/pickupFlight";
// Fallback only — TestSceneC normally hands in the map's real wood-icon
// quad via setPickupTemplate(), and every spawned resource clones that.
@@ -13,94 +9,22 @@ const PICKUP_SIZE = 0.28;
// bounce twice, then fly on to the HUD counter (see HudC) — no walk-over
// pickup trigger. Carrying the collected total into the Pay Zone is a
// separate mechanic (see PayZoneC, which drains getCollectedCount() itself).
const SCATTER_MIN_DISTANCE = 0.5;
const SCATTER_MAX_DISTANCE = 1.2;
const SCATTER_ARC_HEIGHT = 0.6;
const FLY_DURATION = 0.45; // seconds to reach the scattered spot
// Two bounces off the ground, decreasing height/duration, index 0 first.
const BOUNCE_HEIGHTS = [0.4, 0.22];
const BOUNCE_DURATIONS = [0.26, 0.2];
// On touchdown of the last bounce: shrink ramps in and HOLDS (does not ease
// back to full size), uniform (not squash/widen — that read as growing).
// Flash fades on its own, longer, decoupled timeline so tuning one doesn't
// drag the other.
const SHRINK_RAMP_DURATION = 0.22;
const FLASH_DURATION = 0.45;
// Must stay >= FLASH_DURATION or the flash gets cut off mid-fade.
const IMPACT_HOLD_DURATION = 0.55;
const SHRINK_AMOUNT = 0.15; // fraction shrunk, held once reached
// The scale a resource settles at after the bounce shrink, carried all the
// way to the HUD (toTarget's perspective compensation holds this constant
// in apparent size). Exported so PayZoneC's deposit-flight token — a
// separate, fresh mesh — starts from the same baseline instead of its own
// default (1), which read as noticeably larger.
export const RESOURCE_ICON_BASE_SCALE = 1 - SHRINK_AMOUNT;
const TO_TARGET_DURATION = 0.5;
const TO_TARGET_ARC_HEIGHT = 1;
// Fraction of the toTarget flight (0..1) where the arrival brighten (see
// update()) starts ramping in.
const CONVERGE_START_FRACTION = 0.7;
// Extra hold time added per-resource in a burst so they don't all launch
// toward the HUD in the same instant and read as one clump.
const STAGGER_INTERVAL = 0.12;
function randomInt(min: number, max: number): number {
return min + Math.floor(Math.random() * (max - min + 1));
}
// Collected up front, before anything mutates the tree, so adding overlay
// children mid-traversal can't make traverse() walk into overlays it just
// added.
function buildFlashOverlays(root: Object3D): Mesh[] {
const meshes: Mesh[] = [];
root.traverse((child) => {
if (child instanceof Mesh) meshes.push(child);
});
return meshes.map((mesh) => {
const overlay = mesh.clone();
// clone() copies the mesh's own local transform (relative to ITS
// parent) — parenting one level deeper under the mesh itself would
// reinterpret those numbers in the mesh's local space and displace the
// overlay instead of exactly overlaying it. Reset to identity instead.
overlay.position.set(0, 0, 0);
overlay.rotation.set(0, 0, 0);
overlay.scale.set(1, 1, 1);
overlay.material = createFlashOverlayMaterial();
overlay.castShadow = false;
overlay.receiveShadow = false;
mesh.add(overlay);
return overlay;
});
}
type Phase = "scatter" | "bounce" | "impactHold" | "toTarget";
interface FlyingResource {
mesh: Object3D;
flashOverlays: Mesh[];
phase: Phase;
age: number;
from: Vector3;
to: Vector3;
groundY: number; // the y level the two bounces oscillate around
bounceIndex: number; // which of BOUNCE_HEIGHTS/BOUNCE_DURATIONS is playing
staggerDelay: number; // extra hold time before launching to the HUD — see STAGGER_INTERVAL
baseScale: number; // scale.x when toTarget starts — see the perspective compensation there
startDistanceFromCamera: number; // camera distance when toTarget starts — see toTarget phase
}
//
// The whole scatter/bounce/fly animation lives in utils/pickupFlight.ts and is
// shared with the zombie-part chunks (see ZombiePartC) — this file only owns
// the wood visual, the collected count, and where they fly to.
export class ResourceC {
private static flying: FlyingResource[] = [];
private static collected = 0;
private static initialized = false;
private static pickupTemplate: Object3D | null = null;
private static flyTarget: (() => Vector3) | null = null;
private static collected = 0;
private static flight = new PickupFlight({
createMesh: () => this.createPickupMesh(),
getTarget: () => this.flyTarget?.() ?? null,
onCollected: () => {
this.collected++;
},
});
static getCollectedCount() {
return this.collected;
@@ -117,45 +41,18 @@ export class ResourceC {
}
// A standalone clone of the pickup visual for callers (PayZoneC) that fly
// a "resource" token outside the normal spawn/scatter/collect lifecycle
// below — no flash overlays, those are only built for the real ones.
// a "resource" token outside the normal spawn/scatter/collect lifecycle —
// no flash overlays, those are only built for the real ones.
static createVisual(): Object3D {
return this.createPickupMesh();
}
static spawnBurst(origin: Vector3, count: number) {
this.ensureUpdating();
for (let i = 0; i < count; i++) this.spawnOne(origin, i * STAGGER_INTERVAL);
this.flight.spawnBurst(origin, count);
}
static spawnBurstInRange(origin: Vector3, min: number, max: number) {
this.spawnBurst(origin, randomInt(min, max));
}
private static spawnOne(origin: Vector3, staggerDelay: number) {
const angle = Math.random() * Math.PI * 2;
const distance = SCATTER_MIN_DISTANCE + Math.random() * (SCATTER_MAX_DISTANCE - SCATTER_MIN_DISTANCE);
const target = origin
.clone()
.add(new Vector3(Math.cos(angle) * distance, 0, Math.sin(angle) * distance));
const mesh = this.createPickupMesh();
mesh.position.copy(origin);
ThreeC.addToScene(mesh);
this.flying.push({
mesh,
flashOverlays: buildFlashOverlays(mesh),
phase: "scatter",
age: 0,
from: origin.clone(),
to: target,
groundY: 0,
bounceIndex: 0,
staggerDelay,
baseScale: 1,
startDistanceFromCamera: 1,
});
this.flight.spawnBurstInRange(origin, min, max);
}
private static createPickupMesh(): Object3D {
@@ -174,143 +71,4 @@ export class ResourceC {
mesh.castShadow = true;
return mesh;
}
private static ensureUpdating() {
if (this.initialized) return;
this.initialized = true;
UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta));
}
private static update(delta: number) {
for (let i = this.flying.length - 1; i >= 0; i--) {
const resource = this.flying[i];
resource.age += delta;
if (resource.phase === "scatter") {
if (resource.age <= FLY_DURATION) {
const t = resource.age / FLY_DURATION;
const eased = 1 - (1 - t) * (1 - t); // easeOutQuad outward
resource.mesh.position.lerpVectors(resource.from, resource.to, eased);
// Up-then-down arc on top of the outward lerp, so it "pops"
// rather than sliding flat along the ground.
resource.mesh.position.y = resource.from.y + Math.sin(t * Math.PI) * SCATTER_ARC_HEIGHT;
continue;
}
resource.phase = "bounce";
resource.age = 0;
resource.bounceIndex = 0;
resource.groundY = resource.to.y;
continue;
}
if (resource.phase === "bounce") {
const duration = BOUNCE_DURATIONS[resource.bounceIndex];
const height = BOUNCE_HEIGHTS[resource.bounceIndex];
if (resource.age <= duration) {
const t = resource.age / duration;
resource.mesh.position.y = resource.groundY + Math.sin(t * Math.PI) * height;
continue;
}
resource.mesh.position.y = resource.groundY;
if (resource.bounceIndex < BOUNCE_HEIGHTS.length - 1) {
resource.bounceIndex++;
resource.age = 0;
continue;
}
// Last bounce just touched down — flash/sparkle, once.
SparkleFxC.spawnBurst(resource.mesh.getWorldPosition(new Vector3()));
resource.phase = "impactHold";
resource.age = 0;
continue;
}
if (resource.phase === "impactHold") {
const shrinkT = Math.min(resource.age / SHRINK_RAMP_DURATION, 1);
const shrink = shrinkT * shrinkT * (3 - 2 * shrinkT) * SHRINK_AMOUNT; // ease in, then hold
resource.mesh.scale.setScalar(1 - shrink);
const flashT = Math.min(resource.age / FLASH_DURATION, 1);
const flashOpacity = 1 - flashT;
for (const overlay of resource.flashOverlays) {
(overlay.material as MeshBasicMaterial).opacity = flashOpacity;
}
// staggerDelay only extends this wait, per resource — the
// shrink/flash above are already fully settled by
// IMPACT_HOLD_DURATION, so this only delays the launch.
if (resource.age < IMPACT_HOLD_DURATION + resource.staggerDelay) continue;
for (const overlay of resource.flashOverlays) {
(overlay.material as MeshBasicMaterial).opacity = 0;
}
if (!this.flyTarget) {
this.collect(resource);
this.flying.splice(i, 1);
continue;
}
resource.phase = "toTarget";
resource.age = 0;
resource.from = resource.mesh.position.clone();
resource.baseScale = resource.mesh.scale.x;
resource.startDistanceFromCamera = CameraC_internal.getCamera().position.distanceTo(resource.from);
continue;
}
// phase === "toTarget"
if (!this.flyTarget) {
this.collect(resource);
this.flying.splice(i, 1);
continue;
}
const t = Math.min(resource.age / TO_TARGET_DURATION, 1);
const eased = t * t * (3 - 2 * t); // smoothstep — eases in and out
// Re-sampled every frame, not snapshotted at launch: the HUD anchor
// can keep moving during the flight (camera follow), so a stale
// one-time snapshot would miss the icon. An accelerating (ease-in)
// curve was tried here and reverted — it desyncs spatial progress
// from the raw elapsed time CONVERGE_START_FRACTION uses below,
// making the resource vanish well short of the target.
resource.to = this.flyTarget();
resource.mesh.position.lerpVectors(resource.from, resource.to, eased);
resource.mesh.position.y += Math.sin(t * Math.PI) * TO_TARGET_ARC_HEIGHT * (1 - t);
// The target sits much closer to the camera than where resources
// spawn, so without compensation the resource would visibly grow as
// it approaches (perspective foreshortening, not a real scale
// change) — see perspectiveCompensationScale.
const cameraPosition = CameraC_internal.getCamera().position;
const perspectiveScale = perspectiveCompensationScale(
cameraPosition,
resource.mesh.position,
resource.startDistanceFromCamera
);
resource.mesh.scale.setScalar(resource.baseScale * perspectiveScale);
// Brightens over the last stretch, reading as absorbed into the
// counter icon rather than popping out mid-air. Reuses the same
// flashOverlays the bounce-impact flash used, idle since then.
const convergeT = Math.max(0, (t - CONVERGE_START_FRACTION) / (1 - CONVERGE_START_FRACTION));
for (const overlay of resource.flashOverlays) {
(overlay.material as MeshBasicMaterial).opacity = convergeT;
}
if (t >= 1) {
this.collect(resource);
this.flying.splice(i, 1);
}
}
}
private static collect(resource: FlyingResource) {
this.collected++;
ThreeC.removeFromScene(resource.mesh);
}
}
+154 -29
View File
@@ -1,19 +1,26 @@
import { Object3D, Vector3 } from "three";
import { Box3, Object3D, Vector3 } from "three";
import { ThreeC } from "./ThreeC";
import { PlayerC } from "./PlayerC";
import { CameraFollowC } from "./CameraFollowC";
import { PhysicsBody, PhysicsLayer } from "./PhysicsC";
import { BreakablePropC } from "./BreakablePropC";
import { ResourceC } from "./ResourceC";
import { ZombiePartC } from "./ZombiePartC";
import { PayZoneC } from "./PayZoneC";
import { HudC } from "./HudC";
import { PistolResourceHudC } from "./PistolResourceHudC";
import { WeaponHudC } from "./WeaponHudC";
import { BatLevelC } from "./BatLevelC";
import { PistolHudC } from "./PistolHudC";
import { PistolLevelC } from "./PistolLevelC";
import { WeaponAuraC } from "./WeaponAuraC";
import { ZombieInvasionHudC } from "./ZombieInvasionHudC";
import { PropVfxC } from "./PropVfxC";
import { WeaponTrailC } from "./WeaponTrailC";
import { SparkleFxC } from "./SparkleFxC";
import { HealthBarC } from "./HealthBarC";
import { DebugConfigC } from "./DebugConfigC";
import { ZombieC } from "./ZombieC";
// Above and behind the player, along the road's forward (+Z) direction —
// player now spawns at the other end of the road, walking toward +Z, so
@@ -26,6 +33,14 @@ import { DebugConfigC } from "./DebugConfigC";
// black screen.
export const CAMERA_OFFSET = new Vector3(0, 8, -6);
// The map ships one interactive-zone marker ("UI_Interactive_Zone_02"),
// halved in footprint and used as-is for the pistol's own Pay Zone; the bat's
// is a clone of it, placed next to it (see createMap) — two fixed zones, no
// relocation, replacing the old single randomly-relocating one.
const PAY_ZONE_SCALE = 0.5;
// World units of clear ground between the two zones' footprints.
const PAY_ZONE_GAP = 1.5;
export class TestSceneC {
static init() {
DebugConfigC.init();
@@ -33,7 +48,7 @@ export class TestSceneC {
SparkleFxC.init();
HealthBarC.init();
const { colliders, payZoneNode } = this.createMap();
const { colliders, batZoneNode, pistolZoneNode, mapBounds } = this.createMap();
PlayerC.init(colliders);
CameraFollowC.init(PlayerC.object, CAMERA_OFFSET);
@@ -48,31 +63,85 @@ export class TestSceneC {
// right after its own init (see CameraFollowC — it never rotates again).
WeaponTrailC.init(PlayerC.getWeaponAnchor());
// Reuses the map's own "UI_Interactive_Zone_02" marker as the Pay
// Zone's position/visual (see PayZoneC) — drains ResourceC's collected
// total into the zone while the player stands in it. Must come after
// PlayerC.init(): PlayerC.object doesn't exist yet inside createMap().
PayZoneC.init(payZoneNode, PlayerC.object);
// Two fixed Pay Zones, side by side (see createMap) — no relocation, no
// randomness. Each drains a different resource into a different level:
// wood into the bat's, zombie-part chunks into the pistol's. Must come
// after PlayerC.init(): PlayerC.object doesn't exist yet inside
// createMap().
const batPayZone = new PayZoneC({
node: batZoneNode,
target: PlayerC.object,
getDepositOrigin: () => PlayerC.getHeadWorldPosition(),
getCollectedCount: () => ResourceC.getCollectedCount(),
createDepositVisual: () => ResourceC.createVisual(),
onFilled: () => BatLevelC.levelUp(),
buildRewardIcon: (size) => PlayerC.createBatDisplay(size),
});
batPayZone.init();
// HUD counter (top-right) — collected resources fly to it instead of
// just vanishing, and it's also where PayZoneC's deposit-into-the-zone
// flight originates from. The displayed number is collected minus
// delivered — rises on collection, falls as PayZoneC drains it into
// the zone.
const pistolPayZone = new PayZoneC({
node: pistolZoneNode,
target: PlayerC.object,
getDepositOrigin: () => PlayerC.getHeadWorldPosition(),
getCollectedCount: () => ZombiePartC.getCollectedCount(),
createDepositVisual: () => ZombiePartC.createVisual(),
onFilled: () => PistolLevelC.levelUp(),
buildRewardIcon: (size) => PlayerC.createPistolDisplay(size),
});
pistolPayZone.init();
// Wood HUD counter (top-right) — collected resources fly to it instead of
// just vanishing, and it's also where the bat Pay Zone's deposit flight
// originates from. The displayed number is collected minus delivered —
// rises on collection, falls as the zone drains it.
HudC.init();
ResourceC.setFlyTarget(() => HudC.getWorldAnchorPosition());
HudC.setBalanceGetter(() => ResourceC.getCollectedCount() - PayZoneC.getDepositedCount());
HudC.setBalanceGetter(() => ResourceC.getCollectedCount() - batPayZone.getDepositedCount());
// Bottom-left weapon/level plate — static visual, independent of
// everything above (see WeaponHudC).
// Second HUD counter, stacked below the wood one — same treatment for the
// zombie-part chunks that feed the pistol zone instead.
PistolResourceHudC.init();
ZombiePartC.setFlyTarget(() => PistolResourceHudC.getWorldAnchorPosition());
PistolResourceHudC.setBalanceGetter(() => ZombiePartC.getCollectedCount() - pistolPayZone.getDepositedCount());
// Bottom-left octagon plate — the live BAT level, raised by the wood Pay
// Zone (see BatLevelC).
WeaponHudC.init();
WeaponHudC.setLevelGetter(() => BatLevelC.getLevel());
// Top-center "ZOMBIE INVASION" timer bar — also fully independent,
// starts its own timer slide immediately on init (see
// ZombieInvasionHudC). Duration comes from DebugConfigC (itself seeded
// from the platform's own configUIParams, see gameplay.ts) rather than
// ZombieInvasionHudC's own hardcoded default, so a platform-configured
// value actually takes effect in a real build.
// Stacked directly below it — the live PISTOL level, raised by the
// zombie-part Pay Zone instead (see PistolLevelC), plus the matching
// in-world glow on the drawn pistol. The glow's anchor is a getter
// because PlayerC's holstered pistol has no position of its own to track
// — see getPistolAuraAnchor.
PistolHudC.init();
PistolHudC.setLevelGetter(() => PistolLevelC.getLevel());
WeaponAuraC.init(() => PlayerC.getPistolAuraAnchor());
// Placeholder enemies (primitive figures, no real zombie models exist
// yet). Dormant until the invasion timer fills, and then endless — the
// wave that timer releases keeps being reinforced for the rest of the run
// (see ZombieC's REINFORCEMENT_INTERVAL_S / MAX_ALIVE).
// Gets the player's position through a getter rather than importing
// PlayerC, because PlayerC imports ZombieC to find and shoot targets and
// the pair would otherwise cycle.
ZombieC.init(
mapBounds,
() => PlayerC.object.position,
(amount) => PlayerC.takeDamage(amount),
() => PlayerC.isAlive()
);
// Top-center "ZOMBIE INVASION" timer bar — starts its own timer slide
// immediately on init (see ZombieInvasionHudC). Duration comes from
// DebugConfigC (itself seeded from the platform's own configUIParams, see
// gameplay.ts) rather than ZombieInvasionHudC's own hardcoded default, so
// a platform-configured value actually takes effect in a real build.
//
// setOnComplete MUST precede init(): init() starts the countdown on the
// spot, and the bar owns no knowledge of zombies itself — the bar filling
// is simply what releases the (from then on endless) invasion.
ZombieInvasionHudC.setOnComplete(() => ZombieC.startWave());
ZombieInvasionHudC.init(DebugConfigC.getZombieInvasionTimerS());
}
@@ -117,13 +186,25 @@ export class TestSceneC {
if (installMockup) installMockup.visible = false;
// "Lootable" crates own their collider bodies themselves (so they can be
// torn down again when a crate breaks) — everything else just gets a
// plain static wall body.
const propColliders = BreakablePropC.init(map.getObjectByName("Lootable"), (colliderNode) => {
const index = colliders.indexOf(colliderNode);
if (index !== -1) colliders.splice(index, 1);
PlayerC.removeObstacle(colliderNode);
});
// torn down again when a crate breaks, and rebuilt when it respawns) —
// everything else just gets a plain static wall body.
//
// The two callbacks are mirrors of each other: a broken crate stops blocking
// movement, and the same crate blocks again when its respawn timer fires
// (see BreakablePropC's RESPAWN_DELAY_MS). The collider NODES themselves are
// reused across that cycle, never rebuilt.
const propColliders = BreakablePropC.init(
map.getObjectByName("Lootable"),
(colliderNode) => {
const index = colliders.indexOf(colliderNode);
if (index !== -1) colliders.splice(index, 1);
PlayerC.removeObstacle(colliderNode);
},
(colliderNode) => {
if (!colliders.includes(colliderNode)) colliders.push(colliderNode);
PlayerC.addObstacle(colliderNode);
}
);
for (const collider of colliders) {
if (propColliders.has(collider)) continue;
@@ -138,6 +219,50 @@ export class TestSceneC {
ThreeC.addToScene(map);
return { colliders, payZoneNode: map.getObjectByName("UI_Interactive_Zone_02") };
// Real world extents of the loaded map, measured — not a guessed radius
// (see CLAUDE.md: footprints come from Box3.setFromObject, never from
// eyeballing a screenshot). ZombieC clamps its spawn ring into this so
// nobody walks in from beyond the ground mesh.
const mapBounds = new Box3().setFromObject(map);
const { batZoneNode, pistolZoneNode } = this.createPayZoneNodes(map);
return { colliders, batZoneNode, pistolZoneNode, mapBounds };
}
// The pistol zone keeps the map's own authored marker (shrunk in place);
// the bat zone is a plain clone of it, offset alongside — no new art, and
// no random placement (see PayZoneC — both zones now stay put for the
// whole run).
private static createPayZoneNodes(map: Object3D): {
batZoneNode: Object3D | undefined;
pistolZoneNode: Object3D | undefined;
} {
const pistolZoneNode = map.getObjectByName("UI_Interactive_Zone_02");
if (!pistolZoneNode) return { batZoneNode: undefined, pistolZoneNode: undefined };
pistolZoneNode.scale.multiplyScalar(PAY_ZONE_SCALE);
// Box3.setFromObject below reads matrixWorld directly — the scale change
// above has to reach it first (see BreakablePropC.respawn for the same
// requirement after a transform change).
pistolZoneNode.updateWorldMatrix(true, true);
const batZoneNode = pistolZoneNode.clone();
pistolZoneNode.parent?.add(batZoneNode);
// Offset purely along world X by the (now-halved) zone's own width plus a
// gap — PayZoneC's fill quad and footprint checks are already world-axis-
// aligned (see its own notes), so this doesn't need to account for any
// local rotation on the marker itself.
const size = new Box3().setFromObject(pistolZoneNode).getSize(new Vector3());
const targetWorld = pistolZoneNode
.getWorldPosition(new Vector3())
.add(new Vector3(size.x + PAY_ZONE_GAP, 0, 0));
const parent = batZoneNode.parent;
batZoneNode.position.copy(parent ? parent.worldToLocal(targetWorld) : targetWorld);
batZoneNode.updateWorldMatrix(true, true);
return { batZoneNode, pistolZoneNode };
}
}
+125
View File
@@ -0,0 +1,125 @@
import { AdditiveBlending, CanvasTexture, Sprite, SpriteMaterial, Vector3 } from "three";
import { UpdateController } from "@hitplay/playable_template";
import { ThreeC } from "./ThreeC";
import { PistolLevelC } from "./PistolLevelC";
// A soft additive glow riding on the drawn pistol, one visible sign that the
// pistol has been upgraded (see PistolLevelC / its Pay Zone). Level 0 shows
// nothing at all; every level after that is a bit bigger and a bit brighter,
// flattening out at MAX_EFFECT_LEVEL so a long run can't end in a white blob.
//
// A Sprite, not a mesh overlay or a light:
// * Character_Pistol is a SkinnedMesh, so a same-geometry overlay clone would
// have to be re-bound to the character's skeleton to deform with it — a
// Sprite sidesteps skinning entirely.
// * A PointLight would do nothing if the character's materials turn out to be
// unlit (the crates' are — see CLAUDE.md), and costs a shader recompile.
// SpriteMaterial is always camera-facing and ignores scene lighting, so this
// looks the same regardless of what the rig's own materials are.
const BASE_SIZE = 0.34; // world units at level 1
const SIZE_PER_LEVEL = 0.07;
const BASE_OPACITY = 0.16; // at level 1 — deliberately faint
const OPACITY_PER_LEVEL = 0.08;
// Where both ramps stop. Past this the pistol keeps levelling (it's uncapped,
// see PistolLevelC) but the glow doesn't grow any further.
const MAX_EFFECT_LEVEL = 6;
// Very slow, very shallow breathe on top of the level-driven brightness — just
// enough to read as energy rather than a decal stuck to the gun. Scaled by
// level too, so a level-1 glow is nearly steady.
const PULSE_SPEED = 2.6;
const PULSE_DEPTH = 0.18; // fraction of the current opacity
// Warm gold, matching "upgraded/valuable" rather than the green used for
// resources and the exp orbs.
const GLOW_COLOR = 0xffcc66;
// Radial gradient, opaque at the middle and fully transparent at the rim,
// painted once into a small canvas. Small on purpose: it's a blurry blob, so
// texture resolution is invisible here and 64px keeps the base64 export cost at
// nothing (it's generated at runtime, not shipped as an asset).
const TEXTURE_SIZE = 64;
function createGlowTexture(): CanvasTexture {
const canvas = document.createElement("canvas");
canvas.width = TEXTURE_SIZE;
canvas.height = TEXTURE_SIZE;
const context = canvas.getContext("2d")!;
const half = TEXTURE_SIZE / 2;
const gradient = context.createRadialGradient(half, half, 0, half, half, half);
// Three stops, not two: a plain linear falloff reads as a hard-edged disc.
// The middle stop is what gives it a soft core with a long faint halo.
gradient.addColorStop(0, "rgba(255, 255, 255, 1)");
gradient.addColorStop(0.35, "rgba(255, 255, 255, 0.45)");
gradient.addColorStop(1, "rgba(255, 255, 255, 0)");
context.fillStyle = gradient;
context.fillRect(0, 0, TEXTURE_SIZE, TEXTURE_SIZE);
return new CanvasTexture(canvas);
}
export class WeaponAuraC {
private static initialized = false;
private static sprite: Sprite;
private static material: SpriteMaterial;
private static getAnchor: () => Vector3 | null = () => null;
private static elapsed = 0;
// `getAnchor` returns where the glow should sit right now, or null when it
// shouldn't show at all — PlayerC returns the gun-hand bone's world position
// while the pistol is actually drawn, and null while it's holstered (the
// holstered pistol is skinned to the hip and has no node position of its own
// to track, see PlayerC.getPistolAuraAnchor).
static init(getAnchor: () => Vector3 | null) {
if (this.initialized) return;
this.initialized = true;
this.getAnchor = getAnchor;
this.material = new SpriteMaterial({
map: createGlowTexture(),
color: GLOW_COLOR,
transparent: true,
opacity: 0,
blending: AdditiveBlending,
// Additive glow must never occlude anything, and must not be occluded by
// the gun/hand it's meant to be wrapped around.
depthWrite: false,
depthTest: false,
});
this.sprite = new Sprite(this.material);
this.sprite.visible = false;
// Renders after the scene proper — with depthTest off, draw order is the
// only thing left deciding what ends up on top.
this.sprite.renderOrder = 10;
ThreeC.addToScene(this.sprite);
UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta));
}
private static update(delta: number) {
if (!this.initialized) return;
const level = PistolLevelC.getLevel();
const anchor = level > 0 ? this.getAnchor() : null;
this.sprite.visible = anchor !== null;
if (!anchor) return;
this.elapsed += delta;
// Level 1 is one step in, not zero steps — an upgrade that produced no
// visible change at all would defeat the point.
const steps = Math.min(level, MAX_EFFECT_LEVEL) - 1;
const size = BASE_SIZE + steps * SIZE_PER_LEVEL;
const opacity = BASE_OPACITY + steps * OPACITY_PER_LEVEL;
const pulseDepth = PULSE_DEPTH * (steps / Math.max(1, MAX_EFFECT_LEVEL - 1));
this.sprite.position.copy(anchor);
this.sprite.scale.setScalar(size);
this.material.opacity = opacity * (1 - pulseDepth + pulseDepth * Math.sin(this.elapsed * PULSE_SPEED));
}
}
+54 -9
View File
@@ -1,31 +1,54 @@
import { UpdateController } from "@hitplay/playable_template";
import { weaponHudBgSrc, weaponIconBatSrc } from "../resources/images/images";
// Bottom-left "weapon level" plate (octagon background + weapon icon +
// "LVL" text) — purely a visual recreation of the reference. No real
// weapon-leveling system exists in this project, so "LVL 0" is a hardcoded
// label, not backed by any progression state.
// Bottom-left "bat level" plate (octagon background + bat icon + "LVL"
// text) — a visual recreation of the reference, backed by real progression:
// the number is the BAT level, raised by filling the wood Pay Zone (see
// BatLevelC / PayZoneC), separate from the pistol's own level shown in the
// widget stacked below it (see PistolHudC).
//
// Always shows the bat icon (Tool_2), regardless of which weapon PlayerC
// currently has equipped (pistol vs bat, see PlayerC.equipPistol/equipBat)
// — not wired to PlayerC's live equip state.
export class WeaponHudC {
private static initialized = false;
private static plateEl: HTMLElement;
private static levelTextEl: HTMLElement;
private static lastDisplayedLevel = -1;
private static levelGetter: (() => number) | null = null;
// Injected rather than importing BatLevelC directly, matching the rest of
// the HUD widgets in this project (HudC.setBalanceGetter,
// PistolHudC.setLevelGetter).
static setLevelGetter(getLevel: () => number) {
this.levelGetter = getLevel;
}
static init() {
if (this.initialized) return;
this.initialized = true;
this.buildDom();
UpdateController.Instance.onUpdate.addDelegate(() => this.update());
}
// Layout/styling lives in src/css/ui.css (.weapon-hud/__icon/__level) —
// only the two background-driven image sources are set here, same
// Layout/styling lives in src/css/ui.css (.weapon-hud/__plate/__icon/__level)
// — only the two background-driven image sources are set here, same
// reasoning as HudC's resource-counter plate (asset paths are only known
// via the build's asset pipeline, not something a static CSS file can
// reference).
//
// Two nested divs, not one, for exactly the reason spelled out in ui.css for
// .resource-hud: the outer element owns the one-shot mount animation, the
// inner plate owns the upgrade bump. Sharing one element would make every
// upgrade replay the mount animation as well.
private static buildDom() {
const holder = document.createElement("div");
holder.className = "weapon-hud";
holder.style.backgroundImage = `url(${weaponHudBgSrc})`;
const plate = document.createElement("div");
plate.className = "weapon-hud__plate";
plate.style.backgroundImage = `url(${weaponHudBgSrc})`;
const icon = document.createElement("img");
icon.className = "weapon-hud__icon";
@@ -42,10 +65,32 @@ export class WeaponHudC {
levelText.textContent = "LVL 0";
level.appendChild(levelText);
holder.appendChild(icon);
holder.appendChild(level);
plate.appendChild(icon);
plate.appendChild(level);
holder.appendChild(plate);
const uiRoot = document.getElementById("ui-safe-area") ?? document.body;
uiRoot.appendChild(holder);
this.plateEl = plate;
this.levelTextEl = levelText;
}
private static update() {
if (!this.levelGetter) return;
const level = this.levelGetter();
if (level === this.lastDisplayedLevel) return;
const isFirstPaint = this.lastDisplayedLevel === -1;
this.lastDisplayedLevel = level;
this.levelTextEl.textContent = `LVL ${level}`;
// No pop on the first paint — that's just the plate showing its starting
// level. Same remove/reflow/add dance HudC uses, so a second upgrade
// landing quickly still restarts the animation cleanly.
if (isFirstPaint) return;
this.plateEl.classList.remove("is-upgrading");
void this.plateEl.offsetWidth;
this.plateEl.classList.add("is-upgrading");
}
}
+618
View File
@@ -0,0 +1,618 @@
import {
Box3,
BoxGeometry,
CapsuleGeometry,
Group,
Mesh,
MeshStandardMaterial,
Object3D,
SphereGeometry,
Vector3,
} from "three";
import { UpdateController } from "@hitplay/playable_template";
import { ThreeC } from "./ThreeC";
import { HealthBarC, HealthBarOptions } from "./HealthBarC";
import { ZombiePartC } from "./ZombiePartC";
// The opening wave, spawned in a staggered trickle rather than all at once —
// eight figures popping into existence on the same frame reads as a glitch,
// one every SPAWN_INTERVAL_S reads as "they keep coming".
const WAVE_SIZE = 8;
const SPAWN_INTERVAL_S = 0.7;
// The invasion never ends: once the opening wave is out, reinforcements keep
// arriving one at a time, forever, at this slower cadence. Slower than
// SPAWN_INTERVAL_S on purpose — a permanent 0.7s drip would out-produce the
// player's kill rate within seconds and turn into an unplayable wall.
const REINFORCEMENT_INTERVAL_S = 10;
// Hard cap on how many living zombies exist at once. Spawning is paused (not
// cancelled — the timer just retries a cadence later) while at the cap. This
// is what keeps the endless stream survivable AND keeps applySeparation's
// O(n²) pass over the crowd bounded.
const MAX_ALIVE = 14;
// Where a zombie appears: on a ring of this radius around the player, at a
// random angle, then clamped into the map's own XZ bounds (see
// randomSpawnPoint). The clamp is what makes them come "from the edge of the
// map" — the map is far longer on Z (~53 units) than it is wide (~37), so on
// the narrow sides the ring lands outside the ground and gets pulled back to
// the actual edge, while on the long side the ring radius is what applies.
const SPAWN_DISTANCE = 15;
// A spawn is re-rolled if clamping pulled it closer than this. Deliberately
// larger than PlayerC's SHOOT_RANGE: a zombie must never materialize already
// inside the player's firing zone, or the pistol draw would trigger with no
// approach to watch first.
const MIN_SPAWN_DISTANCE = 10;
// Keeps spawns off the very lip of the ground mesh, where a figure would be
// half-hanging over the edge.
const MAP_EDGE_MARGIN = 1.5;
// Slower than the player's MOVE_SPEED (4) on purpose — the player has to be
// able to walk away from them, otherwise the shooting zone can never clear.
const MOVE_SPEED = 1.6;
// Zombies stop this far from the player instead of walking into (and through)
// him — there is no zombie<->player collision, this distance is what stands in
// for it.
const STOP_DISTANCE = 1.3;
// Cheap mutual repulsion so a crowd converging on one point doesn't collapse
// into a single overlapping stack. O(n²) over WAVE_SIZE items — irrelevant at
// this count, revisit only if waves get much bigger.
const SEPARATION_DISTANCE = 0.9;
const SEPARATION_PUSH = 1.4;
// Both fixed by the brief: 100 HP against PlayerC's 20 damage per shot = five
// shots per zombie.
export const ZOMBIE_MAX_HP = 100;
// 10 damage once per second, per zombie — applied as a discrete tick rather
// than a continuous 10*delta drain so it produces the same readable "-10"
// popup the crates do. Two zombies on you is 20/s, which is what makes a
// crowd actually dangerous.
const ATTACK_DAMAGE = 10;
const ATTACK_INTERVAL_S = 1;
// Slightly beyond STOP_DISTANCE, so a zombie that has settled at its stopping
// distance is reliably inside its own attack range instead of hovering right
// on the boundary and flickering in and out of it.
const ATTACK_RANGE = 1.7;
// Short forward jab on each attack tick, decaying back — cheap stand-in for
// an attack animation these primitive figures don't have.
const LUNGE_DISTANCE = 0.28;
const LUNGE_DURATION_S = 0.25;
// How far above the figure's own head the health bar floats.
const HEALTH_BAR_HEIGHT = 2.05;
// Emissive red pulse on hit, decaying back to black. Emissive (not `color`)
// on a MeshStandardMaterial — same reason flashOverlayMaterial.ts exists for
// the crates, except here we own the material outright, so the cheap channel
// is available and no overlay mesh is needed.
const HIT_FLASH_S = 0.16;
const HIT_FLASH_COLOR = 0xff3a2a;
// Fall over, sink, shrink, then get removed. Driven by a plain accumulator in
// update() rather than a tween: tween.onComplete is documented as unreliable
// in this project (see CLAUDE.md), and cleanup here MUST happen or the scene
// leaks meshes.
const DEATH_DURATION_S = 0.7;
const DEATH_FALL_FRACTION = 0.45; // of DEATH_DURATION_S spent tipping over
const DEATH_SINK = 0.35; // world units the corpse sinks into the ground
// Shambling bob — a vertical sine on the figure, not on the root, so it can't
// fight the death animation's own transform of the same node.
const BOB_SPEED = 7;
const BOB_HEIGHT = 0.06;
// Walk cycle: legs swing in antiphase around their hip pivots, arms
// counter-swing by a smaller amount. Not driven by BOB_SPEED (which is the
// body's own vertical shamble at double the stride rate) — a stride is one
// full sine period, so this is deliberately its own, slower rate.
const STRIDE_SPEED = 4.6; // radians/second of the swing sine
const LEG_SWING = 0.55; // radians, peak swing from vertical
const ARM_SWING = 0.2;
// The swing amplitude fades in/out over roughly a fifth of a second instead of
// snapping on and off, so a zombie arriving at STOP_DISTANCE settles into its
// standing pose rather than freezing mid-stride on one frame.
const STRIDE_BLEND_SPEED = 5;
// Zombie-part chunks dropped per kill (see ZombiePartC — they fly to
// PistolResourceHudC and feed PistolLevelC). Spawned from chest height rather
// than the feet-anchored root, so they burst out of the body as it starts
// falling.
const ZOMBIE_PART_DROP_MIN = 1;
const ZOMBIE_PART_DROP_MAX = 3;
const ZOMBIE_PART_DROP_HEIGHT = 0.9;
export interface Zombie {
root: Object3D;
// The visual figure, one level below root: root owns position + facing,
// this owns bob and the death tip-over. Keeping them on separate nodes is
// what lets lookAt() (which overwrites the whole quaternion) coexist with a
// death rotation instead of clobbering it every frame.
figure: Group;
// Limb pivots, one level below `figure`: each holds its limb mesh offset so
// that rotating the pivot swings the limb from the hip/shoulder instead of
// spinning it around its own middle. See createZombie.
legLeft: Group;
legRight: Group;
armLeft: Group;
armRight: Group;
material: MeshStandardMaterial;
maxHp: number;
currentHp: number;
dying: boolean;
deathElapsed: number;
flashElapsed: number;
bobPhase: number;
// Walk cycle: `stridePhase` runs the swing sine, `strideAmount` (0..1) is
// its amplitude, eased toward 1 while actually walking and toward 0 once
// stopped at the player — see updateWalkCycle.
stridePhase: number;
strideAmount: number;
// Counts up to ATTACK_INTERVAL_S while in range of the player; starts full
// so the first hit lands the moment a zombie arrives rather than a second
// after it.
attackCooldown: number;
lungeElapsed: number;
}
// Geometry is identical for every zombie, so it's built once and shared;
// only the material is per-instance (each one flashes independently on hit).
let sharedGeometry: {
leg: BoxGeometry;
torso: CapsuleGeometry;
head: SphereGeometry;
arm: BoxGeometry;
} | null = null;
function getSharedGeometry() {
if (!sharedGeometry) {
sharedGeometry = {
leg: new BoxGeometry(0.18, 0.6, 0.18),
torso: new CapsuleGeometry(0.26, 0.3, 4, 10),
head: new SphereGeometry(0.21, 14, 10),
arm: new BoxGeometry(0.14, 0.14, 0.62),
};
}
return sharedGeometry;
}
// Placeholder enemies: primitive-built humanoid figures standing in for real
// zombie models that don't exist yet. Everything about them is deliberately
// simple — no physics body, no navigation, no attack on the player — because
// the only thing they need to support right now is "something approaches, the
// player shoots it, it dies".
//
// Deliberately does NOT import PlayerC: PlayerC imports THIS file (to find
// and damage targets), so the player's position arrives through an injected
// getter instead, same DI pattern as ResourceC.setFlyTarget etc.
export class ZombieC {
private static initialized = false;
private static zombies: Zombie[] = [];
private static getPlayerPosition: () => Vector3 = () => new Vector3();
private static damagePlayer: (amount: number) => void = () => {};
private static isPlayerAlive: () => boolean = () => true;
private static mapBounds = new Box3();
private static waveActive = false;
private static pendingSpawns = 0;
private static spawnTimer = 0;
// mapBounds is the map's real world bounding box (Box3.setFromObject),
// not a guessed radius — spawn points are clamped into it so nobody walks
// in out of the void beyond the ground mesh.
//
// The player arrives entirely through callbacks (position / take damage /
// still alive) rather than an import: PlayerC imports THIS file to find and
// shoot targets, so the pair would otherwise cycle.
static init(
mapBounds: Box3,
getPlayerPosition: () => Vector3,
damagePlayer: (amount: number) => void,
isPlayerAlive: () => boolean
) {
if (this.initialized) return;
this.initialized = true;
this.mapBounds = mapBounds;
this.getPlayerPosition = getPlayerPosition;
this.damagePlayer = damagePlayer;
this.isPlayerAlive = isPlayerAlive;
UpdateController.Instance.onUpdate.addDelegate((delta: number) => this.update(delta));
}
// Wired to the ZOMBIE INVASION timer finishing — see
// ZombieInvasionHudC.setOnComplete. Idempotent: a second call while a wave
// is already running is ignored rather than stacking two waves (the debug
// panel can restart the timer, which would otherwise fire this again).
static startWave() {
if (this.waveActive) return;
this.waveActive = true;
this.pendingSpawns = WAVE_SIZE;
// First zombie appears immediately, not one interval late — the timer
// hitting zero and nothing happening for a beat reads as a bug.
this.spawnTimer = SPAWN_INTERVAL_S;
}
static isWaveActive(): boolean {
return this.waveActive;
}
// Nearest living zombie within `radius` of `point`, or null. PlayerC calls
// this every frame to decide whether to draw the pistol and who to aim at —
// dying zombies are excluded so the last shot doesn't keep being spent on a
// corpse mid-fall.
static findNearestAlive(point: Vector3, radius: number): Zombie | null {
let nearest: Zombie | null = null;
let nearestDistanceSq = radius * radius;
for (const zombie of this.zombies) {
if (zombie.dying) continue;
const distanceSq = zombie.root.position.distanceToSquared(point);
if (distanceSq > nearestDistanceSq) continue;
nearest = zombie;
nearestDistanceSq = distanceSq;
}
return nearest;
}
// Where a shot should visually land — chest height, not the feet-anchored
// root position.
static getAimPoint(zombie: Zombie): Vector3 {
return zombie.root.position.clone().add(new Vector3(0, 1.1, 0));
}
static damage(zombie: Zombie, amount: number) {
if (zombie.dying) return;
const previousHp = zombie.currentHp;
zombie.currentHp = Math.max(0, zombie.currentHp - amount);
zombie.flashElapsed = 0;
zombie.material.emissive.setHex(HIT_FLASH_COLOR);
HealthBarC.onHit(zombie, this.healthBarOptions(zombie), previousHp, zombie.currentHp, amount);
if (zombie.currentHp === 0) {
zombie.dying = true;
zombie.deathElapsed = 0;
// Dropped as soon as it starts dying rather than when the corpse is
// finally removed — a full bar-with-0-HP hovering over a falling body
// for most of a second reads as a bug.
HealthBarC.onDestroyed(zombie);
// Same timing reasoning for the loot: the chunks burst out as the body
// starts to fall, not once it has finished sinking.
ZombiePartC.spawnBurstInRange(
zombie.root.position.clone().add(new Vector3(0, ZOMBIE_PART_DROP_HEIGHT, 0)),
ZOMBIE_PART_DROP_MIN,
ZOMBIE_PART_DROP_MAX
);
}
}
// `follow` — unlike the crates', these bars track a moving owner, so the
// anchor is re-sampled every frame instead of cached at creation.
private static healthBarOptions(zombie: Zombie): HealthBarOptions {
return {
variant: "enemy",
maxHp: zombie.maxHp,
follow: true,
getWorldTop: () => zombie.root.position.clone().add(new Vector3(0, HEALTH_BAR_HEIGHT, 0)),
};
}
private static update(delta: number) {
if (!this.waveActive) return;
this.updateSpawning(delta);
const playerPosition = this.getPlayerPosition();
// Iterated backwards: dead zombies splice themselves out of this same
// array inside the loop.
for (let i = this.zombies.length - 1; i >= 0; i--) {
const zombie = this.zombies[i];
this.updateHitFlash(zombie, delta);
if (zombie.dying) {
if (this.updateDeath(zombie, delta)) this.zombies.splice(i, 1);
continue;
}
this.updateApproach(zombie, playerPosition, delta);
}
}
// Spawning never stops once the wave is out — the opening WAVE_SIZE arrives
// at the fast SPAWN_INTERVAL_S cadence, then reinforcements keep trickling in
// at REINFORCEMENT_INTERVAL_S for the rest of the run, capped at MAX_ALIVE
// living zombies at a time.
private static updateSpawning(delta: number) {
// Nothing new arrives once the player is down — the game-over veil is up
// and a restart is a few seconds away, so a crowd still building behind it
// is pure wasted work.
if (!this.isPlayerAlive()) return;
const interval = this.pendingSpawns > 0 ? SPAWN_INTERVAL_S : REINFORCEMENT_INTERVAL_S;
this.spawnTimer += delta;
if (this.spawnTimer < interval) return;
this.spawnTimer = 0;
// At the cap the timer has already been reset, so this retries one interval
// later rather than spawning the instant a slot frees up — kills feel like
// they buy breathing room instead of instantly summoning a replacement.
if (this.countAlive() >= MAX_ALIVE) return;
if (this.pendingSpawns > 0) this.pendingSpawns--;
this.spawn();
}
// Dying zombies don't count against MAX_ALIVE: their corpses linger for
// DEATH_DURATION_S and holding a slot for something the player already
// killed would read as the stream stalling.
private static countAlive(): number {
let alive = 0;
for (const zombie of this.zombies) {
if (!zombie.dying) alive++;
}
return alive;
}
private static spawn() {
const zombie = this.createZombie();
zombie.root.position.copy(this.randomSpawnPoint(this.getPlayerPosition()));
ThreeC.addToScene(zombie.root);
this.zombies.push(zombie);
}
// Random angle on a ring around the player, clamped into the map. Retried a
// few times because the clamp can pull a point much closer than intended
// (the player starts near the map's -Z end, so anything spawned "behind"
// him lands right on top of him) — a re-roll picks a different angle, and
// in practice lands on the open, camera-facing side of the map.
private static randomSpawnPoint(playerPosition: Vector3): Vector3 {
const minX = this.mapBounds.min.x + MAP_EDGE_MARGIN;
const maxX = this.mapBounds.max.x - MAP_EDGE_MARGIN;
const minZ = this.mapBounds.min.z + MAP_EDGE_MARGIN;
const maxZ = this.mapBounds.max.z - MAP_EDGE_MARGIN;
let fallback = new Vector3();
for (let attempt = 0; attempt < 8; attempt++) {
const angle = Math.random() * Math.PI * 2;
const point = new Vector3(
playerPosition.x + Math.cos(angle) * SPAWN_DISTANCE,
0,
playerPosition.z + Math.sin(angle) * SPAWN_DISTANCE
);
point.x = Math.min(Math.max(point.x, minX), maxX);
point.z = Math.min(Math.max(point.z, minZ), maxZ);
if (attempt === 0) fallback = point.clone();
if (point.distanceTo(playerPosition) >= MIN_SPAWN_DISTANCE) return point;
}
return fallback;
}
private static updateApproach(zombie: Zombie, playerPosition: Vector3, delta: number) {
const toPlayer = playerPosition.clone().sub(zombie.root.position);
toPlayer.y = 0;
const distance = toPlayer.length();
this.updateAttack(zombie, distance, delta);
const walking = distance > STOP_DISTANCE;
if (distance > 1e-4) {
// lookAt orients +Z at the target, which is the axis the figure below
// is modelled facing (arms reach along +Z) — same convention PlayerC
// uses for its own container.
const facingTarget = zombie.root.position.clone().add(toPlayer);
zombie.root.lookAt(facingTarget);
if (walking) {
toPlayer.divideScalar(distance); // normalize, reusing the length already computed
zombie.root.position.addScaledVector(toPlayer, MOVE_SPEED * delta);
}
}
this.applySeparation(zombie, delta);
this.updateWalkCycle(zombie, walking, delta);
zombie.bobPhase += delta * BOB_SPEED;
zombie.figure.position.y = Math.abs(Math.sin(zombie.bobPhase)) * BOB_HEIGHT;
// Jab forward and ease back. Sits on figure.position.z (local +Z is the
// direction root.lookAt aimed at the player), so it reads as a lunge at
// whoever it's facing without touching the root's own position.
if (zombie.lungeElapsed < LUNGE_DURATION_S) {
zombie.lungeElapsed += delta;
const progress = Math.min(1, zombie.lungeElapsed / LUNGE_DURATION_S);
// Out and back within the one duration — sin(pi * t) peaks at the
// halfway point and returns to zero at the end.
zombie.figure.position.z = Math.sin(Math.PI * progress) * LUNGE_DISTANCE;
} else {
zombie.figure.position.z = 0;
}
}
// 10 damage a second while standing on the player. The cooldown only ticks
// up while actually in range, so a zombie that has just shuffled into
// contact hits immediately (it starts full) and one that gets pushed away
// resumes rather than restarting its wind-up.
private static updateAttack(zombie: Zombie, distance: number, delta: number) {
if (distance > ATTACK_RANGE || !this.isPlayerAlive()) return;
zombie.attackCooldown += delta;
if (zombie.attackCooldown < ATTACK_INTERVAL_S) return;
zombie.attackCooldown = 0;
zombie.lungeElapsed = 0;
this.damagePlayer(ATTACK_DAMAGE);
}
// Legs swing in antiphase around their hips, arms counter-swing — the actual
// "they walk" read, on top of the body's vertical shamble. The phase only
// advances while moving, so a zombie stopped on top of the player holds a
// still stance instead of marching in place; the amplitude eases in and out
// separately (STRIDE_BLEND_SPEED) so neither transition snaps.
private static updateWalkCycle(zombie: Zombie, walking: boolean, delta: number) {
if (walking) zombie.stridePhase += delta * STRIDE_SPEED;
const target = walking ? 1 : 0;
const step = STRIDE_BLEND_SPEED * delta;
zombie.strideAmount =
Math.abs(target - zombie.strideAmount) <= step
? target
: zombie.strideAmount + Math.sign(target - zombie.strideAmount) * step;
const swing = Math.sin(zombie.stridePhase) * zombie.strideAmount;
zombie.legLeft.rotation.x = swing * LEG_SWING;
zombie.legRight.rotation.x = -swing * LEG_SWING;
// Opposite sign to the leg on the same side — a same-side arm and leg
// swinging together is the one thing that instantly reads as "broken
// walk cycle" rather than "shambling".
zombie.armLeft.rotation.x = -swing * ARM_SWING;
zombie.armRight.rotation.x = swing * ARM_SWING;
}
private static applySeparation(zombie: Zombie, delta: number) {
for (const other of this.zombies) {
if (other === zombie || other.dying) continue;
const away = zombie.root.position.clone().sub(other.root.position);
away.y = 0;
const distance = away.length();
if (distance >= SEPARATION_DISTANCE || distance < 1e-4) continue;
// Push strength ramps up as they get closer, so touching shoulders
// nudges gently and a near-total overlap separates hard.
const strength = (SEPARATION_DISTANCE - distance) / SEPARATION_DISTANCE;
away.divideScalar(distance);
zombie.root.position.addScaledVector(away, strength * SEPARATION_PUSH * delta);
}
}
private static updateHitFlash(zombie: Zombie, delta: number) {
if (zombie.flashElapsed >= HIT_FLASH_S) return;
zombie.flashElapsed += delta;
const remaining = Math.max(0, 1 - zombie.flashElapsed / HIT_FLASH_S);
zombie.material.emissive.setHex(HIT_FLASH_COLOR).multiplyScalar(remaining);
}
// Returns true once the corpse has been torn down and should leave the
// array.
private static updateDeath(zombie: Zombie, delta: number): boolean {
zombie.deathElapsed += delta;
const progress = Math.min(1, zombie.deathElapsed / DEATH_DURATION_S);
// Tips over face-down within the first stretch, then keeps sinking and
// shrinking for the remainder.
const fallProgress = Math.min(1, progress / DEATH_FALL_FRACTION);
zombie.figure.rotation.x = -(Math.PI / 2) * fallProgress;
zombie.figure.position.y = -DEATH_SINK * progress;
zombie.figure.scale.setScalar(1 - 0.25 * progress);
if (progress < 1) return false;
ThreeC.removeFromScene(zombie.root);
zombie.material.dispose();
return true;
}
private static createZombie(): Zombie {
const geometry = getSharedGeometry();
// Per-instance: hit flash writes to `emissive`, and a shared material
// would make every zombie on the map flash on every hit — the same
// shared-material trap the GLB crates have (see CLAUDE.md).
const material = new MeshStandardMaterial({ color: 0x5f9e4a, roughness: 0.85, metalness: 0 });
const figure = new Group();
// Each limb is a pivot Group at the joint with the mesh hung off it, not a
// bare mesh — a box's own origin is its center, so rotating the mesh
// directly would spin the leg around its knee. The pivot sits at the
// hip/shoulder and the mesh is offset by half its length, which is what
// makes updateWalkCycle's rotation.x read as a swing.
const legLeft = this.createLimb(geometry.leg, material, -0.14, 0.6, 0, new Vector3(0, -0.3, 0));
const legRight = this.createLimb(geometry.leg, material, 0.14, 0.6, 0, new Vector3(0, -0.3, 0));
// Capsule is 0.82 tall overall (0.3 body + 2 * 0.26 caps), sitting on top
// of the 0.6-tall legs.
const torso = new Mesh(geometry.torso, material);
torso.position.set(0, 1.01, 0);
const head = new Mesh(geometry.head, material);
head.position.set(0, 1.57, 0);
// Reaching forward along +Z — the axis root.lookAt() aims at the player,
// so the arms always point at whoever they're shambling toward. The
// shoulder pivot is at the body, the arm mesh reaches out from it.
const armLeft = this.createLimb(geometry.arm, material, -0.32, 1.25, 0, new Vector3(0, 0, 0.3));
const armRight = this.createLimb(geometry.arm, material, 0.32, 1.25, 0, new Vector3(0, 0, 0.3));
figure.add(legLeft, legRight, torso, head, armLeft, armRight);
const root = new Object3D();
root.add(figure);
ThreeC.setShadowsStateForChildren(root, true, false);
return {
root,
figure,
legLeft,
legRight,
armLeft,
armRight,
material,
maxHp: ZOMBIE_MAX_HP,
currentHp: ZOMBIE_MAX_HP,
dying: false,
deathElapsed: 0,
flashElapsed: HIT_FLASH_S, // starts "already faded", so no flash on spawn
// Random phase so a crowd doesn't bob in perfect lockstep.
bobPhase: Math.random() * Math.PI * 2,
// Same reasoning for the stride: a crowd walking in perfect step reads
// as a marching formation, not a horde.
stridePhase: Math.random() * Math.PI * 2,
strideAmount: 1, // spawns mid-approach, so already walking
attackCooldown: ATTACK_INTERVAL_S,
lungeElapsed: LUNGE_DURATION_S, // starts "already finished"
};
}
// A limb mesh hung off a pivot Group placed at its joint — see the note in
// createZombie for why the mesh can't just be rotated in place.
private static createLimb(
geometry: BoxGeometry,
material: MeshStandardMaterial,
jointX: number,
jointY: number,
jointZ: number,
meshOffset: Vector3
): Group {
const pivot = new Group();
pivot.position.set(jointX, jointY, jointZ);
const mesh = new Mesh(geometry, material);
mesh.position.copy(meshOffset);
pivot.add(mesh);
return pivot;
}
}
+23 -6
View File
@@ -6,11 +6,11 @@ import { zombieHeadIconSrc } from "../resources/images/images";
// duplicated as a magic number in ui.css. Exported so DebugConfigC's timer
// slider (see restartTimer) starts at this same default instead of
// duplicating the number.
export const TIMER_DURATION_S = 120;
export const TIMER_DURATION_S = 30;
// Last stretch of the timer where the bar starts pulsing + flashing red to
// warn the player it's about to fill. 0.2 = last 20%, i.e. the last 24s of
// a 120s timer.
// warn the player it's about to fill. 0.2 = last 20%, i.e. the last 6s of a
// 30s timer.
const WARNING_FRACTION = 0.2;
// Top-center "ZOMBIE INVASION" timer bar (title + a zombie-head marker
@@ -18,9 +18,10 @@ const WARNING_FRACTION = 0.2;
// the track is a plain CSS pill, not a designer asset like HudC/WeaponHudC's
// backgrounds.
//
// Once the head reaches the left edge (full), nothing else happens here on
// purpose — no completion callback, no loop/reset. Purely the visual, wired
// up once at init and left alone after that.
// The bar itself still owns nothing but the visual. What happens when it
// fills is injected from outside via setOnComplete (TestSceneC wires it to
// ZombieC.startWave) — this file deliberately doesn't know that zombies
// exist, same DI pattern as ResourceC.setFlyTarget etc.
export class ZombieInvasionHudC {
private static initialized = false;
private static barEl: HTMLElement;
@@ -28,12 +29,20 @@ export class ZombieInvasionHudC {
private static flashEl: HTMLElement;
private static headEl: HTMLElement;
private static warningTimeout: ReturnType<typeof setTimeout> | null = null;
private static completeTimeout: ReturnType<typeof setTimeout> | null = null;
private static onComplete: (() => void) | null = null;
// durationS defaults to TIMER_DURATION_S but is normally passed in
// explicitly by TestSceneC, sourced from DebugConfigC.getZombieInvasionTimerS()
// (itself seeded from the platform's own configUIParams) — not read from
// DebugConfigC directly in here, since DebugConfigC already imports this
// file for restartTimer/TIMER_DURATION_S and importing back would cycle.
// Must be called BEFORE init(): init() starts the countdown immediately, so
// a callback registered afterwards would miss a very short timer.
static setOnComplete(callback: () => void) {
this.onComplete = callback;
}
static init(durationS: number = TIMER_DURATION_S) {
if (this.initialized) return;
this.initialized = true;
@@ -49,6 +58,7 @@ export class ZombieInvasionHudC {
// from init() above.
static restartTimer(durationS: number) {
if (this.warningTimeout !== null) clearTimeout(this.warningTimeout);
if (this.completeTimeout !== null) clearTimeout(this.completeTimeout);
this.barEl.classList.remove("is-almost-full");
this.flashEl.classList.remove("is-almost-full");
@@ -131,5 +141,12 @@ export class ZombieInvasionHudC {
this.barEl.classList.add("is-almost-full");
this.flashEl.classList.add("is-almost-full");
}, warningDelayMs);
// A timeout, not the fill's `transitionend`: transitions don't fire at
// all if the element is display:none or the tab was backgrounded mid-run,
// and "the wave never starts" is a far worse failure than firing a frame
// early. The bar keeps pulsing red afterwards on purpose — the invasion
// has arrived, so the alarm state staying on is correct.
this.completeTimeout = setTimeout(() => this.onComplete?.(), durationS * 1000);
}
}
+101
View File
@@ -0,0 +1,101 @@
import { IcosahedronGeometry, Mesh, MeshStandardMaterial, Vector3 } from "three";
import { PickupFlight } from "../utils/pickupFlight";
// Low-poly, jagged chunk radius/squash — reads as a torn-off piece by
// silhouette alone, instantly distinguishable from the flat wood-plank icon
// at the same on-screen size, same reasoning the old exp crystal followed
// before this file replaced it.
//
// The squash is baked into the GEOMETRY, not the mesh scale — PickupFlight
// writes a uniform scale.setScalar() every frame for its impact shrink and
// perspective compensation, which would flatten a non-uniform mesh scale.
const CHUNK_RADIUS = 0.16;
const CHUNK_SQUASH = new Vector3(1, 0.7, 1.15);
// Dull, fleshy green rather than the old exp crystal's glowing teal — this is
// meat, not a magic pickup. A little emissive so it still reads clearly
// against the map's own lighting.
const CHUNK_COLOR = 0x3c7a2e;
const CHUNK_EMISSIVE = 0x1f4d16;
const CHUNK_EMISSIVE_INTENSITY = 0.6;
const SPIN_SPEED = 2.4;
// Fixed tilt off vertical so the spin actually reads as a tumbling chunk
// rather than a shape rotating around its own axis of symmetry.
const CHUNK_TILT = 0.4;
// Geometry and material are identical for every chunk, so both are built once
// and shared — nothing here mutates the material (PickupFlight's flash lives
// on per-chunk overlay clones with their own material), so sharing is safe.
let sharedGeometry: IcosahedronGeometry | null = null;
let sharedMaterial: MeshStandardMaterial | null = null;
function getSharedGeometry(): IcosahedronGeometry {
if (!sharedGeometry) {
sharedGeometry = new IcosahedronGeometry(CHUNK_RADIUS, 0);
sharedGeometry.scale(CHUNK_SQUASH.x, CHUNK_SQUASH.y, CHUNK_SQUASH.z);
}
return sharedGeometry;
}
function getSharedMaterial(): MeshStandardMaterial {
if (!sharedMaterial) {
sharedMaterial = new MeshStandardMaterial({
color: CHUNK_COLOR,
emissive: CHUNK_EMISSIVE,
emissiveIntensity: CHUNK_EMISSIVE_INTENSITY,
roughness: 0.9,
metalness: 0,
});
}
return sharedMaterial;
}
// Green zombie-part chunks dropped by dying zombies (see ZombieC) — the
// currency for the PISTOL's own progression (see PistolLevelC and its Pay
// Zone), separate from the wood crates drop for the bat's (see ResourceC /
// BatLevelC). Uses the exact same scatter/bounce/impact/fly animation wood
// does — shared via utils/pickupFlight.ts — and, like wood, flies to a HUD
// counter rather than to the player: see PistolResourceHudC.
export class ZombiePartC {
private static flyTarget: (() => Vector3) | null = null;
private static collected = 0;
private static flight = new PickupFlight({
createMesh: () => this.createChunkMesh(),
getTarget: () => this.flyTarget?.() ?? null,
onCollected: () => {
this.collected++;
},
spinSpeed: SPIN_SPEED,
});
static getCollectedCount() {
return this.collected;
}
// Where settled chunks fly to before being counted — see
// PistolResourceHudC.getWorldAnchorPosition.
static setFlyTarget(getTarget: () => Vector3) {
this.flyTarget = getTarget;
}
// A standalone clone of the pickup visual for the pistol Pay Zone's
// deposit-flight token — same reasoning as ResourceC.createVisual.
static createVisual() {
return this.createChunkMesh();
}
static spawnBurstInRange(origin: Vector3, min: number, max: number) {
this.flight.spawnBurstInRange(origin, min, max);
}
private static createChunkMesh(): Mesh {
const mesh = new Mesh(getSharedGeometry(), getSharedMaterial());
mesh.rotation.z = CHUNK_TILT;
// No shadow: a self-lit chunk casting a hard shadow reads as a solid
// prop, and there are up to three of these per zombie in a wave.
mesh.castShadow = false;
return mesh;
}
}
+42 -13
View File
@@ -1,5 +1,10 @@
/* World-tracking crate health bars: bordered bar, blue = current HP, black
= already-lost HP, white = HP lost on the hit that just landed.
/* World-tracking health bars, shared by crates, zombies and the player:
bordered bar, coloured fill = current HP, black = already-lost HP, a
second contrasting fill = HP lost on the hit that just landed.
The two fill layers are named for their ROLE (__fill / __lost), not their
colour, because the colour is per-variant — see the .health-bar--* blocks
below. Damage numbers stay white on every variant.
Positioned every frame in JS (HealthBarC.update()) via world->screen
projection, not laid out by normal CSS flow — position:fixed + raw
canvas-rect px, the same technique HudC uses for the opposite direction
@@ -61,32 +66,56 @@
overflow: hidden;
}
.health-bar__white,
.health-bar__blue {
.health-bar__lost,
.health-bar__fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
/* The three-color effect (see HealthBarC.onHit): white snaps instantly to
the pre-hit fraction (transition-duration forced to 0s from JS for that
one frame), then both animate to the post-hit fraction — white with a
transition-delay so it visibly lags behind blue for a moment before
catching down and disappearing under the black track underneath. */
/* The three-colour effect (see HealthBarC.onHit): __lost snaps instantly
to the pre-hit fraction (transition-duration forced to 0s from JS for
that one frame), then both animate to the post-hit fraction — __lost
with a transition-delay so it visibly lags behind the fill for a moment
before catching down and disappearing under the black track. */
transition: width 260ms ease-out;
}
.health-bar__white {
.health-bar__lost {
background: #ffffff;
transition-delay: 220ms;
}
.health-bar__blue {
/* Layered on top of white so white's lag never covers the current-HP
fill — only ever reveals it from the right as blue catches down. */
.health-bar__fill {
/* Layered on top of __lost so the lag never covers the current-HP fill —
it only ever reveals it from the right as the fill catches down. */
z-index: 1;
background: #2ea3ff;
}
/* Zombies — red current HP, yellow for the chunk just shot off. */
.health-bar--enemy .health-bar__fill {
background: #e0342a;
}
.health-bar--enemy .health-bar__lost {
background: #ffd23f;
}
/* The player's own bar: green so it can never be confused with an enemy's at
a glance, same yellow just-lost flash. Wider than the world bars above —
it's the one the player actually has to read mid-fight. */
.health-bar--player {
--health-bar-width: calc(var(--lvh, 100vh) * 0.1);
}
.health-bar--player .health-bar__fill {
background: #46c65a;
}
.health-bar--player .health-bar__lost {
background: #ffd23f;
}
.health-bar__damage {
position: absolute;
left: 50%;
+258 -7
View File
@@ -165,9 +165,14 @@
}
/* WeaponHudC — bottom-left octagon plate (ZombiePunk_Icon-Bottom.webp) +
weapon icon (Tool_2.webp, the bat) + "LVL 0" text. Square source art, so
aspect-ratio:1 instead of a second fixed dimension — same reasoning as
.resource-hud above. */
weapon icon (Tool_2.webp, the bat) + "LVL N" text, N being the live weapon
level (raised by filling the wood Pay Zone — see BatLevelC). Square source art,
so aspect-ratio:1 instead of a second fixed dimension — same reasoning as
.resource-hud above.
Split into outer + __plate for the same reason .resource-hud is: the outer
element plays the one-shot mount animation, the plate plays the per-upgrade
bump, and one shared element would replay the mount on every upgrade. */
.weapon-hud {
/* Single source of truth for the plate's scale, same pattern as
--hud-width on .resource-hud — the icon size and level text's
@@ -179,13 +184,41 @@
bottom: 26%;
width: var(--weapon-hud-width);
aspect-ratio: 1;
background-repeat: no-repeat;
background-size: 100% 100%;
pointer-events: none;
z-index: 50;
animation: weapon-hud-enter 400ms ease-out;
}
.weapon-hud__plate {
position: relative;
width: 100%;
height: 100%;
background-repeat: no-repeat;
background-size: 100% 100%;
}
/* Toggled by WeaponHudC whenever the weapon level actually changes (not on the
first paint) — a pop plus a warm rim flare, matching the gold of the weapon's
own upgrade glow in-world (see WeaponAuraC's GLOW_COLOR). */
.weapon-hud__plate.is-upgrading {
animation: weapon-hud-upgrade 460ms ease-out;
}
@keyframes weapon-hud-upgrade {
0% {
transform: scale(1);
filter: drop-shadow(0 0 0 rgba(255, 204, 102, 0));
}
35% {
transform: scale(1.2);
filter: drop-shadow(0 0 calc(var(--weapon-hud-width) * 0.2) rgba(255, 204, 102, 0.9));
}
100% {
transform: scale(1);
filter: drop-shadow(0 0 0 rgba(255, 204, 102, 0));
}
}
/* Landscape override — see house convention rule 4. Mirrors the portrait
values above as a starting point; retune once seen live. */
@media (orientation: landscape) {
@@ -233,6 +266,88 @@
text-shadow: 0 0.05em 0.1em rgba(0, 0, 0, 0.8);
}
/* PistolHudC — pistol level plate, stacked directly BELOW the .weapon-hud
octagon (mirror image of where the old character-level widget used to sit
above it — same tuned offsets, sign-flipped). Reuses .weapon-hud's own
background art and keyframes: it's a generic octagon frame, not
bat-specific, so the pair reads as two matched medallions. */
.pistol-hud {
--pistol-hud-width: calc(var(--lvh, 100vh) * 0.08);
position: absolute;
left: 0%;
bottom: calc(26% - var(--lvh, 100vh) * 0.092);
width: var(--pistol-hud-width);
aspect-ratio: 1;
pointer-events: none;
z-index: 50;
animation: weapon-hud-enter 400ms ease-out;
}
/* Landscape override — see house convention rule 4. Mirrors .weapon-hud's own
landscape values the same way portrait does above. */
@media (orientation: landscape) {
.pistol-hud {
--pistol-hud-width: calc(var(--lvh, 100vh) * 0.15);
left: 0%;
bottom: calc(28% - var(--lvh, 100vh) * 0.168);
}
}
.pistol-hud__plate {
position: relative;
width: 100%;
height: 100%;
background-repeat: no-repeat;
background-size: 100% 100%;
}
/* Same upgrade pop as .weapon-hud__plate — see weapon-hud-upgrade above. */
.pistol-hud__plate.is-upgrading {
animation: weapon-hud-upgrade 460ms ease-out;
}
.pistol-hud__icon {
position: absolute;
top: 0;
left: 50%;
width: 72%;
aspect-ratio: 1;
transform: translateX(-50%);
}
.pistol-hud__level {
position: absolute;
bottom: 8%;
left: 2%;
width: 100%;
text-align: center;
color: #ffffff;
font-weight: normal;
font-size: calc(var(--pistol-hud-width) * 0.2);
font-family: gameFont, sans-serif;
/* em, not px — see .resource-hud__count's identical note above. */
text-shadow: 0 0.05em 0.1em rgba(0, 0, 0, 0.8);
}
/* PistolResourceHudC — second resource counter, stacked directly below
HudC's own wood counter. Reuses the exact same plate art
(resource_counter_bg.webp) rather than shipping a second bespoke asset —
the hue-rotate below is what reads it as a different currency (the
zombie-part resource is new, the plate isn't). */
.resource-hud--pistol {
top: calc(15.7% + var(--lvh, 100vh) * 0.05);
}
@media (orientation: landscape) {
.resource-hud--pistol {
top: calc(5% + var(--lvh, 100vh) * 0.08);
}
}
.resource-hud--pistol .resource-hud__plate {
filter: hue-rotate(140deg) saturate(1.35);
}
/* ZombieInvasionHudC — top-center title + timer bar (see reference
screenshot, temp/ResourceBackground_Wood.webp). No track/bar art was
supplied (only the head icon), so unlike .resource-hud/.weapon-hud
@@ -244,7 +359,13 @@
/* --lvh, not a raw vh — see house convention rule 3 above. */
--zombie-invasion-width: calc(var(--lvh, 100vh) * 0.27);
position: absolute;
top: 0%;
/* Не 0%: #ui-safe-area's own 3.5% inset alone still put the title under
an iPhone's Dynamic Island / notch (~5.5% of screen height on a 16 Pro,
and this widget is the only one that starts at the very top edge).
`env(safe-area-inset-*)` is NOT the fix here — it reports 0 unless the
viewport meta carries `viewport-fit=cover`, and index.html's meta is
SDK-owned/marked do-not-edit. A flat percentage is the honest knob. */
top: 6%;
left: 50%;
transform: translateX(-50%);
width: var(--zombie-invasion-width);
@@ -257,7 +378,11 @@
@media (orientation: landscape) {
.zombie-invasion {
--zombie-invasion-width: calc(var(--lvh, 100vh) * 0.47);
top: 0%;
/* Smaller bump than portrait's 6%: under the orientation lock the
logical top maps to a PHYSICAL side edge, so the island only ever
eats into one of the two rotation directions — and vertical room is
scarcer here. */
top: 4%;
}
}
@@ -372,6 +497,132 @@
filter: drop-shadow(0 0 calc(var(--zombie-invasion-width) * 0.01) rgba(0, 0, 0, 0.6));
}
/* PayZoneC — the "+1 LVL" label under the floating pistol in the middle of the
Pay Zone (the pistol itself is a 3D mesh on the canvas, not DOM).
Positioned every frame in JS from a projected world point (position:fixed +
raw canvas-rect px), the same technique the health bars use — see
health-bar.css's header note. z-index sits with them, BELOW the HUD widgets'
50: this is a world-space label, so a HUD plate must always win the stacking
fight against it. */
.pay-zone-reward {
position: fixed;
/* Centred horizontally on the projected point and hanging below it — the
point itself is just under the pistol. */
transform: translate(-50%, 0);
pointer-events: none;
z-index: 40;
color: #ffffff;
font-family: gameFont, sans-serif;
font-size: calc(var(--lvh, 100vh) * 0.024);
white-space: nowrap;
-webkit-text-stroke: calc(var(--lvh, 100vh) * 0.0018 * var(--game-font-stroke-width)) #000000;
paint-order: stroke fill;
text-shadow: 0 0.05em 0.12em rgba(0, 0, 0, 0.8);
/* A slow breathe, so it reads as an offer rather than as static scenery.
On the wrapper, not the .gamefont-x child — that child's font squeeze is
itself a transform, and the two would fight on one element (same split as
.game-over__counter-value). */
animation: pay-zone-reward-pulse 1.6s ease-in-out infinite;
}
@keyframes pay-zone-reward-pulse {
0%,
100% {
opacity: 0.82;
transform: translate(-50%, 0) scale(1);
}
50% {
opacity: 1;
transform: translate(-50%, 0) scale(1.07);
}
}
/* GameOverC — red veil over the whole play area after the player dies, with
a "YOU DIED" title and a 3..2..1 countdown to the restart.
Mounted into #ui, not #ui-safe-area: this one has to reach the actual
edges of the letterboxed game area, and #ui-safe-area is inset from them
by --ui-safe-margin (see its own note above), which would leave an
unpainted frame around the veil.
Animates the veil's own RGB up from transparent via opacity on the whole
layer — the element carries no content of its own, so there's no risk of
the "transparent hole instead of a colour" trap that made animating alpha
the wrong move on the health bars. */
.game-over {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: calc(var(--lvh, 100vh) * 0.02);
background: rgba(150, 12, 8, 0.45);
/* Above every HUD widget (z-index 50) — the run is over, nothing
underneath is still interactive or worth reading. */
z-index: 200;
/* Stays click-through: nothing here is a button, and the restart is on a
timer, so swallowing taps would only make a dead run feel frozen. */
pointer-events: none;
animation: game-over-fade-in 350ms ease-out;
}
@keyframes game-over-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.game-over__title {
color: #ffffff;
font-family: gameFont, sans-serif;
font-weight: bold;
font-size: calc(var(--lvh, 100vh) * 0.062);
-webkit-text-stroke: calc(var(--lvh, 100vh) * 0.0025 * var(--game-font-stroke-width)) #000000;
paint-order: stroke fill;
text-shadow: 0 0.05em 0.12em rgba(0, 0, 0, 0.7);
}
.game-over__counter {
color: #ffffff;
font-family: gameFont, sans-serif;
font-size: calc(var(--lvh, 100vh) * 0.095);
-webkit-text-stroke: calc(var(--lvh, 100vh) * 0.003 * var(--game-font-stroke-width)) #000000;
paint-order: stroke fill;
text-shadow: 0 0.05em 0.12em rgba(0, 0, 0, 0.7);
}
/* A wrapper around the .gamefont-x span, not the span itself: that span's
font squeeze is a `transform`, and so is this pop — on one shared element
the animation would override the squeeze for its whole duration and snap
back at the end. Nested, the two transforms compose.
GameOverC replaces this element outright on every tick, which is what
makes the pop replay per number (a CSS animation doesn't restart just
because textContent changed). */
.game-over__counter-value {
display: inline-block;
animation: game-over-counter-pop 600ms ease-out;
}
@keyframes game-over-counter-pop {
0% {
transform: scale(1.6);
opacity: 0.4;
}
35% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
/* InstallBanner (SDK component — @hitplay/playable_template's
InstallBanner.ts, wired up in beforeResourcesLoadedCb.ts) — a light
visual restyle only, NOT a rebuild: it has real click ->
+308
View File
@@ -0,0 +1,308 @@
import { Mesh, MeshBasicMaterial, Object3D, Vector3 } from "three";
import { CameraC_internal, UpdateController } from "@hitplay/playable_template";
import { ThreeC } from "../controllers/ThreeC";
import { SparkleFxC } from "../controllers/SparkleFxC";
import { perspectiveCompensationScale } from "./perspectiveScale";
import { createFlashOverlayMaterial } from "./flashOverlayMaterial";
// The shared "something popped out of a killed thing and flew somewhere"
// animation: pop out of the origin, scatter a short random distance, land,
// bounce twice, flash/sparkle on touchdown, then fly on to a target that is
// re-sampled every frame.
//
// Extracted from ResourceC so a second pickup type (see ZombiePartC) gets
// exactly the same feel instead of a second, slowly-diverging copy of this
// state machine. Everything type-specific — the mesh, where it flies to, what
// happens when it arrives — is injected via PickupFlightOptions; the
// timings/distances below are deliberately NOT configurable, because "the
// exp drop should look like the resource drop" is the whole point.
//
// One instance per pickup type. Each registers its own onUpdate delegate,
// lazily, on the first spawn.
const SCATTER_MIN_DISTANCE = 0.5;
const SCATTER_MAX_DISTANCE = 1.2;
const SCATTER_ARC_HEIGHT = 0.6;
const FLY_DURATION = 0.45; // seconds to reach the scattered spot
// Two bounces off the ground, decreasing height/duration, index 0 first.
const BOUNCE_HEIGHTS = [0.4, 0.22];
const BOUNCE_DURATIONS = [0.26, 0.2];
// On touchdown of the last bounce: shrink ramps in and HOLDS (does not ease
// back to full size), uniform (not squash/widen — that read as growing).
// Flash fades on its own, longer, decoupled timeline so tuning one doesn't
// drag the other.
const SHRINK_RAMP_DURATION = 0.22;
const FLASH_DURATION = 0.45;
// Must stay >= FLASH_DURATION or the flash gets cut off mid-fade.
const IMPACT_HOLD_DURATION = 0.55;
// Fraction shrunk on impact, held once reached.
const PICKUP_SHRINK_AMOUNT = 0.15;
// Scale a pickup settles at after that impact shrink. Exported because callers
// that fly a matching token OUTSIDE this state machine (see PayZoneC's deposit
// flights, shared by every resource type that has its own Pay Zone) have to
// start from the same settled scale or it reads as a different, larger object.
export const PICKUP_SETTLED_SCALE = 1 - PICKUP_SHRINK_AMOUNT;
const TO_TARGET_DURATION = 0.5;
const TO_TARGET_ARC_HEIGHT = 1;
// Fraction of the toTarget flight (0..1) where the arrival brighten (see
// update()) starts ramping in.
const CONVERGE_START_FRACTION = 0.7;
// Extra hold time added per-pickup in a burst so they don't all launch
// toward the target in the same instant and read as one clump.
const STAGGER_INTERVAL = 0.12;
export interface PickupFlightOptions {
// A fresh visual per spawned pickup — cloned template, primitive mesh,
// whatever the caller wants. Called once per pickup.
createMesh: () => Object3D;
// Where the pickup flies to after it has settled, re-sampled every frame
// during the flight (both the HUD anchor and the player keep moving under
// a following camera, so a snapshot taken at launch would miss). Returning
// null collects the pickup on the spot instead of flying it anywhere.
getTarget: () => Vector3 | null;
// The pickup reached its target (or there was none) — count it, level up,
// whatever. The mesh is torn down by this driver either way.
onCollected: () => void;
// Optional idle spin around Y, radians/second. The driver only ever writes
// position and (uniform) scale, so a caller-owned rotation is safe.
spinSpeed?: number;
}
function randomInt(min: number, max: number): number {
return min + Math.floor(Math.random() * (max - min + 1));
}
// Collected up front, before anything mutates the tree, so adding overlay
// children mid-traversal can't make traverse() walk into overlays it just
// added.
function buildFlashOverlays(root: Object3D): Mesh[] {
const meshes: Mesh[] = [];
root.traverse((child) => {
if (child instanceof Mesh) meshes.push(child);
});
return meshes.map((mesh) => {
const overlay = mesh.clone();
// clone() copies the mesh's own local transform (relative to ITS
// parent) — parenting one level deeper under the mesh itself would
// reinterpret those numbers in the mesh's local space and displace the
// overlay instead of exactly overlaying it. Reset to identity instead.
overlay.position.set(0, 0, 0);
overlay.rotation.set(0, 0, 0);
overlay.scale.set(1, 1, 1);
overlay.material = createFlashOverlayMaterial();
overlay.castShadow = false;
overlay.receiveShadow = false;
mesh.add(overlay);
return overlay;
});
}
type Phase = "scatter" | "bounce" | "impactHold" | "toTarget";
interface FlyingPickup {
mesh: Object3D;
flashOverlays: Mesh[];
phase: Phase;
age: number;
from: Vector3;
to: Vector3;
groundY: number; // the y level the two bounces oscillate around
bounceIndex: number; // which of BOUNCE_HEIGHTS/BOUNCE_DURATIONS is playing
staggerDelay: number; // extra hold time before launching to the target — see STAGGER_INTERVAL
baseScale: number; // scale.x when toTarget starts — see the perspective compensation there
startDistanceFromCamera: number; // camera distance when toTarget starts — see toTarget phase
}
export class PickupFlight {
private flying: FlyingPickup[] = [];
private updating = false;
constructor(private readonly options: PickupFlightOptions) {}
spawnBurst(origin: Vector3, count: number) {
this.ensureUpdating();
for (let i = 0; i < count; i++) this.spawnOne(origin, i * STAGGER_INTERVAL);
}
spawnBurstInRange(origin: Vector3, min: number, max: number) {
this.spawnBurst(origin, randomInt(min, max));
}
private spawnOne(origin: Vector3, staggerDelay: number) {
const angle = Math.random() * Math.PI * 2;
const distance = SCATTER_MIN_DISTANCE + Math.random() * (SCATTER_MAX_DISTANCE - SCATTER_MIN_DISTANCE);
const target = origin
.clone()
.add(new Vector3(Math.cos(angle) * distance, 0, Math.sin(angle) * distance));
const mesh = this.options.createMesh();
mesh.position.copy(origin);
ThreeC.addToScene(mesh);
this.flying.push({
mesh,
flashOverlays: buildFlashOverlays(mesh),
phase: "scatter",
age: 0,
from: origin.clone(),
to: target,
groundY: 0,
bounceIndex: 0,
staggerDelay,
baseScale: 1,
startDistanceFromCamera: 1,
});
}
private ensureUpdating() {
if (this.updating) return;
this.updating = true;
UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta));
}
private update(delta: number) {
const spinSpeed = this.options.spinSpeed ?? 0;
for (let i = this.flying.length - 1; i >= 0; i--) {
const pickup = this.flying[i];
pickup.age += delta;
if (spinSpeed !== 0) pickup.mesh.rotation.y += spinSpeed * delta;
if (pickup.phase === "scatter") {
if (pickup.age <= FLY_DURATION) {
const t = pickup.age / FLY_DURATION;
const eased = 1 - (1 - t) * (1 - t); // easeOutQuad outward
pickup.mesh.position.lerpVectors(pickup.from, pickup.to, eased);
// Up-then-down arc on top of the outward lerp, so it "pops"
// rather than sliding flat along the ground.
pickup.mesh.position.y = pickup.from.y + Math.sin(t * Math.PI) * SCATTER_ARC_HEIGHT;
continue;
}
pickup.phase = "bounce";
pickup.age = 0;
pickup.bounceIndex = 0;
pickup.groundY = pickup.to.y;
continue;
}
if (pickup.phase === "bounce") {
const duration = BOUNCE_DURATIONS[pickup.bounceIndex];
const height = BOUNCE_HEIGHTS[pickup.bounceIndex];
if (pickup.age <= duration) {
const t = pickup.age / duration;
pickup.mesh.position.y = pickup.groundY + Math.sin(t * Math.PI) * height;
continue;
}
pickup.mesh.position.y = pickup.groundY;
if (pickup.bounceIndex < BOUNCE_HEIGHTS.length - 1) {
pickup.bounceIndex++;
pickup.age = 0;
continue;
}
// Last bounce just touched down — flash/sparkle, once.
SparkleFxC.spawnBurst(pickup.mesh.getWorldPosition(new Vector3()));
pickup.phase = "impactHold";
pickup.age = 0;
continue;
}
if (pickup.phase === "impactHold") {
const shrinkT = Math.min(pickup.age / SHRINK_RAMP_DURATION, 1);
const shrink = shrinkT * shrinkT * (3 - 2 * shrinkT) * PICKUP_SHRINK_AMOUNT; // ease in, then hold
pickup.mesh.scale.setScalar(1 - shrink);
const flashT = Math.min(pickup.age / FLASH_DURATION, 1);
const flashOpacity = 1 - flashT;
for (const overlay of pickup.flashOverlays) {
(overlay.material as MeshBasicMaterial).opacity = flashOpacity;
}
// staggerDelay only extends this wait, per pickup — the
// shrink/flash above are already fully settled by
// IMPACT_HOLD_DURATION, so this only delays the launch.
if (pickup.age < IMPACT_HOLD_DURATION + pickup.staggerDelay) continue;
for (const overlay of pickup.flashOverlays) {
(overlay.material as MeshBasicMaterial).opacity = 0;
}
const target = this.options.getTarget();
if (!target) {
this.collect(pickup);
this.flying.splice(i, 1);
continue;
}
pickup.phase = "toTarget";
pickup.age = 0;
pickup.from = pickup.mesh.position.clone();
pickup.baseScale = pickup.mesh.scale.x;
pickup.startDistanceFromCamera = CameraC_internal.getCamera().position.distanceTo(pickup.from);
continue;
}
// phase === "toTarget"
// Re-sampled every frame, not snapshotted at launch: the target can
// keep moving during the flight (camera follow, or a target that IS the
// player), so a stale one-time snapshot would miss it.
const target = this.options.getTarget();
if (!target) {
this.collect(pickup);
this.flying.splice(i, 1);
continue;
}
const t = Math.min(pickup.age / TO_TARGET_DURATION, 1);
const eased = t * t * (3 - 2 * t); // smoothstep — eases in and out
// An accelerating (ease-in) curve was tried here and reverted — it
// desyncs spatial progress from the raw elapsed time
// CONVERGE_START_FRACTION uses below, making the pickup vanish well
// short of the target.
pickup.to = target;
pickup.mesh.position.lerpVectors(pickup.from, pickup.to, eased);
pickup.mesh.position.y += Math.sin(t * Math.PI) * TO_TARGET_ARC_HEIGHT * (1 - t);
// The target usually sits much closer to the camera than where pickups
// spawn, so without compensation it would visibly grow as it
// approaches (perspective foreshortening, not a real scale change) —
// see perspectiveCompensationScale.
const cameraPosition = CameraC_internal.getCamera().position;
const perspectiveScale = perspectiveCompensationScale(
cameraPosition,
pickup.mesh.position,
pickup.startDistanceFromCamera
);
pickup.mesh.scale.setScalar(pickup.baseScale * perspectiveScale);
// Brightens over the last stretch, reading as absorbed into the target
// rather than popping out mid-air. Reuses the same flashOverlays the
// bounce-impact flash used, idle since then.
const convergeT = Math.max(0, (t - CONVERGE_START_FRACTION) / (1 - CONVERGE_START_FRACTION));
for (const overlay of pickup.flashOverlays) {
(overlay.material as MeshBasicMaterial).opacity = convergeT;
}
if (t >= 1) {
this.collect(pickup);
this.flying.splice(i, 1);
}
}
}
private collect(pickup: FlyingPickup) {
this.options.onCollected();
ThreeC.removeFromScene(pickup.mesh);
}
}