From eddcfe1af5ffe29de5c9c3e2c72ee704496e3dec Mon Sep 17 00:00:00 2001 From: Oleksandr Vlasiuk Date: Thu, 13 Aug 2026 18:49:04 +0300 Subject: [PATCH] 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 --- src/configUIParams/installBanner.ts | 10 +- src/controllers/BreakablePropC.ts | 126 ++++--- src/controllers/CameraFollowC.ts | 50 +-- src/controllers/HealthBarC.ts | 204 +++++++++++ src/controllers/HudC.ts | 38 +- src/controllers/PayZoneC.ts | 162 ++++----- src/controllers/PhysicsC.ts | 49 +-- src/controllers/PlayerC.ts | 23 +- src/controllers/PropVfxC.ts | 28 +- src/controllers/ResourceC.ts | 201 ++++------ src/controllers/SparkleFxC.ts | 40 +- src/controllers/TestSceneC.ts | 28 +- src/controllers/WeaponHudC.ts | 44 +++ src/controllers/WeaponTrailC.ts | 19 +- src/controllers/ZombieInvasionHudC.ts | 100 +++++ src/css/health-bar.css | 130 +++++++ src/css/main.css | 82 +++++ src/css/ui.css | 343 +++++++++++++++++- src/index.html | 12 +- src/resources/fonts/PassionOne-Black.otf | Bin 0 -> 18176 bytes src/resources/images/images.ts | 12 + src/resources/images/weapon_hud_bg.webp | Bin 0 -> 7184 bytes src/resources/images/weapon_icon_bat.webp | Bin 0 -> 14474 bytes src/resources/images/zombie_head_icon.webp | Bin 0 -> 9770 bytes src/templateConfig/beforeResourcesLoadedCb.ts | 64 ++++ src/utils/flashOverlayMaterial.ts | 19 + src/utils/perspectiveScale.ts | 11 + src/utils/screenWorldProjection.ts | 28 ++ src/utils/tweenChain.ts | 15 + 29 files changed, 1354 insertions(+), 484 deletions(-) create mode 100644 src/controllers/HealthBarC.ts create mode 100644 src/controllers/WeaponHudC.ts create mode 100644 src/controllers/ZombieInvasionHudC.ts create mode 100644 src/css/health-bar.css create mode 100644 src/resources/fonts/PassionOne-Black.otf create mode 100644 src/resources/images/weapon_hud_bg.webp create mode 100644 src/resources/images/weapon_icon_bat.webp create mode 100644 src/resources/images/zombie_head_icon.webp create mode 100644 src/utils/flashOverlayMaterial.ts create mode 100644 src/utils/perspectiveScale.ts create mode 100644 src/utils/screenWorldProjection.ts create mode 100644 src/utils/tweenChain.ts diff --git a/src/configUIParams/installBanner.ts b/src/configUIParams/installBanner.ts index 7da7891..0a30b39 100644 --- a/src/configUIParams/installBanner.ts +++ b/src/configUIParams/installBanner.ts @@ -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", diff --git a/src/controllers/BreakablePropC.ts b/src/controllers/BreakablePropC.ts index 562a1b9..4a37988 100644 --- a/src/controllers/BreakablePropC.ts +++ b/src/controllers/BreakablePropC.ts @@ -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); } diff --git a/src/controllers/CameraFollowC.ts b/src/controllers/CameraFollowC.ts index 1f8da66..f398cf4 100644 --- a/src/controllers/CameraFollowC.ts +++ b/src/controllers/CameraFollowC.ts @@ -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); } diff --git a/src/controllers/HealthBarC.ts b/src/controllers/HealthBarC.ts new file mode 100644 index 0000000..223c4ab --- /dev/null +++ b/src/controllers/HealthBarC.ts @@ -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 | 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(); + + 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); + } +} diff --git a/src/controllers/HudC.ts b/src/controllers/HudC.ts index 51d5658..ecd03f2 100644 --- a/src/controllers/HudC.ts +++ b/src/controllers/HudC.ts @@ -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; diff --git a/src/controllers/PayZoneC.ts b/src/controllers/PayZoneC.ts index 023da06..d54dfd2 100644 --- a/src/controllers/PayZoneC.ts +++ b/src/controllers/PayZoneC.ts @@ -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; diff --git a/src/controllers/PhysicsC.ts b/src/controllers/PhysicsC.ts index 5182430..b8b0540 100644 --- a/src/controllers/PhysicsC.ts +++ b/src/controllers/PhysicsC.ts @@ -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; - - 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); - } - } -} diff --git a/src/controllers/PlayerC.ts b/src/controllers/PlayerC.ts index 59f6bd9..8b2ae77 100644 --- a/src/controllers/PlayerC.ts +++ b/src/controllers/PlayerC.ts @@ -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[] = []; diff --git a/src/controllers/PropVfxC.ts b/src/controllers/PropVfxC.ts index 9fb4842..11e7492 100644 --- a/src/controllers/PropVfxC.ts +++ b/src/controllers/PropVfxC.ts @@ -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 diff --git a/src/controllers/ResourceC.ts b/src/controllers/ResourceC.ts index bc85518..96510ae 100644 --- a/src/controllers/ResourceC.ts +++ b/src/controllers/ResourceC.ts @@ -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); } } diff --git a/src/controllers/SparkleFxC.ts b/src/controllers/SparkleFxC.ts index 783bb12..f1a311c 100644 --- a/src/controllers/SparkleFxC.ts +++ b/src/controllers/SparkleFxC.ts @@ -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(); } } diff --git a/src/controllers/TestSceneC.ts b/src/controllers/TestSceneC.ts index d5f2acc..acba318 100644 --- a/src/controllers/TestSceneC.ts +++ b/src/controllers/TestSceneC.ts @@ -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; diff --git a/src/controllers/WeaponHudC.ts b/src/controllers/WeaponHudC.ts new file mode 100644 index 0000000..49e20bb --- /dev/null +++ b/src/controllers/WeaponHudC.ts @@ -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); + } +} diff --git a/src/controllers/WeaponTrailC.ts b/src/controllers/WeaponTrailC.ts index 21949b7..0de2a6b 100644 --- a/src/controllers/WeaponTrailC.ts +++ b/src/controllers/WeaponTrailC.ts @@ -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(); diff --git a/src/controllers/ZombieInvasionHudC.ts b/src/controllers/ZombieInvasionHudC.ts new file mode 100644 index 0000000..d53fd4c --- /dev/null +++ b/src/controllers/ZombieInvasionHudC.ts @@ -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); + } +} diff --git a/src/css/health-bar.css b/src/css/health-bar.css new file mode 100644 index 0000000..7c9f7b2 --- /dev/null +++ b/src/css/health-bar.css @@ -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); + } +} diff --git a/src/css/main.css b/src/css/main.css index 8a0f7e4..c1602d4 100644 --- a/src/css/main.css +++ b/src/css/main.css @@ -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; diff --git a/src/css/ui.css b/src/css/ui.css index 6aae49c..b43cb21 100644 --- a/src/css/ui.css +++ b/src/css/ui.css @@ -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: `, 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 `` 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 +
+