Add health bars, WeaponHudC/ZombieInvasionHudC, redesign joystick, and clean up shared logic

- New HP-driven health bars for breakable crates (blue/white/black fill,
  floating damage numbers, world-tracked DOM overlay)
- WeaponHudC and ZombieInvasionHudC HUD widgets, PassionOne-Black font,
  InstallBanner CSS restyle
- Reworked PayZoneC's deposit flight to launch from the player's head with
  a lob arc into the Pay Zone's real center, instead of the HUD anchor
- Redesigned the joystick visual (gradient ring, force-gated highlight
  that tracks the thumb, tuned sizing) and the follow camera framing
- Extracted duplicated perspective-scale, screen<->world projection,
  tween-chain, and flash-overlay logic into src/utils/
- Removed dead code (PhysicsObjPair, PayZoneC.getTargetWorldPosition) and
  stray console.log calls, trimmed narrative comments across controllers

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Oleksandr Vlasiuk
2026-08-13 18:49:04 +03:00
parent 63609eea2c
commit eddcfe1af5
29 changed files with 1354 additions and 484 deletions
+5 -5
View File
@@ -49,19 +49,19 @@ export const installBanner: ConfigUiParamsCategories[] = [
id: "install_button_x_p",
name: "Install button portrait X (%)",
type: "int",
values: [0, 100, 100],
values: [0,100, 90],
},
{
id: "install_button_y_p",
name: "Install button portrait Y (%)",
type: "int",
values: [0, 100, 100],
values: [0, 100, 97.3],
},
{
id: "install_button_sc_p",
name: "Install button portrait scale (%)",
type: "float",
values: [0.5, 2, 1],
values: [0.5, 2, 0.83],
},
{
id: "text_top_offset_y_p",
@@ -86,13 +86,13 @@ export const installBanner: ConfigUiParamsCategories[] = [
id: "install_b_icon_x_p",
name: "Icon Banner portrait X (%)",
type: "int",
values: [0, 100, 0],
values: [0, 100, 1.4],
},
{
id: "install_b_icon_y_p",
name: "Icon Banner portrait Y (%)",
type: "int",
values: [0, 100, 100],
values: [0, 100, 97.7],
},
{
id: "install_b_icon_sc_p",
+73 -53
View File
@@ -1,9 +1,12 @@
import { AdditiveBlending, Color, Euler, Mesh, MeshBasicMaterial, Object3D, Vector3 } from "three";
import { 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 { chainAndRegisterTweens } from "../utils/tweenChain";
import { createFlashOverlayMaterial } from "../utils/flashOverlayMaterial";
// A "Lootable" crate in the map glb is authored as:
// Wooden_Box_XXX (root)
@@ -30,7 +33,21 @@ export interface BreakableProp {
baseRotation: Euler;
stages: StageVisual[];
stageIndex: number;
hitsOnStage: number;
// HP is the authoritative damage/destroy state — stageIndex above is a
// VISUAL derived from the current HP fraction (see hit() below), not its
// own separate hit-counter.
currentHp: number;
// 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;
// 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
// this particular instance's stage array actually starts at. Together
// these let hit() map "remaining HP fraction" back onto this crate's own
// (possibly truncated) stages array.
totalPossibleStages: number;
startStageNumber: number;
physicsBody: PhysicsBody;
hitTween: Tween<{ t: number }> | null;
}
@@ -40,9 +57,11 @@ const COLLIDER_NAME_PATTERN = /collider/i;
const STATES_GROUP_PATTERN = /_states/i;
const SHADOW_MESH_PATTERN = /shadow/i;
// Two hits to knock a crate from one damage stage to the next (or to
// destroy it, on its last stage).
const HITS_PER_STAGE = 2;
// Exported so HealthBarC can compute the same 0..1 fraction for its bar
// fills without duplicating this number — the fresh/whole-crate reference,
// not a per-instance value (see BreakableProp.maxHp for why those differ).
export const MAX_HP = 100;
const HIT_DAMAGE = 10;
// A hit that only advances a damage stage drops a small trickle; the hit
// that finally destroys the crate dumps out most of its loot at once.
@@ -53,8 +72,9 @@ const DESTROY_RESOURCE_RANGE: [number, number] = [2, 5];
// wherever the hit came from, springing back to its resting angle.
const HIT_FLASH_UP_MS = 90;
const HIT_FLASH_DOWN_MS = 150;
// Caps the peak opacity the flash-in tween reaches (below full white).
const HIT_FLASH_MAX_OPACITY = 0.6;
const HIT_TILT_ANGLE = -0.16; // radians, ~7°
const FLASH_COLOR = new Color(0xffffff);
// Where the spark burst spawns, relative to the crate's own pivot: nudged
// toward the attacker (opposite hitDirection) so it sits near the surface
@@ -97,36 +117,44 @@ export class BreakablePropC {
return this.byColliderNode.get(node);
}
// HITS_PER_STAGE hits advance exactly one stage; past the last stage that
// exists for this crate, the hit that would've been one more advance
// fully destroys it instead. Every hit plays feedback; a stage advance
// (or the destroying hit) also drops resources — more on that final hit.
// `hitDirection` (attacker -> target, XZ, normalized) drives which way
// the crate leans on a non-destroying hit.
// Every hit takes HIT_DAMAGE 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) {
prop.hitsOnStage++;
if (prop.hitsOnStage < HITS_PER_STAGE) {
this.playHitFeedback(prop, hitDirection);
return;
}
prop.hitsOnStage = 0;
const previousHp = prop.currentHp;
prop.currentHp = Math.max(0, prop.currentHp - HIT_DAMAGE);
HealthBarC.onHit(prop, previousHp, prop.currentHp, HIT_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.
const dropOrigin = prop.root.getWorldPosition(new Vector3()).add(new Vector3(0, 0.5, 0));
const nextIndex = prop.stageIndex + 1;
if (nextIndex < prop.stages.length) {
prop.stages[prop.stageIndex].node.visible = false;
prop.stages[nextIndex].node.visible = true;
prop.stageIndex = nextIndex;
ResourceC.spawnBurstInRange(dropOrigin, ...STAGE_HIT_RESOURCE_RANGE);
this.playHitFeedback(prop, hitDirection);
if (prop.currentHp <= 0) {
ResourceC.spawnBurstInRange(dropOrigin, ...DESTROY_RESOURCE_RANGE);
this.destroy(prop, hitDirection);
return;
}
ResourceC.spawnBurstInRange(dropOrigin, ...DESTROY_RESOURCE_RANGE);
this.destroy(prop, hitDirection);
// How many of the GLOBAL damage stages (see startStageNumber/
// totalPossibleStages on the interface) this crate's remaining HP
// fraction has used up, translated back into an index into THIS
// crate's own (possibly shorter, pre-damaged-start) stages array.
const globalStagesUsed = Math.floor((1 - prop.currentHp / prop.maxHp) * prop.totalPossibleStages);
const targetStageIndex = Math.min(
Math.max(globalStagesUsed - (prop.startStageNumber - 1), 0),
prop.stages.length - 1
);
if (targetStageIndex > prop.stageIndex) {
prop.stages[prop.stageIndex].node.visible = false;
prop.stages[targetStageIndex].node.visible = true;
prop.stageIndex = targetStageIndex;
ResourceC.spawnBurstInRange(dropOrigin, ...STAGE_HIT_RESOURCE_RANGE);
}
this.playHitFeedback(prop, hitDirection);
}
private static buildProp(root: Object3D): BreakableProp | null {
@@ -152,25 +180,29 @@ export class BreakablePropC {
PhysicsLayer.Player | PhysicsLayer.Enemy
);
// Derived from the REAL "_SN" numbers baked into this crate's own
// stage node names, not guessed — a crate missing its early stages
// (pre-damaged/pre-broken level-design variety, see the file header)
// starts partway through the global HP range instead of always at 100.
const startStageNumber = this.stageNumber(stageNodes[0]);
const totalPossibleStages = this.stageNumber(stageNodes[stageNodes.length - 1]);
const currentHp = MAX_HP - (MAX_HP * (startStageNumber - 1)) / totalPossibleStages;
return {
root,
colliderNode,
baseRotation: root.rotation.clone(),
stages,
stageIndex: 0,
hitsOnStage: 0,
currentHp,
maxHp: MAX_HP,
totalPossibleStages,
startStageNumber,
physicsBody,
hitTween: null,
};
}
// The crate mesh uses an unlit MeshBasicMaterial with color already at
// pure white (confirmed by logging it — the texture supplies all the
// actual color, the tint is just left at its default) — lerping that
// color toward FLASH_COLOR (also white) is a no-op, which is why the
// flash was invisible despite the tween running. Instead, a same-geometry
// clone is layered on top with additive blending: animating ITS opacity
// reads as a flash regardless of what color/texture the base mesh has.
private static buildStageVisual(node: Object3D): StageVisual {
const mesh = node.children.find(
(child): child is Mesh => child instanceof Mesh && !SHADOW_MESH_PATTERN.test(child.name)
@@ -178,13 +210,7 @@ export class BreakablePropC {
if (!mesh) return { node, mesh: null, flashMesh: null };
const flashMesh = mesh.clone();
flashMesh.material = new MeshBasicMaterial({
color: FLASH_COLOR,
transparent: true,
opacity: 0,
blending: AdditiveBlending,
depthWrite: false,
});
flashMesh.material = createFlashOverlayMaterial();
// Shadows off — this is a same-shape overlay that's invisible almost
// all the time, not a real surface; TestSceneC's later
// setShadowsStateForChildren(map, true, true) would otherwise flip
@@ -235,7 +261,7 @@ export class BreakablePropC {
const tiltZ = hitDirection.x * HIT_TILT_ANGLE;
const apply = ({ t }: { t: number }) => {
if (flashMaterial) flashMaterial.opacity = t;
if (flashMaterial) flashMaterial.opacity = t * HIT_FLASH_MAX_OPACITY;
prop.root.rotation.set(
prop.baseRotation.x + tiltX * t,
prop.baseRotation.y,
@@ -245,16 +271,9 @@ export class BreakablePropC {
const flashIn = new Tween({ t: 0 }).to({ t: 1 }, HIT_FLASH_UP_MS).easing(Easing.Quadratic.Out).onUpdate(apply);
const flashOut = new Tween({ t: 1 }).to({ t: 0 }, HIT_FLASH_DOWN_MS).easing(Easing.Quadratic.In).onUpdate(apply);
// chain() only tells flashIn to call flashOut.start() on completion —
// it does NOT add flashOut to any group. Without adding it here too,
// flashOut marks itself "playing" but nothing ever calls its update(),
// so it just freezes at t=1 (fully flashed/tilted) forever.
flashIn.chain(flashOut);
TweenC.add(flashIn);
TweenC.add(flashOut);
flashIn.start();
prop.hitTween = flashIn;
const chain = chainAndRegisterTweens(flashIn, flashOut);
chain.start();
prop.hitTween = chain;
}
private static destroy(prop: BreakableProp, hitDirection: Vector3) {
@@ -262,6 +281,7 @@ export class BreakablePropC {
this.byColliderNode.delete(prop.colliderNode);
this.onDestroyed?.(prop.colliderNode);
prop.hitTween?.stop();
HealthBarC.onDestroyed(prop);
PropVfxC.spawnDestroy(prop.root.getWorldPosition(new Vector3()));
this.playDisappearAnimation(prop, hitDirection);
}
+19 -31
View File
@@ -10,25 +10,16 @@ const POSITION_DAMPING = 5;
// How far (world units) the camera's target position pans in the direction
// the character is actually TRANSLATING — a fixed-length nudge, not scaled
// by speed, so it's the full offset the instant they're moving at all and
// zero the instant they stop. Deliberately derived from position deltas,
// not facing/quaternion: that was the earlier version, and even damped, a
// vector tied to facing rotates right along with a stationary turn (e.g.
// pivoting to face a crate) and drags the camera along an arc. A vector
// tied to actual movement can't do that — turning in place is zero
// movement, so it's zero look-ahead, however the character is turning.
//
// Bigger than the old facing-based version's 1.2 — that version's visible
// "shift" came from two effects stacked together (the position nudge AND
// camera.lookAt() re-aiming toward the shifted point every frame); with
// rotation now completely fixed, only the position nudge is left, so it
// has to move further on its own to read as a comparable shift from a
// camera sitting ~12.7 units away (the (0,9,-9) rig offset).
const LOOK_AHEAD_DISTANCE = 1.5;
// zero the instant they stop. Derived from position deltas, not
// facing/quaternion: a vector tied to facing rotates right along with a
// stationary turn (e.g. pivoting to face a crate) and drags the camera
// along an arc. A vector tied to actual movement can't do that — turning
// in place is zero movement, so it's zero look-ahead.
const LOOK_AHEAD_DISTANCE = 0.9;
// Smooths transitions in and out of the look-ahead pan (starting/stopping)
// so it doesn't snap. Separate from, and slower than, the position damping
// below — see update(). Slower than before (was 2) per feedback: the ease
// itself needed to read more gradual.
// below — see update().
const LOOK_AHEAD_DAMPING = 1.2;
// Below this much squared movement in a single frame, treat the character
@@ -55,13 +46,11 @@ export class CameraFollowC {
this.smoothedLookAhead.set(0, 0, 0);
// The camera's angle is set once, here, from the actual offset geometry
// — not left at whatever camera_rotation_p/_l happens to hold, since
// that config value was never actually tuned to work standalone (the
// old per-frame lookAt() always overwrote it immediately). update()
// below never touches rotation again after this — camera.position is
// the only thing it ever moves, in a straight line toward a single
// target point (see chat: no facing-based pivoting, just two points
// and a line between them).
// — not left at whatever camera_rotation_p/_l holds, since that config
// value isn't tuned to work standalone. update() below never touches
// rotation again after this — camera.position is the only thing it
// ever moves, in a straight line toward a single target point (no
// facing-based pivoting).
const camera = CameraC_internal.getCamera();
camera.position.copy(target.position).add(this.offset);
camera.lookAt(target.position);
@@ -72,10 +61,10 @@ export class CameraFollowC {
// Lets the look-ahead react to the character locking onto a nearby
// breakable while stopped (see PlayerC.getFacingTarget) — that rotation
// carries zero movement, so it would otherwise be invisible to the
// movement-only look-ahead below. Deliberately fed a fixed world POINT,
// not the character's facing/quaternion: a point doesn't rotate as the
// body turns to face it, so easing toward it is a single linear shift,
// not the arc/orbit a facing-tied vector produced (see chat).
// movement-only look-ahead below. Fed a fixed world POINT, not the
// character's facing/quaternion: a point doesn't rotate as the body
// turns to face it, so easing toward it is a single linear shift, not
// the arc/orbit a facing-tied vector would produce.
static setFacingTargetGetter(getter: () => Vector3 | null) {
this.facingTargetGetter = getter;
}
@@ -105,10 +94,9 @@ export class CameraFollowC {
} else if (movement.lengthSq() > MOVEMENT_EPSILON_SQ) {
// Only updates the held direction while actually translating — when
// the character stops (and isn't locked onto a target either), this
// is simply skipped, so the pan holds at wherever it last settled
// instead of easing back to center (see chat: "камера після зсуву не
// має повертатись назад... тільки якщо він змінить напрям"). It only
// moves again once movement resumes in a different direction.
// is skipped, so the pan holds at wherever it last settled instead of
// easing back to center. It only moves again once movement resumes
// in a different direction.
this.desiredLookAhead.copy(movement).normalize().multiplyScalar(LOOK_AHEAD_DISTANCE);
}
+204
View File
@@ -0,0 +1,204 @@
import { Box3, 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";
// 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.
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 animation
// duration, so the popup element is removed right as the CSS animation
// finishes, not before/after.
const DAMAGE_POPUP_LIFETIME_MS = 920;
// Random per-popup punch-rotation range (degrees) — see spawnDamagePopup.
// Always clockwise from the 12 o'clock resting angle (0deg), never past the
// 2 o'clock mark (2 * 30deg/hour = 60deg).
const DAMAGE_ROTATE_MIN_DEG = 10;
const DAMAGE_ROTATE_MAX_DEG = 50;
interface BarInstance {
prop: BreakableProp;
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.
worldTop: Vector3;
idleTime: number;
hiding: boolean;
removeTimeout: 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
// src/css/health-bar.css) — only the per-frame world->screen repositioning
// and the hit-triggered width changes are JS.
export class HealthBarC {
private static initialized = false;
private static root: HTMLElement;
private static bars = new Map<BreakableProp, BarInstance>();
static init() {
if (this.initialized) return;
this.initialized = true;
this.root = document.getElementById("ui-safe-area") ?? document.body;
UpdateController.Instance.onUpdate.addDelegate((delta: number) => this.update(delta));
}
// 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);
bar.idleTime = 0;
this.cancelHide(bar);
const previousFraction = (previousHp / prop.maxHp) * 100;
const currentFraction = (currentHp / prop.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
bar.whiteEl.style.transitionDuration = "";
bar.whiteEl.style.transitionDelay = "";
bar.whiteEl.style.width = `${currentFraction}%`;
bar.blueEl.style.width = `${currentFraction}%`;
this.spawnDamagePopup(bar, damage);
}
static onDestroyed(prop: BreakableProp) {
const bar = this.bars.get(prop);
if (!bar) return;
this.removeBar(bar);
}
private static update(delta: number) {
if (this.bars.size === 0) return;
const camera = CameraC_internal.getCamera();
const canvasRect = ThreeC.renderer.domElement.getBoundingClientRect();
for (const bar of this.bars.values()) {
this.positionBar(bar, camera, canvasRect);
if (bar.hiding) continue;
bar.idleTime += delta;
if (bar.idleTime >= HIDE_DELAY_S) this.startHide(bar);
}
}
private static positionBar(bar: BarInstance, camera: Camera, canvasRect: DOMRect) {
const { x, y } = projectWorldPointToScreen(camera, canvasRect, bar.worldTop);
bar.container.style.left = `${x}px`;
bar.container.style.top = `${y}px`;
}
private static getOrCreateBar(prop: BreakableProp): BarInstance {
const existing = this.bars.get(prop);
if (existing) return existing;
const container = document.createElement("div");
container.className = "health-bar";
const border = document.createElement("div");
border.className = "health-bar__border";
const track = document.createElement("div");
track.className = "health-bar__track";
const white = document.createElement("div");
white.className = "health-bar__white";
const blue = document.createElement("div");
blue.className = "health-bar__blue";
track.appendChild(white);
track.appendChild(blue);
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,
container,
whiteEl: white,
blueEl: blue,
worldTop,
idleTime: 0,
hiding: false,
removeTimeout: null,
};
this.bars.set(prop, bar);
return bar;
}
private static cancelHide(bar: BarInstance) {
if (bar.removeTimeout !== null) {
clearTimeout(bar.removeTimeout);
bar.removeTimeout = null;
}
if (bar.hiding) {
bar.hiding = false;
bar.container.classList.remove("is-hiding");
}
}
private static startHide(bar: BarInstance) {
bar.hiding = true;
bar.container.classList.add("is-hiding");
bar.removeTimeout = setTimeout(() => this.removeBar(bar), FADE_OUT_MS);
}
private static removeBar(bar: BarInstance) {
if (bar.removeTimeout !== null) clearTimeout(bar.removeTimeout);
bar.container.remove();
this.bars.delete(bar.prop);
}
private static spawnDamagePopup(bar: BarInstance, damage: number) {
const popup = document.createElement("div");
popup.className = "health-bar__damage";
popup.textContent = `-${damage}`;
// Random per-popup punch angle so every hit reads differently — the
// CSS keyframes just read var(--damage-rotate). Always clockwise,
// never negative/left.
const rotateDeg = DAMAGE_ROTATE_MIN_DEG + Math.random() * (DAMAGE_ROTATE_MAX_DEG - DAMAGE_ROTATE_MIN_DEG);
popup.style.setProperty("--damage-rotate", `${rotateDeg}deg`);
bar.container.appendChild(popup);
setTimeout(() => popup.remove(), DAMAGE_POPUP_LIFETIME_MS);
}
}
+14 -24
View File
@@ -2,6 +2,7 @@ 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";
// How far in front of the camera (world units, along the screen ray) to
// place the projected anchor point — getWorldAnchorPosition() below finds
@@ -30,14 +31,11 @@ export class HudC {
UpdateController.Instance.onUpdate.addDelegate(() => this.update());
}
// Where collected resources fly to, and where PayZoneC's deposit flight
// into the Pay Zone originates from — the plate's own right edge,
// projected into 3D via the real DOM position (see chat: "дістати
// позицію правого края юайки"), not a hand-picked local offset off the
// camera like before. Recomputed from scratch on every call rather than
// cached: it's only called once per resource-fly event (not per frame),
// cheap enough, and this way it stays correct through window
// resize/orientation changes for free.
// Where collected resources fly to — the plate's own right edge,
// projected into 3D via its real DOM position, not a hand-picked local
// offset off the camera. Recomputed on every call rather than cached:
// only called once per resource-fly event (not per frame), and this way
// it stays correct through resize/orientation changes for free.
static getWorldAnchorPosition(): Vector3 {
const canvasRect = ThreeC.renderer.domElement.getBoundingClientRect();
const plateRect = this.plateEl.getBoundingClientRect();
@@ -45,24 +43,16 @@ export class HudC {
const screenX = plateRect.right;
const screenY = plateRect.top + plateRect.height / 2;
// NDC relative to the actual canvas box, not window.innerWidth/Height —
// the SDK letterboxes/centers the canvas into a fixed 9:16 area (see
// #ui/#editor in main.css), so the canvas's own bounding rect is the
// only thing guaranteed to line up with the camera's projection.
const ndcX = ((screenX - canvasRect.left) / canvasRect.width) * 2 - 1;
const ndcY = -((screenY - canvasRect.top) / canvasRect.height) * 2 + 1;
const camera = CameraC_internal.getCamera();
const point = new Vector3(ndcX, ndcY, 0.5).unproject(camera);
const direction = point.sub(camera.position).normalize();
return camera.position.clone().addScaledVector(direction, ANCHOR_DISTANCE);
const { origin, direction } = projectScreenPointToWorldRay(camera, canvasRect, screenX, screenY);
return origin.addScaledVector(direction, ANCHOR_DISTANCE);
}
// The displayed number is a live balance, not a lifetime total — it
// should rise as resources are collected and fall as they're delivered
// into the Pay Zone (see chat). Injected rather than importing
// ResourceC/PayZoneC directly, since PayZoneC already imports HudC (for
// getWorldAnchorPosition) and importing back would cycle.
// The displayed number is a live balance, not a lifetime total — rises as
// resources are collected, falls as they're delivered into the Pay Zone.
// Injected rather than importing ResourceC/PayZoneC directly, since
// PayZoneC already imports HudC (for getWorldAnchorPosition) and
// importing back would cycle.
static setBalanceGetter(getBalance: () => number) {
this.balanceGetter = getBalance;
}
@@ -92,7 +82,7 @@ export class HudC {
plate.appendChild(count);
holder.appendChild(plate);
const uiRoot = document.getElementById("ui") ?? document.body;
const uiRoot = document.getElementById("ui-safe-area") ?? document.body;
uiRoot.appendChild(holder);
this.plateEl = plate;
+68 -94
View File
@@ -3,58 +3,61 @@ import { CameraC_internal, TweenC, UpdateController } from "@hitplay/playable_te
import { Easing, Tween } from "@tweenjs/tween.js";
import { ThreeC } from "./ThreeC";
import { RESOURCE_ICON_BASE_SCALE, ResourceC } from "./ResourceC";
import { HudC } from "./HudC";
import { PlayerC } from "./PlayerC";
import { perspectiveCompensationScale } from "../utils/perspectiveScale";
// How many resources need to actually make it into the zone before it's
// done and disappears — what happens after that is intentionally out of
// scope for now (see chat).
// How many resources need to actually make it into the zone before it
// disappears.
const RESOURCES_TO_CLOSE_ZONE = 20;
// While standing in the zone with resources still to deliver, one flies in
// every DEPOSIT_INTERVAL seconds — a steady drain rather than a single
// instant dump.
// every DEPOSIT_INTERVAL seconds — a steady drain rather than an instant dump.
const DEPOSIT_INTERVAL = 0.3;
const DEPOSIT_FLIGHT_DURATION_MS = 500;
// Redundant safety-net cleanup delay past the tween's own duration — see
// the note in playDepositFlight on why this exists.
// Redundant safety-net cleanup: the deposit tween occasionally never fires
// onComplete and freezes mid-flight — cause not pinned down (not the usual
// chain()-registration gotcha, this tween isn't chained). This plain
// timeout removes the mesh regardless. Calling removeFromScene twice (if
// onComplete also fires) is harmless — the second call just finds no
// parent to detach from.
const DEPOSIT_FLIGHT_CLEANUP_MARGIN_MS = 300;
const PAY_ZONE_SHRINK_MS = 400;
// Extra multiplier on top of RESOURCE_ICON_BASE_SCALE (see ResourceC), just
// for this deposit flight — tune this, not the shared constant, if only
// the fly-to-Pay-Zone token needs to look smaller/bigger (see chat: "де я
// можу зменшити розмір ресурсу під час анімації заповнення пей зони?").
const DEPOSIT_FLIGHT_SCALE_MULTIPLIER = 0.3;
// Extra multiplier on top of RESOURCE_ICON_BASE_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
// needs to look bigger/smaller.
const DEPOSIT_FLIGHT_SCALE_MULTIPLIER = 1.3;
// Fill overlay: a flat quad laid over the zone's own footprint, growing
// from its near edge (fixed, closest to the camera) toward the far edge as
// deposits come in — a ground-level wipe, not a rising column (see chat:
// two earlier attempts — a horizontal X wipe read as "right to left", then
// a rising box read as "water filling a pool" — both wrong; this reads as
// "bottom to top" on screen because +Z, this game's forward/"away from
// camera" direction, projects toward the top of the screen under this
// isometric-ish camera).
// How high above the straight line to the Pay Zone the flight lobs, on top
// of the ordinary from->to lerp.
const DEPOSIT_FLIGHT_ARC_HEIGHT = 2;
// Fill overlay: a flat quad over the zone's own footprint, growing from its
// near edge (fixed, closest to camera) toward the far edge as deposits come
// in. Grows along world Z, not X or Y — under this camera, +Z (forward/
// away-from-camera) projects toward the top of the screen, so a Z-growing
// quad reads as "filling bottom to top."
const FILL_COLOR = 0x5ce65c;
const FILL_OPACITY = 0.55;
// Sits just above the zone's own ground-level quad so it doesn't z-fight
// with it, but still reads as "on the ground" rather than floating.
// 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 (already-visible) "UI_Interactive_Zone_02"
// marker as both the pay zone's location and its visual — see CLAUDE.md.
// ResourceC.getCollectedCount() keeps counting resources the moment
// they're picked up near whatever crate dropped them (unrelated to this
// zone); this controller separately drains that total into the zone,
// resource by resource, only while the player is standing inside it.
// 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 class PayZoneC {
private static payZoneNode: Object3D | null = null;
private static target: Object3D | null = null;
// The zone's actual world-space footprint (an XZ rectangle), computed
// once from its real geometry rather than guessed as a fixed radius — a
// hardcoded radius previously undershot the visible dashed square, so
// standing anywhere but right on its center pivot silently never
// counted as "inside".
// 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;
@@ -74,10 +77,9 @@ export class PayZoneC {
UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta));
}
// A unit quad, later scaled/positioned per-frame in updateFillVisual — a
// fixed width (full X span of the zone) but a depth (world Z) that grows
// from 0 up to the zone's full Z span as `deposited` approaches
// RESOURCES_TO_CLOSE_ZONE.
// 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 {
const mesh = new Mesh(
new PlaneGeometry(1, 1),
@@ -96,10 +98,9 @@ export class PayZoneC {
return mesh;
}
// Grows the fill quad from the zone's min-Z edge (fixed, nearest the
// camera) toward its max-Z edge as `fraction` (0..1) increases — scaling
// a centered quad also moves its center, so the Z position is re-derived
// each time to keep that near edge pinned in place instead of drifting.
// 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() {
if (!this.fillMesh || !this.zoneBounds) return;
@@ -111,20 +112,15 @@ export class PayZoneC {
this.fillMesh.position.z = this.zoneBounds.min.z + filledDepth / 2;
}
static getTargetWorldPosition(): Vector3 {
return this.payZoneNode ? this.payZoneNode.getWorldPosition(new Vector3()) : new Vector3();
}
// How many resources have actually been delivered so far — 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
// 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 {
return this.deposited;
}
// XZ containment against the zone's real footprint (ignores Y — the
// zone lies flat on the ground, height doesn't matter).
// zone lies flat on the ground).
static isPlayerInside(playerPosition: Vector3): boolean {
if (!this.zoneBounds) return false;
return (
@@ -146,55 +142,34 @@ export class PayZoneC {
this.depositCooldown = DEPOSIT_INTERVAL;
this.deposited++;
this.playDepositFlight(HudC.getWorldAnchorPosition(), this.payZoneNode);
this.playDepositFlight(PlayerC.getHeadWorldPosition());
this.updateFillVisual();
if (this.deposited >= RESOURCES_TO_CLOSE_ZONE) {
this.closed = true;
console.log(`[PayZoneC] zone full (${this.deposited} delivered) — closing`);
this.playDisappear(this.payZoneNode);
}
}
// One resource, flying from the HUD counter (see chat: "від лічильника
// до пей зони") into the zone itself — the player only needs to be
// standing in the zone to trigger it, not to be the flight's origin.
//
// Traced via temporary logging (see chat): occasionally one of these
// tweens never fires onComplete and freezes mid-flight forever — a
// visible board-textured icon just hanging in the air (user report:
// "текстура доски... постійно статично відображається"). Couldn't pin
// down why inside tween.js/TweenC (a single, non-chained tween, added to
// the group before .start() — the usual "chain() doesn't register the
// second link" bug from Day 6 doesn't apply here). Rather than keep
// digging, added a hard, redundant cleanup: a plain timeout that removes
// the mesh regardless of whether the tween ever completes. Calling
// removeFromScene twice (once from onComplete, once from the timeout, if
// both fire) is harmless — the second call just finds no parent to
// detach from.
private static playDepositFlight(from: Vector3, payZoneNode: Object3D) {
// 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) {
if (!this.zoneBounds) return;
const mesh = ResourceC.createVisual();
const to = payZoneNode.getWorldPosition(new Vector3());
// 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(
(this.zoneBounds.min.x + this.zoneBounds.max.x) / 2,
this.zoneBounds.min.y,
(this.zoneBounds.min.z + this.zoneBounds.max.z) / 2
);
mesh.position.copy(from);
ThreeC.addToScene(mesh);
// No rotation (see chat: "ротейту не має бути") — same as the
// ground-to-HUD flight in ResourceC, this is position/scale only.
//
// Same perspective-compensation trick as ResourceC's toTarget phase,
// just in reverse: this flight starts right at the HUD anchor (close
// to the camera, see HudC.getWorldAnchorPosition) and moves out to the
// Pay Zone (typically much farther away), so without this it would
// visibly *shrink* as it recedes — pure perspective, not an actual
// size change. Scaling by the ratio of current to starting
// camera-distance cancels that, keeping it the one size the whole
// flight (see chat: "розмір має бути таким самим як і в цій
// анімації"). Anchored to RESOURCE_ICON_BASE_SCALE, not the mesh's own
// default (1) — this token is a fresh ResourceC.createVisual(), not one
// of ResourceC's own bounced-and-shrunk FlyingResources, so without
// this it started noticeably larger than the resource that was just
// sitting at that same HUD icon a moment ago (see chat: "дуже великий,
// требя його уніфікувати").
// Position/scale only, no rotation — same as ResourceC's ground-to-HUD
// flight.
const cameraPosition = CameraC_internal.getCamera().position;
const startDistance = cameraPosition.distanceTo(from);
@@ -203,8 +178,11 @@ export class PayZoneC {
.easing(Easing.Quadratic.InOut)
.onUpdate(({ t }) => {
mesh.position.lerpVectors(from, to, t);
const currentDistance = cameraPosition.distanceTo(mesh.position);
const perspectiveScale = startDistance > 1e-6 ? currentDistance / startDistance : 1;
// Up-then-down lob layered on top of the straight lerp above — 0
// at both ends, peaks at the midpoint.
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);
})
.onComplete(() => ThreeC.removeFromScene(mesh));
@@ -216,11 +194,7 @@ export class PayZoneC {
}
// Shrinks the zone's own marker AND the fill overlay together, as one
// piece — the fill previously just popped invisible instantly once the
// marker's shrink finished (see chat: "прогрес заповненості не
// зменшується разом з пей зоною"). Scaling the fill quad down from
// whatever it currently is (full, since this only runs once the zone is
// done) shrinks it toward its own center in lockstep with the marker.
// piece, toward their own centers in lockstep.
private static playDisappear(node: Object3D) {
const fillMesh = this.fillMesh;
const fillBaseScaleX = fillMesh?.scale.x ?? 0;
+1 -48
View File
@@ -1,8 +1,4 @@
import {
Delegate,
Physics_internal,
UpdateController,
} from "@hitplay/playable_template";
import { Physics_internal } from "@hitplay/playable_template";
import { Box3, Mesh, Object3D, Quaternion, Vector3 } from "three";
import { Body, Box, Sphere, Vec3 } from "cannon-es";
@@ -15,7 +11,6 @@ export enum PhysicsLayer {
export class PhysicsBody {
private body: Body;
private pair: PhysicsObjPair | null = null;
constructor(
threeObj: Object3D,
@@ -53,14 +48,6 @@ export class PhysicsBody {
let worldQuat = threeObj.getWorldQuaternion(new Quaternion());
this.body.quaternion.set(worldQuat.x, worldQuat.y, worldQuat.z, worldQuat.w);
// if you need sync three obj and physics body
// if (isEnemy) {
// let pair = new PhysicsObjPair(threeObj, this.body);
// PhysicsC_Instance.addPhysicsPair(pair);
// this.pair = pair;
// }
Physics_internal.physicsWorld &&
Physics_internal.physicsWorld.addBody(this.body);
@@ -81,12 +68,6 @@ export class PhysicsBody {
return new Box3().setFromObject(threeObj).getSize(new Vector3());
}
disablePhysicsPair() {
if (this.pair) {
this.pair.destroyed = true;
}
}
getPhysicsBody() {
return this.body;
}
@@ -99,31 +80,3 @@ export class PhysicsBody {
(this.body as any) = null;
}
}
export class PhysicsObjPair {
threeObj: Object3D;
physicsObj: Body;
destroyed: boolean;
delegateId: null | Delegate<number>;
constructor(threeObj: Object3D, physicsObj: Body) {
this.threeObj = threeObj;
this.physicsObj = physicsObj;
this.destroyed = false;
this.delegateId = UpdateController.Instance.onUpdate.addDelegate(() => {
this.update();
});
}
update() {
if (this.destroyed) return;
if (this.threeObj && this.physicsObj) {
// @ts-ignore
this.threeObj.position.copy(this.physicsObj.position);
// @ts-ignore
this.threeObj.quaternion.copy(this.physicsObj.quaternion);
}
}
}
+14 -9
View File
@@ -195,9 +195,15 @@ export class PlayerC {
return this.batInHand;
}
// Where PayZoneC's deposit flight launches from — PLAYER_HEIGHT is the
// measured rig height (see its own comment above), not a guessed offset.
static getHeadWorldPosition(): Vector3 {
return this.object.position.clone().add(new Vector3(0, PLAYER_HEIGHT, 0));
}
// Exactly one of {pistol, bat} is ever in hand, and the other is stowed —
// the pistol has no separate "in hand" prop to show (see PlayerC chat
// notes), so its own single mesh just toggles at its holster spot instead.
// the pistol has no separate "in hand" prop, so its own single mesh just
// toggles at its holster spot instead.
private static equipPistol() {
this.pistol.visible = true;
this.batInHand.visible = false;
@@ -335,7 +341,7 @@ export class PlayerC {
// it while idle, rather than resetting to face forward the moment input
// stops. rotateTowards caps the step at MAX_TURN_SPEED * delta radians
// instead of interpolating a percentage of the remaining angle — except
// for a near-180° reversal, which snaps immediately (see chat).
// for a near-180° reversal, which snaps immediately.
if (this.object.quaternion.angleTo(this.facingRotation) > OPPOSITE_TURN_THRESHOLD) {
this.object.quaternion.copy(this.facingRotation);
} else {
@@ -349,7 +355,7 @@ export class PlayerC {
// breakable it's stopped next to), or null otherwise — CameraFollowC uses
// this to pan toward the same point, since it's a fixed world position
// (not a rotation), so it can't produce the arc/orbit motion a
// facing/quaternion-driven look-ahead did (see CameraFollowC chat notes).
// facing/quaternion-driven look-ahead would.
static getFacingTarget(): Vector3 | null {
return this.facingTarget;
}
@@ -423,11 +429,10 @@ export class PlayerC {
}
// Every breakable prop within interaction range AND within the frontal
// swing arc (same cone as isFacing, see chat: hitting crates standing
// behind the player didn't make sense for a forward bat swing) —
// "unlimited targets in the hit zone" per the design, just not a full
// 360° one. Non-breakable obstacles (walls) are excluded: there's
// nothing for the attack to do to them.
// swing arc (same cone as isFacing — a forward bat swing shouldn't hit
// crates standing behind the player) — unlimited targets within that
// cone, not a full 360° radius. Non-breakable obstacles (walls) are
// excluded: there's nothing for the attack to do to them.
private static findBreakableTargetsInZone(): ZoneTarget[] {
const bounds = this.getCollisionBounds(this.object.position).expandByScalar(INTERACTION_REACH);
const targets: ZoneTarget[] = [];
+11 -17
View File
@@ -5,25 +5,19 @@ import { ThreeC } from "./ThreeC";
import vfxLootableHitJson from "../resources/vfx/VFX_Lootable_Hit.json";
import vfxLootableDestroyJson from "../resources/vfx/VFX_Lootable_Destroy.json";
// Real designer-authored VFX for breakable props — found sitting unused in
// temp/ (exported from the quarks.art editor as plain Object3D.toJSON()
// groups, each a couple of ParticleEmitter children with their own tuned
// shape/speed/color/behaviors, referencing real textures like
// "novalines.webp" for the ray-streak look). Replaces the earlier
// hand-built SparkFxC, whose particles read as a soft dot-blob rather than
// the rays the reference screenshots called for — no amount of tuning a
// from-scratch system was going to match a real authored asset anyway.
// Designer-authored VFX for breakable props — quarks.art editor exports
// (plain Object3D.toJSON() groups, each a couple of ParticleEmitter
// children with their own tuned shape/speed/color/behaviors).
//
// Loaded via plain Vite JSON imports + three.quarks' own QuarksLoader
// directly, NOT the SDK's quarksLoader()/ConvertToBase64WhenRelease
// (the pattern meshes.ts/images.ts use): quarksLoader() hardcodes
// `base64String.split(",")[1]` then atob() on it, which assumes it's
// always handed an actual base64 data: URI — true only once the build-time
// AST plugin rewrites the ConvertToBase64WhenRelease() call; in plain
// `vite dev` that call is a no-op passthrough returning a bare path
// string, which has no comma and would break the split/atob chain. A
// static JSON import sidesteps that entirely — Vite inlines JSON content
// into the bundle natively in every mode (dev, build, and the single-file
// directly, NOT the SDK's quarksLoader()/ConvertToBase64WhenRelease (the
// pattern meshes.ts/images.ts use): quarksLoader() hardcodes
// `base64String.split(",")[1]` then atob() on it, assuming it's always
// handed an actual base64 data: URI — true only once the build-time AST
// plugin rewrites the ConvertToBase64WhenRelease() call; in plain
// `vite dev` that call is a no-op passthrough returning a bare path string
// with no comma, breaking the split/atob chain. A static JSON import
// sidesteps that — Vite inlines JSON natively in every mode (dev, build,
// export), no fetch, no base64 round-trip, no dev/build inconsistency.
const CLEANUP_DELAY_MS = 1500; // covers the longest emitter's duration+life in either prefab
+63 -138
View File
@@ -1,111 +1,61 @@
import {
AdditiveBlending,
BoxGeometry,
Color,
Mesh,
MeshBasicMaterial,
MeshStandardMaterial,
Object3D,
Vector3,
} from "three";
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";
// The map ships an actual wood-icon quad ("UI_Wood", textured with
// Icon_Wood) sitting unused in the scene — TestSceneC hands it in via
// setPickupTemplate() and every spawned resource is a clone of it. This
// placeholder cube only covers the case where that node is ever missing.
// Fallback only — TestSceneC normally hands in the map's real wood-icon
// quad via setPickupTemplate(), and every spawned resource clones that.
const PICKUP_SIZE = 0.28;
// Resources pop out of a hit target, scatter a short, fixed distance, land,
// bounce twice, then fly on to the HUD counter (see HudC) — the player
// never has to walk over and trigger any of this (see chat: "персонажу не
// потрібно підходити для збору"). Carrying the collected total on into the
// Pay Zone is a separate, later mechanic — see PayZoneC, which drains
// ResourceC.getCollectedCount() on its own.
// Resources pop out of a hit target, scatter a short fixed distance, land,
// 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 where it lands — decreasing height/duration
// each time, index 0 first. On the second (last) bounce's touchdown, the
// resource flashes white (like BreakablePropC's crate hit-flash) and
// shrinks while a sparkle burst plays around it (see chat: exact reference
// look). This shrink is intentional and stays (see chat: "Ти прибрав зміну
// розміру при баунсі... я тобі писав не про це" — a later, unrelated
// request to remove scaling from the toTarget *flight* got misread as
// "remove scaling everywhere" and briefly took this out too; it's back).
// The only place scale must NOT change is the toTarget flight itself —
// see that phase below, which deliberately never touches scale, so
// whatever value this phase lands on just carries through unchanged.
// 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];
// The shrink ramps in quickly (SHRINK_RAMP_DURATION) and then just holds —
// it does NOT ease back out to the original size (see chat: "стискання
// без повернення до початкового розміру"). Uniform shrink, not a
// squash-and-widen — an earlier version widened X/Z while flattening Y,
// which read as the resource *growing* (see chat: "мають навпаки
// зменшуватись"). The flash fades out on its own, longer, slower timeline
// (see chat: "нехай флеш буде довшим") — decoupled from the shrink so
// lengthening one doesn't drag the other out too.
// 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 be >= FLASH_DURATION or the flash gets cut off mid-fade before the
// resource flies off — a little slack on top so the fully-faded state
// actually reads for a beat first.
// 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
const FLASH_COLOR = new Color(0xffffff);
// The world-space scale a resource settles at after the bounce shrink and
// carries all the way to the HUD (see toTarget's perspective compensation
// below, which holds this constant in apparent/on-screen size). Exported
// so PayZoneC's deposit-flight token — a separate, fresh mesh, not one of
// these FlyingResources — can start from the same baseline instead of its
// own default (1), which read as noticeably larger (see chat: "розмір...
// дуже великий, треба уніфікувати").
// 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;
// Once settled, a resource flies on to wherever setFlyTarget() points (the
// HUD counter, see HudC). Without a target set, it's counted immediately
// instead (no flight) — see update() below.
//
// An accelerating (ease-in) version of this was tried and reverted (see
// chat: "зникають раніше часу") — CONVERGE_START_FRACTION triggers the
// shrink-to-nothing off raw elapsed time `t`, not spatial progress, and
// ease-in makes spatial progress lag well behind `t` (at t=0.7 an eased-in
// t² curve has only covered half the distance), so the resource visibly
// vanished while still far short of the target. Smoothstep keeps those two
// closely matched enough that convergence reads as "arriving", not
// "disappearing early".
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 — 0.7 means the last 30% of the flight.
// update()) starts ramping in.
const CONVERGE_START_FRACTION = 0.7;
// Resources dropped by the same hit shouldn't all launch toward the HUD in
// the same instant — each one already bounces on identical timing
// constants, so without this they'd all reach "toTarget" together and fly
// as one visual clump (see chat: "не мають летіти однією кучею"). Instead,
// each resource in a burst waits STAGGER_INTERVAL longer than the previous
// one before launching — see spawnOne's staggerDelay param and its use in
// the impactHold exit condition below.
// 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));
}
// Same trick as BreakablePropC's hit-flash: a same-geometry clone layered
// on top with additive blending, opacity animated for the flash — lerping
// an unlit mesh's own (already-white) material color is a silent no-op
// (see BreakablePropC/CLAUDE.md for why). Collected up front, before
// anything mutates the tree, so adding overlay children mid-traversal
// can't make traverse() walk into the overlays it just added.
// 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) => {
@@ -114,22 +64,14 @@ function buildFlashOverlays(root: Object3D): Mesh[] {
return meshes.map((mesh) => {
const overlay = mesh.clone();
// clone() copies mesh's own local transform (relative to ITS parent) —
// parenting the overlay under the mesh itself, one level deeper than
// that, would reinterpret those same numbers in the mesh's local space
// and displace the overlay instead of overlaying it exactly on top.
// Reset to identity so it sits precisely where its new parent (mesh)
// already is.
// 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 = new MeshBasicMaterial({
color: FLASH_COLOR,
transparent: true,
opacity: 0,
blending: AdditiveBlending,
depthWrite: false,
});
overlay.material = createFlashOverlayMaterial();
overlay.castShadow = false;
overlay.receiveShadow = false;
mesh.add(overlay);
@@ -164,22 +106,19 @@ export class ResourceC {
return this.collected;
}
// The real wood-icon node from the map (see TestSceneC.createMap) — every
// spawned pickup is a clone of this rather than the fallback cube.
static setPickupTemplate(template: Object3D) {
this.pickupTemplate = template;
}
// Where settled resources fly to before being counted — the HUD counter
// (see HudC.getWorldAnchorPosition).
// Where settled resources fly to before being counted — see
// HudC.getWorldAnchorPosition.
static setFlyTarget(getTarget: () => Vector3) {
this.flyTarget = getTarget;
}
// A standalone clone of the pickup visual, for callers (PayZoneC) that
// need to fly a "resource" token somewhere outside of the normal
// spawn/scatter/collect lifecycle below. No flash overlays — those are
// only built for the actual bounce-and-collect resources below.
// 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.
static createVisual(): Object3D {
return this.createPickupMesh();
}
@@ -222,9 +161,8 @@ export class ResourceC {
private static createPickupMesh(): Object3D {
if (this.pickupTemplate) {
const clone = this.pickupTemplate.clone();
// The template itself is hidden in its resting spot in the map (see
// TestSceneC) — clone() copies that visible:false too, so force it
// back on regardless of which order those two happen in.
// The template sits hidden at rest in the map — clone() copies that
// visible:false too, so force it back on.
clone.visible = true;
return clone;
}
@@ -253,8 +191,8 @@ export class ResourceC {
const t = resource.age / FLY_DURATION;
const eased = 1 - (1 - t) * (1 - t); // easeOutQuad outward
resource.mesh.position.lerpVectors(resource.from, resource.to, eased);
// simple up-then-down arc on top of the outward lerp, so it "pops"
// rather than sliding flat along the ground
// 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;
}
@@ -293,9 +231,7 @@ export class ResourceC {
if (resource.phase === "impactHold") {
const shrinkT = Math.min(resource.age / SHRINK_RAMP_DURATION, 1);
// Ease in toward the shrink and stay there — no easing back out to
// the original size (see chat).
const shrink = shrinkT * shrinkT * (3 - 2 * shrinkT) * SHRINK_AMOUNT;
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);
@@ -304,10 +240,9 @@ export class ResourceC {
(overlay.material as MeshBasicMaterial).opacity = flashOpacity;
}
// The stagger delay (see STAGGER_INTERVAL) just extends this wait
// per-resource — the shrink/flash above are already fully settled
// by IMPACT_HOLD_DURATION regardless, so waiting longer here only
// delays the *launch*, not any of the visible impact feedback.
// 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) {
@@ -336,41 +271,32 @@ export class ResourceC {
}
const t = Math.min(resource.age / TO_TARGET_DURATION, 1);
const eased = t * t * (3 - 2 * t); // smoothstep — eases in and out of the flight
// Re-sampled every frame, not snapshotted once at launch — the
// camera (and so the HUD anchor's world position, see
// HudC.getWorldAnchorPosition) can keep moving during the ~0.5s
// flight (player walking, camera follow), so a one-time snapshot
// would leave the resource flying toward a stale point that no
// longer lines up with the icon (see chat: "втрачають потрібну
// позицію і летять не туди"). Re-fetching each frame means it always
// curves toward wherever the icon actually is *right now*.
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 flight target sits much closer to the camera than where
// resources spawn (see HudC.getWorldAnchorPosition — it's pinned a
// fixed, short distance in front of the camera), so without this the
// resource would visibly *grow* as it approaches — pure perspective
// foreshortening from closing distance, not any actual change to
// world-space scale (see chat: "ресурс не має змінюватись в розмірі
// в залежності від відстані"). Scaling by the ratio of current to
// starting camera-distance exactly cancels that: apparent (on-screen)
// size stays pinned to whatever it already was leaving the bounce,
// no matter how close it gets.
// 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 currentDistance = cameraPosition.distanceTo(resource.mesh.position);
const perspectiveScale =
resource.startDistanceFromCamera > 1e-6 ? currentDistance / resource.startDistanceFromCamera : 1;
const perspectiveScale = perspectiveCompensationScale(
cameraPosition,
resource.mesh.position,
resource.startDistanceFromCamera
);
resource.mesh.scale.setScalar(resource.baseScale * perspectiveScale);
// Over the last stretch of the flight, brightens right as it reaches
// the target — reads as being absorbed into the counter's icon,
// rather than just popping out of existence mid-air (see chat: "має
// наприкінці збиратись в одиницю ресурсу і... залітати на місце
// іконки"). Reuses the same flashOverlays the bounce-impact flash
// used, idle since then.
// 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;
@@ -385,7 +311,6 @@ export class ResourceC {
private static collect(resource: FlyingResource) {
this.collected++;
console.log(`[ResourceC] gathered wood (${this.collected} total)`);
ThreeC.removeFromScene(resource.mesh);
}
}
+15 -25
View File
@@ -8,8 +8,9 @@ import {
ShapeGeometry,
Vector3,
} from "three";
import { CameraC_internal, TweenC } from "@hitplay/playable_template";
import { CameraC_internal } from "@hitplay/playable_template";
import { Easing, Tween } from "@tweenjs/tween.js";
import { chainAndRegisterTweens } from "../utils/tweenChain";
import { ThreeC } from "./ThreeC";
const SPARKLE_COUNT = 5;
@@ -30,9 +31,9 @@ const FADE_OUT_MS = 220;
const POOL_SIZE = 30;
// A small 4-point twinkle star (outer/inner radius alternating around the
// ring) — the classic "sparkle" shape, not a designer sprite/atlas asset
// (none exists yet for this, see PropVfxC for the kind of VFX that does
// have one). Built once and shared read-only across every spawned star.
// ring), not a designer sprite/atlas asset (see PropVfxC for the kind of
// VFX that does have one). Built once and shared read-only across every
// spawned star.
function buildStarShape(): Shape {
const points = 4;
const shape = new Shape();
@@ -57,18 +58,15 @@ interface StarSlot {
}
// A short "twinkle" burst of a handful of star-shaped quads around a point
// — used for the second bounce of a landed resource (see ResourceC), but
// not tied to that specifically, so it's its own small reusable effect.
// — used for a landed resource's second bounce (see ResourceC), but not
// tied to that specifically, so it's its own small reusable effect.
//
// Pooled (see chat: "давай зробимо пул") — every burst used to `new Mesh` +
// `new MeshBasicMaterial` per star and throw them away ~390ms later via
// removeFromScene(). Multiple resources bouncing in from the same crate
// hit fire their bursts within a frame or two of each other, so that
// pattern meant a dozen-plus create+GC cycles clustered into under half a
// second — exactly the "small VFX objects churning" case object pooling
// exists for (see Day 7 brief: "recommended to use pools for VFX"). Now a
// fixed set of star meshes is built once at init() and reused: spawnBurst
// just grabs whichever slots aren't currently animating.
// Pooled: a fixed set of star meshes is built once at init() and reused —
// spawnBurst just grabs whichever slots aren't currently animating, rather
// than create+GC-ing a `Mesh`/`MeshBasicMaterial` per star. Multiple
// resources bouncing in from the same crate hit fire their bursts within a
// frame or two of each other, so without pooling this would be a dozen-plus
// create+destroy cycles clustered into under half a second.
export class SparkleFxC {
private static pool: StarSlot[] = [];
private static initialized = false;
@@ -111,8 +109,7 @@ export class SparkleFxC {
}
// Not a hard failure — just fewer stars than usual this burst — but
// worth knowing about if it ever actually happens, rather than a
// silently smaller effect (see Day 7 workflow guidance on silent caps).
// worth surfacing rather than a silently smaller effect.
if (spawned < SPARKLE_COUNT) {
console.debug(`[SparkleFxC] pool exhausted — spawned ${spawned}/${SPARKLE_COUNT} stars`);
}
@@ -161,13 +158,6 @@ export class SparkleFxC {
slot.inUse = false;
});
// Both links added to TweenC individually — .chain() only tells
// popIn to call fadeOut.start() on completion, it doesn't register
// fadeOut for update ticks on its own (the same gotcha BreakablePropC
// hit with its hit-flash chain — see CLAUDE.md).
popIn.chain(fadeOut);
TweenC.add(popIn);
TweenC.add(fadeOut);
popIn.start();
chainAndRegisterTweens(popIn, fadeOut).start();
}
}
+19 -9
View File
@@ -7,19 +7,23 @@ import { BreakablePropC } from "./BreakablePropC";
import { ResourceC } from "./ResourceC";
import { PayZoneC } from "./PayZoneC";
import { HudC } from "./HudC";
import { WeaponHudC } from "./WeaponHudC";
import { ZombieInvasionHudC } from "./ZombieInvasionHudC";
import { PropVfxC } from "./PropVfxC";
import { WeaponTrailC } from "./WeaponTrailC";
import { SparkleFxC } from "./SparkleFxC";
import { HealthBarC } from "./HealthBarC";
// 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
// "behind" flipped from +Z to -Z to keep looking the same way they walk.
const CAMERA_OFFSET = new Vector3(0, 9, -9);
const CAMERA_OFFSET = new Vector3(0, 8, -6);
export class TestSceneC {
static init() {
PropVfxC.init();
SparkleFxC.init();
HealthBarC.init();
const { colliders, payZoneNode } = this.createMap();
@@ -50,6 +54,15 @@ export class TestSceneC {
HudC.init();
ResourceC.setFlyTarget(() => HudC.getWorldAnchorPosition());
HudC.setBalanceGetter(() => ResourceC.getCollectedCount() - PayZoneC.getDepositedCount());
// Bottom-left weapon/level plate — static visual, independent of
// everything above (see WeaponHudC).
WeaponHudC.init();
// Top-center "ZOMBIE INVASION" timer bar — also fully independent,
// starts its own 2-minute slide immediately on init (see
// ZombieInvasionHudC).
ZombieInvasionHudC.init();
}
private static createMap() {
@@ -78,14 +91,11 @@ export class TestSceneC {
// Leftover art scaffolding near the Pay Zone — a second wood-icon quad
// stacked on it, an unused "tool zone" plate, and a rotated
// Background/Foreground/Middleground billboard group (likely a fake
// install-button mockup, floating above ground nearby) — always
// visible by default, unused by any code, just cluttering the scene.
// ⚠️ Named "UI_Wood.001" (with a dot) in the source glb, but whatever
// loads it here strips the dot — the live scene has it as "UI_Wood001".
// getObjectByName("UI_Wood.001") always silently returned undefined,
// so this never actually hid anything (confirmed by traversing the live
// scene — see chat). This is why it kept showing up no matter what else
// got hidden/removed.
// install-button mockup) — always visible by default, unused by any
// code, just cluttering the scene.
// Named "UI_Wood.001" (with a dot) in the source glb, but the loader
// strips the dot — the live scene has it as "UI_Wood001". Look up
// nodes by their LIVE name, not the raw glb name.
const woodIconAlt = map.getObjectByName("UI_Wood001");
if (woodIconAlt) woodIconAlt.visible = false;
+44
View File
@@ -0,0 +1,44 @@
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.
//
// 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;
static init() {
if (this.initialized) return;
this.initialized = true;
this.buildDom();
}
// Layout/styling lives in src/css/ui.css (.weapon-hud/__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).
private static buildDom() {
const holder = document.createElement("div");
holder.className = "weapon-hud";
holder.style.backgroundImage = `url(${weaponHudBgSrc})`;
const icon = document.createElement("img");
icon.className = "weapon-hud__icon";
icon.src = weaponIconBatSrc;
const level = document.createElement("span");
level.className = "weapon-hud__level";
level.textContent = "LVL 0";
holder.appendChild(icon);
holder.appendChild(level);
const uiRoot = document.getElementById("ui-safe-area") ?? document.body;
uiRoot.appendChild(holder);
}
}
+7 -12
View File
@@ -14,10 +14,8 @@ import { CameraC_internal, UpdateController } from "@hitplay/playable_template";
import { ThreeC } from "./ThreeC";
// How long (seconds) a sampled point stays visible before fully fading —
// short and snappy on purpose (see Day 7 "keep effects short"). Governs the
// actual trail length in time, independent of framerate. Shortened from an
// earlier 0.15 — the longer tail read as too smooth/flowing for a bat swing
// (see chat: "не таким плавним").
// short and snappy on purpose. Governs the actual trail length in time,
// independent of framerate.
const TRAIL_LIFETIME = 0.30;
// Safety cap on buffered points — real length is TRAIL_LIFETIME (above),
@@ -32,9 +30,9 @@ const TRAIL_WIDTH = 0.35;
// fade = (1 - age/TRAIL_LIFETIME) ^ FADE_POWER — 1 (linear) reads as a
// smooth, even gradient; a higher power holds close to full width/color
// near the head and then drops off sharply toward the tail, which reads as
// snappier/choppier rather than a silky ribbon (see chat: "не таким
// плавним"). Applied to both the per-point width and color intensity below.
// near the head and then drops off sharply toward the tail, reading as
// snappier/choppier rather than a silky ribbon. Applied to both the
// per-point width and color intensity below.
const FADE_POWER = 4.5;
// Pale, slightly cool white — reads as air/wind displacement rather than a
@@ -42,8 +40,6 @@ const FADE_POWER = 4.5;
// black (not a separate alpha channel) is the same cheap trick
// BreakablePropC's hit-flash overlay uses, no custom shader needed.
const TRAIL_COLOR = new Color(0xdff3ff);
// Lowered from an earlier 0.85 — the trail read as too opaque/solid (see
// chat: "прозорішим").
const MATERIAL_OPACITY = 0.15;
const UP_AXIS = new Vector3(0, 1, 0);
@@ -51,7 +47,7 @@ const FALLBACK_TANGENT = new Vector3(0, 0, 1);
// How far back from the bat's very tip the trail actually samples, as a
// fraction of the half-length along the bat's own length axis — 0 = exact
// tip corner, 1 = box center. See chat: "не на самий кінець бити".
// tip corner, 1 = box center.
const TIP_INSET_FRACTION = 0.25;
interface TrailPoint {
@@ -78,8 +74,7 @@ export class WeaponTrailC {
private static viewDirection = new Vector3();
// Where on the bat the trail actually samples, in the weapon node's own
// local space — see computeLocalTipOffset. The node itself is pivoted at
// the grip (see chat: the trail was following the hand, not the bat's
// business end), so sampling weapon.getWorldPosition() directly anchored
// the grip, so sampling weapon.getWorldPosition() directly would anchor
// the ribbon at the palm instead of the tip.
private static localTipOffset = new Vector3();
+100
View File
@@ -0,0 +1,100 @@
import { zombieHeadIconSrc } from "../resources/images/images";
// How long the head takes to slide from the bar's right edge to its left
// edge ("full" happens when it arrives). Single source of truth: set as
// the actual CSS transition-duration from here (see startTimer), not
// duplicated as a magic number in ui.css.
const TIMER_DURATION_S = 120;
// Last stretch of the timer where the bar starts pulsing + flashing red to
// warn the player it's about to fill. 0.05 = last 5%, i.e. the last 6s of
// a 120s timer.
const WARNING_FRACTION = 0.05;
// Top-center "ZOMBIE INVASION" timer bar (title + a zombie-head marker
// sliding right-to-left over TIMER_DURATION_S). No bar/track art exists —
// 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.
export class ZombieInvasionHudC {
private static initialized = false;
private static barEl: HTMLElement;
private static fillEl: HTMLElement;
private static flashEl: HTMLElement;
private static headEl: HTMLElement;
static init() {
if (this.initialized) return;
this.initialized = true;
this.buildDom();
this.startTimer();
}
// .zombie-invasion__track wraps fill+flash in its own overflow:hidden
// box (clips the fill to the pill shape) — the head is a sibling of
// that wrapper, not a child of it, since it's deliberately bigger than
// the bar and would get clipped along with the fill otherwise.
private static buildDom() {
const holder = document.createElement("div");
holder.className = "zombie-invasion";
const title = document.createElement("div");
title.className = "zombie-invasion__title";
title.textContent = "ZOMBIE INVASION";
const bar = document.createElement("div");
bar.className = "zombie-invasion__bar";
const track = document.createElement("div");
track.className = "zombie-invasion__track";
const fill = document.createElement("div");
fill.className = "zombie-invasion__fill";
const flash = document.createElement("div");
flash.className = "zombie-invasion__flash";
const head = document.createElement("img");
head.className = "zombie-invasion__head";
head.src = zombieHeadIconSrc;
track.appendChild(fill);
track.appendChild(flash);
bar.appendChild(track);
bar.appendChild(head);
holder.appendChild(title);
holder.appendChild(bar);
const uiRoot = document.getElementById("ui-safe-area") ?? document.body;
uiRoot.appendChild(holder);
this.barEl = bar;
this.fillEl = fill;
this.flashEl = flash;
this.headEl = head;
}
// The slide/fill are plain CSS transitions (see ui.css:
// .zombie-invasion__head/__fill, transition-property: left/width) — this
// only sets the duration and flips both target values once, in the same
// tick, so they run in lockstep and the fill's edge stays exactly under
// the head with no per-frame sync needed. Reading offsetWidth forces a
// reflow between setting transitionDuration and the target values —
// without it the browser would jump straight there instead of animating.
private static startTimer() {
this.headEl.style.transitionDuration = `${TIMER_DURATION_S}s`;
this.fillEl.style.transitionDuration = `${TIMER_DURATION_S}s`;
void this.headEl.offsetWidth;
this.headEl.style.left = "0%";
this.fillEl.style.width = "100%";
const warningDelayMs = TIMER_DURATION_S * 1000 * (1 - WARNING_FRACTION);
setTimeout(() => {
this.barEl.classList.add("is-almost-full");
this.flashEl.classList.add("is-almost-full");
}, warningDelayMs);
}
}
+130
View File
@@ -0,0 +1,130 @@
/* World-tracking crate health bars: bordered bar, blue = current HP, black
= already-lost HP, white = HP lost on the hit that just landed.
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
(screen->world), see HealthBarC.ts. pointer-events:none throughout —
must never intercept the joystick/attack taps underneath it.
--health-bar-width follows the same house convention as every other
widget in ui.css even though this one isn't anchored in #ui-safe-area —
it's still a fixed on-screen size regardless of camera distance, so it
still wants the same --lvh rotation-safety as any other screen-space
size. */
.health-bar {
position: fixed;
transform: translate(-20%, 200%);
pointer-events: none;
z-index: 50;
/* Width and height are separate variables — border/padding/radius below
are all derived from the HEIGHT variable, not width, so the bezel
doesn't get chunkier as the bar stretches wider. */
--health-bar-height: calc(var(--lvh, 100vh) * 0.0143);
--health-bar-width: calc(var(--lvh, 100vh) * 0.0715);
width: var(--health-bar-width);
transition: opacity 250ms ease-out;
opacity: 1;
}
.health-bar.is-hiding {
opacity: 0;
}
.health-bar__border {
box-sizing: border-box;
width: 100%;
height: var(--health-bar-height);
padding: calc(var(--health-bar-height) * 0.135);
background: #0a0a0a;
border: calc(var(--health-bar-height) * 0.077) solid #2b2b2b;
border-radius: calc(var(--health-bar-height) * 0.115);
box-shadow: 0 0 0 calc(var(--health-bar-height) * 0.03) rgba(255, 255, 255, 0.15) inset;
}
.health-bar__track {
position: relative;
width: 100%;
height: 100%;
background: #000000;
border-radius: calc(var(--health-bar-height) * 0.058);
overflow: hidden;
}
.health-bar__white,
.health-bar__blue {
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. */
transition: width 260ms ease-out;
}
.health-bar__white {
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. */
z-index: 1;
background: #2ea3ff;
}
.health-bar__damage {
position: absolute;
left: 50%;
bottom: calc(100% + var(--health-bar-height) * 1.5);
color: #ffffff;
font-family: gameFont, sans-serif;
font-size: calc(var(--health-bar-width) * 0.288);
/* Real stroke (not just a glow) — a black outline around an opaque
number reads as "solid" the instant it appears, no fade-in needed.
Same -webkit-text-stroke technique already used on .bannerButtonText.
Scaled with font-size so the border stays the same proportion of the
glyph. */
-webkit-text-stroke: calc(var(--health-bar-width) * 0.024) #000000;
paint-order: stroke fill;
white-space: nowrap;
transform-origin: 50% 100%;
/* Fast start, slow finish across the WHOLE animation — an expo-out
curve, so the punch snaps out instantly and the fade-out eases off
gently instead of both phases reading the same speed. */
animation: health-bar-damage-punch 920ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* Sharp punch-then-fade: pops in already fully opaque (no fade-in — the
appearance itself is the animation's start), snaps up to 1.5x scale
with a rotation — the ANGLE comes from --damage-rotate, a per-popup
random value set from JS (see HealthBarC.spawnDamagePopup), so every
hit reads differently. Fully opaque at the 1.5x peak; the return to
scale 1/rotate 0 and the fade-out run IN PARALLEL (opacity already
dropping while the punch snaps back), finishing the fade a little after
it's back at rest, not the instant it arrives. */
@keyframes health-bar-damage-punch {
0% {
opacity: 1;
transform: translateX(-50%) scale(1) rotate(0deg);
}
18% {
opacity: 1;
transform: translateX(-50%) scale(1.5) rotate(var(--damage-rotate, 60deg));
}
38% {
opacity: 0.35;
transform: translateX(-50%) scale(1) rotate(0deg);
}
58% {
opacity: 0;
transform: translateX(-50%) scale(1) rotate(0deg);
}
100% {
opacity: 0;
transform: translateX(-50%) scale(1) rotate(0deg);
}
}
+82
View File
@@ -1,3 +1,20 @@
/* "gameFont" is referenced everywhere below (html, ui.css's widgets) —
this @font-face is what makes it resolve to a real font instead of
silently falling back to sans-serif. Vite's own asset pipeline inlines
this url() as base64 automatically (assetsInlineLimit is effectively
unlimited in this project's vite config) — no
ConvertToBase64WhenRelease/images.ts-style indirection needed, that
pattern is for assets loaded through JS/the SDK's resource loader, not
plain CSS url() references. Only one weight exists in the source file
(Black/900), so it's the one face used regardless of which font-weight
a rule asks for. */
@font-face {
font-family: "gameFont";
src: url("../resources/fonts/PassionOne-Black.otf") format("opentype");
font-weight: 400 900;
font-style: normal;
}
html,
body {
overflow-x: hidden;
@@ -109,6 +126,29 @@ canvas {
height: 100vh;
}
/* The 3D canvas is sized via ThreeC_internal's renderer.setSize(
window.screenSize.width, window.screenSize.height) — in this project's
default Adaptive orientation mode (see configUIParams/orientation.ts),
window.screenSize equals the raw physical viewport, so the canvas fills
the ENTIRE landscape screen edge-to-edge. Without this override,
#ui/#editor stay pinned to the portrait-shaped 9:16 box above regardless
of orientation, so every DOM HUD widget renders inside that narrow
centered strip while the 3D world beneath it uses the full wide screen.
This matches #ui/#editor to the same full-viewport footprint the canvas
already has, so the two coordinate systems line up. Every widget's own
`@media (orientation: landscape)` block in ui.css positions itself
relative to THIS wider box. */
@media (orientation: landscape) {
#ui,
#editor {
top: 0;
left: 0;
transform: none;
width: 100vw;
height: 100vh;
}
}
#modal {
z-index: 10000;
}
@@ -129,6 +169,48 @@ canvas {
z-index: 900;
}
/* The ring/thumb COLORS are set via JoystickC.init's `color` option (see
beforeResourcesLoadedCb.ts) — nipplejs applies those directly as inline
`background`, including the "back" ring's conic-gradient highlight that
tracks the thumb (--joy-angle/--joy-force, driven from
beforeResourcesLoadedCb.ts). Only what `color` can't express lives here,
scoped under #joystick_zone so it can never affect anything else:
- the ring's transparent middle — a plain `background` fill is a solid
disc, so the "ring" shape (transparent hole + faded outer edge) comes
from a mask instead, layered independently on top of whatever
color/gradient `background` paints;
- overriding nipplejs's own hardcoded inline `opacity:.5` on the thumb
(see nipplejs's Joystick.ts buildEl) — needs !important since a
same-specificity inline style otherwise always wins over an external
rule regardless of source order.
vmin/vw, not --lvh: JoystickC.init deliberately keeps this zone in
physical screen space even under an orientation lock, so it should
scale with the raw viewport, not the logical/rotated one everything
under #ui uses. */
#joystick_zone .front {
opacity: 1 !important;
/* nipplejs hardcodes the thumb at exactly half of JoystickC.init's
"size" option, with no separate option to size it independently —
this decouples the thumb's own size from the ring's "size". Override
both width/height AND the margins together: nipplejs uses a negative
margin equal to half ITS OWN thumb size to re-center it, so changing
one without the other offsets it from the actual drag anchor point. */
width: 35px !important;
height: 35px !important;
margin-left: -17.5px !important;
margin-top: -17.5px !important;
}
#joystick_zone .back {
/* Absolute px stops, not percentages of the circle's own radius — with
percentages, changing "size" (see beforeResourcesLoadedCb.ts) would
stretch the ring band's thickness along with it. These numbers are
independent of "size": retune them here directly if the ring ever
needs to be thicker/thinner or sit closer to the outer edge. */
-webkit-mask-image: radial-gradient(circle, transparent 43.4px, black 44.8px, black 46.9px, transparent 48.3px);
mask-image: radial-gradient(circle, transparent 43.4px, black 44.8px, black 46.9px, transparent 48.3px);
}
@media (orientation: landscape) {
#videoPlayable {
height: 100vh;
+325 -18
View File
@@ -1,8 +1,62 @@
#ui {
/* flex-basis: 60%; */
flex-grow: 1;
position: relative;
}
/* Every HUD widget's containing block, not #ui itself — one shared,
orientation-aware margin instead of baking an edge-gap into every
widget's own top/right/bottom/left.
position:absolute + inset (not padding!): padding on an ancestor does
NOT inset position:absolute descendants — their containing block is the
padding EDGE, so top:0/right:0 on a child sits flush against the
ancestor's border regardless of padding (a well-known CSS trap).
Shrinking this box's own extent via inset is what makes a child's "0"
mean "0 from the safe edge". Widgets keep whatever extra offset they
individually need beyond this shared baseline. */
#ui-safe-area {
--ui-safe-margin: 3.5%; /* portrait */
position: absolute;
inset: var(--ui-safe-margin);
pointer-events: none;
}
@media (orientation: landscape) {
#ui-safe-area {
--ui-safe-margin: 2.5%;
}
}
/* House convention for every HUD widget below:
1. The outer plate never sets width+height independently — always
`width` (or a `--*-width` custom property driving it) plus
`aspect-ratio: <real source image ratio>`, so it can't drift out of
the original art's proportions at any width.
2. Every child inside that plate is sized/positioned off of THAT one
width — `%`/`left`/`top`/`width` are relative to the plate's own box
for free, but anything not naturally relative to width (font-size,
text-shadow/box-shadow offsets) is pinned via
`calc(var(--*-width) * ratio)` or `em` (em rides along with
font-size, itself tied to the width variable). Child `<img>` tags
also get an explicit `aspect-ratio` matching their real source
dimensions. Net effect: changing one `--*-width` rescales the whole
widget — plate, icon, text, shadow — as one proportional unit.
3. That root `--*-width` is set from `--lvh` (logical viewport height),
never a raw `vh`/`vw`/`vmin`. Under the SDK's orientation lock
(OrientationC), rotating the device wraps the page in a
`transform: rotate(-90deg)` box so it still LOOKS portrait — raw
viewport units ignore ancestor transforms and keep measuring the
PHYSICAL screen, so anything sized in plain `vh` comes out wrong the
instant the lock rotates things. OrientationC's `--lvw`/`--lvh` are
the rotation-aware equivalents (equal to physical vw/vh when
unlocked, so this costs nothing in that mode).
4. Each widget also gets an explicit
`@media (orientation: landscape) { ... }` block re-declaring its
position and `--*-width` — a real media query reacts to the
viewport's own aspect regardless of the lock/JS layer, so it's the
more direct knob for landscape values independent of portrait. */
/* HudC — resource counter plate (dark background + diagonal wood-plank
icon baked into resourceCounterBgSrc, see images.ts/temp/Tool_15.webp).
184x71 source aspect ratio (~2.59:1) — aspect-ratio below derives height
@@ -24,22 +78,30 @@
.resource-hud {
/* Single source of truth for the plate's scale — width below and the
count's font-size (see .resource-hud__count) both derive from this one
value, so resizing the whole HUD is a one-line change instead of
hunting down every place a size was independently tuned in vh. */
--hud-width: 9vh;
value. --lvh, not a raw vh — see house convention rule 3 above. */
--hud-width: calc(var(--lvh, 100vh) * 0.09);
position: absolute;
top: 6%;
right: 10%;
top: 15.7%;
right: 0%;
width: var(--hud-width);
aspect-ratio: 184 / 71;
pointer-events: none;
z-index: 50;
/* Runs once as soon as the element mounts — no JS timing/opacity code
needed, the browser drives it (Day 8: "use built-in animations instead
of changing elements through code"). */
needed, the browser drives it. */
animation: resource-hud-enter 400ms ease-out;
}
/* Landscape override — see house convention rule 4. Mirrors the portrait
values above as a starting point; retune once seen live. */
@media (orientation: landscape) {
.resource-hud {
--hud-width: calc(var(--lvh, 100vh) * 0.16);
top: 5%;
right: 0%;
}
}
@keyframes resource-hud-enter {
from {
opacity: 0;
@@ -60,13 +122,11 @@
}
/* Toggled by HudC whenever the displayed number actually changes — a pure
CSS "pop" of the whole plate (background + number together), not a
JS-driven tween (Day 8 technical note) and not just the digit (see
chat: "не тільки на каунтер"). HudC removes and re-adds this class
(forcing a reflow in between) each time, which is what lets the
animation restart cleanly even if it retriggers before the previous run
finished — e.g. PayZoneC draining one resource every 0.3s while the
player stands in the zone. */
CSS "pop" of the whole plate (background + number together), not just
the digit. HudC removes and re-adds this class (forcing a reflow in
between) each time, which lets the animation restart cleanly even if it
retriggers before the previous run finished — e.g. PayZoneC draining one
resource every 0.3s while the player stands in the zone. */
.resource-hud__plate.is-bumping {
animation: resource-hud-bump 220ms ease-out;
}
@@ -85,18 +145,265 @@
.resource-hud__count {
position: absolute;
left: 16%;
top: 46%;
left: 22%;
top: 48%;
width: 46%;
transform: translateY(-50%);
text-align: center;
color: #ffffff;
font-weight: bold;
font-weight: normal;
/* Scales with --hud-width (set on .resource-hud, inherited down through
.resource-hud__plate) instead of a fixed vh value, so it tracks
whatever the plate is currently sized to — ratio matches the 2.1vh
that read well at the original 12vh plate width (2.1/12 ≈ 0.175). */
font-size: calc(var(--hud-width) * 0.175);
font-family: gameFont, sans-serif;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
/* em, not a fixed px offset — rides along with font-size (already tied
to --hud-width above), so the shadow scales with everything else
instead of staying a fixed size while the text around it grows/shrinks. */
text-shadow: 0 0.05em 0.1em rgba(0, 0, 0, 0.8);
}
/* 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-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
font-size both derive from this one value. --lvh, not a raw vh —
see house convention rule 3 above. */
--weapon-hud-width: calc(var(--lvh, 100vh) * 0.08);
position: absolute;
left: 0%;
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;
}
/* Landscape override — see house convention rule 4. Mirrors the portrait
values above as a starting point; retune once seen live. */
@media (orientation: landscape) {
.weapon-hud {
--weapon-hud-width: calc(var(--lvh, 100vh) * 0.15);
left: 0%;
bottom: 28%;
}
}
@keyframes weapon-hud-enter {
from {
opacity: 0;
transform: translateY(1rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.weapon-hud__icon {
position: absolute;
top: 0;
left: 50%;
width: 72%;
/* Tool_2.webp's real source is 1024x1024 — explicit, not left to the
browser's default img sizing, so it can't silently distort even if
something else sets an explicit height on it later. */
aspect-ratio: 1;
transform: translateX(-50%);
}
.weapon-hud__level {
position: absolute;
bottom: 6%;
left: 7%;
width: 100%;
text-align: center;
color: #ffffff;
font-weight: normal;
font-size: calc(var(--weapon-hud-width) * 0.20);
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);
}
/* 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
above, .zombie-invasion__bar is a plain CSS pill, not a real background
image — flag if that reads as visually off from the reference.
Everything still keys off --zombie-invasion-width, same convention as
the other widgets. */
.zombie-invasion {
/* --lvh, not a raw vh — see house convention rule 3 above. */
--zombie-invasion-width: calc(var(--lvh, 100vh) * 0.27);
position: absolute;
top: 0%;
left: 50%;
transform: translateX(-50%);
width: var(--zombie-invasion-width);
pointer-events: none;
z-index: 50;
}
/* Landscape override — see house convention rule 4. Mirrors the portrait
values above as a starting point; retune once seen live. */
@media (orientation: landscape) {
.zombie-invasion {
--zombie-invasion-width: calc(var(--lvh, 100vh) * 0.47);
top: 0%;
}
}
.zombie-invasion__title {
text-align: center;
color: #ffffff;
font-weight: bold;
font-size: calc(var(--zombie-invasion-width) * 0.12);
-webkit-text-stroke: calc(var(--zombie-invasion-width) * 0.004) #000000;
text-shadow: 0 0.05em 0.1em rgba(0, 0, 0, 0.6);
margin: 0 0 calc(var(--zombie-invasion-width) * 0.045) 0;
}
.zombie-invasion__bar {
position: relative;
width: 100%;
height: calc(var(--zombie-invasion-width) * 0.11);
/* Dark, empty-track color — the green fill (.zombie-invasion__fill) is a
separate element layered on top, not this element's own background. A
solid green background here instead would make the bar always look
100% full regardless of actual progress. */
background: #16240f;
border: calc(var(--zombie-invasion-width) * 0.012) solid #17390f;
border-radius: 999px;
box-shadow: inset 0 0 calc(var(--zombie-invasion-width) * 0.02) rgba(0, 0, 0, 0.5);
}
/* Toggled once, ~6s before a 120s timer completes (see
ZombieInvasionHudC.startTimer) — a slow, continuous pulse to flag "this
is about to fill," with no code turning it back off once the timer
finishes. Same duration on both the bar's scale-pulse and the flash's
opacity-pulse so they read as one synced beat. */
.zombie-invasion__bar.is-almost-full {
animation: zombie-invasion-pulse 0.7s ease-in-out infinite;
}
@keyframes zombie-invasion-pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.06);
}
}
/* Clips the fill/flash to the pill shape without also clipping the head
below — the head has to live OUTSIDE this wrapper (as .zombie-invasion__bar's
other direct child, not this one's) since it's now deliberately bigger
than the bar itself and needs to overhang above/below it. */
.zombie-invasion__track {
position: absolute;
inset: 0;
border-radius: 999px;
overflow: hidden;
}
/* Grows from the right edge toward the left, tracking the head's position
exactly: the head moves from left:100% to left:0% over TIMER_DURATION_S,
this grows from width:0% to width:100% over the same duration starting
at the same instant (see ZombieInvasionHudC.startTimer) — since it's
anchored with `right:0`, its *left* edge at any moment is always
`100% - width`, which is exactly the head's current `left` value. They
stay in lockstep without needing any per-frame sync code. */
.zombie-invasion__fill {
position: absolute;
top: 0;
right: 0;
height: 100%;
width: 0%;
background: #5ce65c; /* same green as PayZoneC's fill quad — project-wide consistency */
transition-property: width;
transition-timing-function: linear;
}
.zombie-invasion__flash {
position: absolute;
inset: 0;
background: #ff3b30;
opacity: 0;
}
.zombie-invasion__flash.is-almost-full {
animation: zombie-invasion-flash 0.7s ease-in-out infinite;
}
@keyframes zombie-invasion-flash {
0%,
100% {
opacity: 0;
}
/* Peak opacity kept low — a subtle warning tint, not a hard strobe. */
50% {
opacity: 0.32;
}
}
.zombie-invasion__head {
position: absolute;
top: 50%;
/* Right edge at the start — see ZombieInvasionHudC.startTimer, which
flips this to 0% (left edge) once, letting transition-duration below
carry it there over TIMER_DURATION_S. */
left: 100%;
/* Bigger than the bar's own height (0.15, above) on purpose — meant to
visibly overhang the pill above/below, not sit flush inside it. */
width: calc(var(--zombie-invasion-width) * 0.17);
aspect-ratio: 1;
transform: translate(-50%, -50%);
transition-property: left;
transition-timing-function: linear;
filter: drop-shadow(0 0 calc(var(--zombie-invasion-width) * 0.01) rgba(0, 0, 0, 0.6));
}
/* 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 ->
AnalyticsC.sendDownload() -> Template.end() wiring and its own
designer-configurable upload_file/position system.
Everything here only ADDS properties the SDK's own injected <style>
(built at runtime, appended to <head> after this stylesheet loads)
doesn't already set on these classes — .bannerLogo/.bannerButton/
.bannerButtonText have no filter/text-stroke/text-shadow of their own,
so there's nothing here to lose a cascade fight against. Position/size
(left/top/height/--*Scale) stay fully owned by InstallBanner's own
JS/CSS — not touched. */
.bannerLogo {
/* --lvh, not raw vmin — same rotation-safety reasoning as house
convention rule 3 above (vmin has the identical problem as vh/vw). */
filter: drop-shadow(0 calc(var(--lvh, 100vh) * 0.003) calc(var(--lvh, 100vh) * 0.006) rgba(0, 0, 0, 0.6));
}
/* This rule is dead in a real build — the SDK's own minifier deduplicates
the 4-times-repeated "bannerButton" substring (itself +
Holder/Img/Text), so the bare class name no longer exists verbatim in
the minified bundle for purgecss to find, and purgecss strips this rule
as unused. .bannerLogo/.bannerButtonText survive (only 2 derivatives
each, below the dedup threshold). Kept here for clarity/symmetry, not
because it takes effect. */
.bannerButton {
filter: drop-shadow(0 calc(var(--lvh, 100vh) * 0.003) calc(var(--lvh, 100vh) * 0.006) rgba(0, 0, 0, 0.6));
}
.bannerButtonText {
font-weight: bold;
-webkit-text-stroke: 0.05em #000000;
text-shadow: 0 0.05em 0.1em rgba(0, 0, 0, 0.8);
}
+11 -1
View File
@@ -11,6 +11,7 @@
<link href="./css/main.css" rel="stylesheet" />
<link href="./css/ui.css" rel="stylesheet" />
<link href="./css/health-bar.css" rel="stylesheet" />
<style id="animations"></style>
<!-- ⛔ DO NOT EDIT: critical loader cover. Full-screen opaque so the game
never flashes before @hitplay/playable_template paints the loader.
@@ -29,7 +30,16 @@
</div>
<div id="constructor">
<div id="ui"></div>
<div id="ui">
<!-- All game HUD widgets (HudC/WeaponHudC/ZombieInvasionHudC) mount
here, not directly into #ui — see ui.css's #ui-safe-area rule:
this box is #ui inset by --ui-safe-margin (separate portrait/
landscape values), so every widget's own top/right/bottom/left
is measured from an edge that's already pulled in from the true
viewport edge by one shared, orientation-aware amount instead
of each widget baking its own edge-margin into its own numbers. -->
<div id="ui-safe-area"></div>
</div>
<div id="editor"></div>
</div>
<script id="dev-start">
Binary file not shown.
+12
View File
@@ -6,3 +6,15 @@ import { ConvertToBase64WhenRelease } from "@hitplay/ads_common";
// CSS-drawn pill background; the plank icon is part of this image, not a
// separate <img>.
export const resourceCounterBgSrc = ConvertToBase64WhenRelease("./resource_counter_bg.webp");
// Weapon-level HUD widget (bottom-left octagon plate + weapon icon + "LVL"
// text, see reference screenshot) — see WeaponHudC.
export const weaponHudBgSrc = ConvertToBase64WhenRelease("./weapon_hud_bg.webp");
export const weaponIconBatSrc = ConvertToBase64WhenRelease("./weapon_icon_bat.webp");
// Zombie-head marker for the top "ZOMBIE INVASION" timer bar (see
// ZombieInvasionHudC) — source is temp/REF_1.png, despite the .png name
// it's actually WebP data (RIFF/WEBP header, confirmed by reading the raw
// bytes), so it's stored here with a matching .webp extension instead of
// carrying the misleading source extension forward.
export const zombieHeadIconSrc = ConvertToBase64WhenRelease("./zombie_head_icon.webp");
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

@@ -36,6 +36,70 @@ export const beforeResourcesLoadedCb = () => {
restJoystick: true,
restOpacity: 0,
follow: false,
// nipplejs's own default is 100; the inner thumb is always exactly
// half of this by nipplejs's own rule — irrelevant here since .front's
// size is overridden independently in main.css.
size: 105,
// "front" (the thumb/finger position) is a single flat color. Its
// actual on-screen SIZE is overridden separately in main.css, since
// nipplejs ties it to a hardcoded half of "size" above with no
// independent option. "back" (the ring) is a single flat, darkened
// color around most of its circumference, except a wide arc right
// where the thumb sits, which brightens toward the thumb's own color
// as if lit by it. --joy-angle (WHERE) and --joy-force (HOW STRONG)
// are live CSS vars driven from onJoysticMove below. The ring's
// transparent middle and the thumb's forced-opaque override
// (nipplejs hardcodes the thumb at 50% opacity) also live in
// main.css, since `color` can't express either.
//
// Alpha is fixed (not interpolated to 0) at every stop, only the RGB
// channels shift with --joy-force — interpolating alpha down to 0
// at rest made the highlight's position read as a see-through gap
// instead of matching the base color. Alpha itself still ramps up
// slightly with force (0.75 -> 0.95) so the highlight reads as the
// thumb's actual saturated color rather than diluted by the sandy
// background showing through it.
color: {
back: "conic-gradient(from var(--joy-angle, 0deg), rgba(calc(70 + 185 * var(--joy-force, 0)), calc(55 + 85 * var(--joy-force, 0)), calc(45 - 5 * var(--joy-force, 0)), calc(0.75 + 0.2 * var(--joy-force, 0))) 0deg, rgba(70, 55, 45, 0.75) 85deg, rgba(70, 55, 45, 0.75) 275deg, rgba(calc(70 + 185 * var(--joy-force, 0)), calc(55 + 85 * var(--joy-force, 0)), calc(45 - 5 * var(--joy-force, 0)), calc(0.75 + 0.2 * var(--joy-force, 0))) 360deg)",
front: "#ff8c28",
},
});
// Keeps --joy-angle/--joy-force (read by the conic-gradient above)
// tracking the thumb in real time.
//
// Angle: uses the thumb's own pixel offset from center (frontPosition —
// what nipplejs itself uses to `translate()` the thumb) through
// Math.atan2, not nipplejs's own `angle` field (flipped into a
// "human-readable" convention internally and rotated again for
// orientation-lock logical space — neither matches plain CSS/screen
// angle math). atan2 and CSS conic-gradient both sweep clockwise as the
// angle increases, but their zero points differ by 90° (atan2's 0° is
// 3 o'clock/east; conic-gradient's `from 0deg` is 12 o'clock/north) — the
// fix is a flat +90 added once, not a sign flip.
//
// Force: nipplejs documents `force` as normalized 0..1, but it's
// actually computed from the RAW pre-clamp touch distance, while
// frontPosition (and so the angle above) uses the CLAMPED distance — so
// force keeps climbing past 1 the further you drag beyond the ring's
// edge even though the thumb itself is visibly pinned there. Every
// calc() above assumes force is 0..1, so it's clamped once here rather
// than in every calc() in the gradient string.
//
// `data.instance` isn't in the SDK's own (stale) type declaration for
// this event, though it's really there on every nipplejs 1.x payload —
// the cast below bridges that gap.
JoystickC.onJoysticMove.addDelegate(({ data }) => {
const { instance, force } = data as unknown as {
instance?: { frontPosition: { x: number; y: number }; ui: { el: HTMLElement } };
force?: number;
};
if (!instance) return;
const { x, y } = instance.frontPosition;
const angleDeg = Math.atan2(y, x) * (180 / Math.PI) + 90;
const clampedForce = Math.min(Math.max(force ?? 0, 0), 1);
instance.ui.el.style.setProperty("--joy-angle", `${angleDeg}deg`);
instance.ui.el.style.setProperty("--joy-force", `${clampedForce}`);
});
Template.updateVariableConfig.addDelegate(([category, variable, value]) => {
+19
View File
@@ -0,0 +1,19 @@
import { AdditiveBlending, Color, MeshBasicMaterial } from "three";
// Crate/resource meshes use unlit MeshBasicMaterial already at pure white
// (their texture supplies the actual color) — lerping that color toward
// white is a silent no-op, which is why animating a mesh's own
// material.color never produces a visible flash. A same-geometry clone
// layered on top with additive blending, opacity animated instead of
// color, works regardless of the base mesh's own color/texture.
export const FLASH_COLOR = new Color(0xffffff);
export function createFlashOverlayMaterial(): MeshBasicMaterial {
return new MeshBasicMaterial({
color: FLASH_COLOR,
transparent: true,
opacity: 0,
blending: AdditiveBlending,
depthWrite: false,
});
}
+11
View File
@@ -0,0 +1,11 @@
import { Vector3 } from "three";
// Cancels pure perspective foreshortening/growth as an object's distance
// from the camera changes over an animation: scaling by (current / starting
// distance) keeps its on-screen size pinned to whatever it was when the
// flight started, regardless of how the world-space distance to the camera
// changes in between.
export function perspectiveCompensationScale(cameraPosition: Vector3, currentPoint: Vector3, startDistance: number): number {
const currentDistance = cameraPosition.distanceTo(currentPoint);
return startDistance > 1e-6 ? currentDistance / startDistance : 1;
}
+28
View File
@@ -0,0 +1,28 @@
import { Camera, Vector3 } from "three";
// Both directions below read the renderer canvas's own getBoundingClientRect()
// as the canonical screen-space reference, not window.innerWidth/Height —
// the SDK letterboxes/centers the canvas into a fixed aspect area, so the
// canvas's own box is the only thing guaranteed to line up with the
// camera's projection.
export function projectScreenPointToWorldRay(
camera: Camera,
canvasRect: DOMRect,
screenX: number,
screenY: number
): { origin: Vector3; direction: Vector3 } {
const ndcX = ((screenX - canvasRect.left) / canvasRect.width) * 2 - 1;
const ndcY = -((screenY - canvasRect.top) / canvasRect.height) * 2 + 1;
const point = new Vector3(ndcX, ndcY, 0.5).unproject(camera);
const direction = point.sub(camera.position).normalize();
return { origin: camera.position.clone(), direction };
}
export function projectWorldPointToScreen(camera: Camera, canvasRect: DOMRect, worldPoint: Vector3): { x: number; y: number } {
const ndc = worldPoint.clone().project(camera);
return {
x: ((ndc.x + 1) / 2) * canvasRect.width + canvasRect.left,
y: ((1 - ndc.y) / 2) * canvasRect.height + canvasRect.top,
};
}
+15
View File
@@ -0,0 +1,15 @@
import { TweenC } from "@hitplay/playable_template";
import { Tween } from "@tweenjs/tween.js";
// tween.js's .chain() only tells the first tween to call the second's
// .start() on completion — it does NOT add the second tween to any Group.
// TweenC's update loop only ticks tweens added via TweenC.add(), so an
// un-added chained tween marks itself "playing" but never receives an
// update() and freezes at its start value forever. Both links must be
// added individually before the first is started.
export function chainAndRegisterTweens<A extends object, B extends object>(first: Tween<A>, second: Tween<B>): Tween<A> {
first.chain(second);
TweenC.add(first);
TweenC.add(second);
return first;
}