Add health bars, WeaponHudC/ZombieInvasionHudC, redesign joystick, and clean up shared logic
- New HP-driven health bars for breakable crates (blue/white/black fill, floating damage numbers, world-tracked DOM overlay) - WeaponHudC and ZombieInvasionHudC HUD widgets, PassionOne-Black font, InstallBanner CSS restyle - Reworked PayZoneC's deposit flight to launch from the player's head with a lob arc into the Pay Zone's real center, instead of the HUD anchor - Redesigned the joystick visual (gradient ring, force-gated highlight that tracks the thumb, tuned sizing) and the follow camera framing - Extracted duplicated perspective-scale, screen<->world projection, tween-chain, and flash-overlay logic into src/utils/ - Removed dead code (PhysicsObjPair, PayZoneC.getTargetWorldPosition) and stray console.log calls, trimmed narrative comments across controllers Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Box3, Camera, Vector3 } from "three";
|
||||
import { CameraC_internal, UpdateController } from "@hitplay/playable_template";
|
||||
import { ThreeC } from "./ThreeC";
|
||||
import type { BreakableProp } from "./BreakablePropC";
|
||||
import { projectWorldPointToScreen } from "../utils/screenWorldProjection";
|
||||
|
||||
// If a bar's crate stops getting hit and the player walks away before it's
|
||||
// destroyed, the bar lingers this long before fading out — reset to 0 on
|
||||
// every hit, see onHit.
|
||||
const HIDE_DELAY_S = 2;
|
||||
const FADE_OUT_MS = 250;
|
||||
|
||||
// How far above the crate's own real bounding-box top (world units, not a
|
||||
// guessed pixel offset — see getOrCreateBar) the bar sits.
|
||||
const BAR_HEIGHT_OFFSET = 0.2;
|
||||
|
||||
// Kept in sync with health-bar.css's health-bar-damage-punch animation
|
||||
// duration, so the popup element is removed right as the CSS animation
|
||||
// finishes, not before/after.
|
||||
const DAMAGE_POPUP_LIFETIME_MS = 920;
|
||||
|
||||
// Random per-popup punch-rotation range (degrees) — see spawnDamagePopup.
|
||||
// Always clockwise from the 12 o'clock resting angle (0deg), never past the
|
||||
// 2 o'clock mark (2 * 30deg/hour = 60deg).
|
||||
const DAMAGE_ROTATE_MIN_DEG = 10;
|
||||
const DAMAGE_ROTATE_MAX_DEG = 50;
|
||||
|
||||
interface BarInstance {
|
||||
prop: BreakableProp;
|
||||
container: HTMLElement;
|
||||
whiteEl: HTMLElement;
|
||||
blueEl: HTMLElement;
|
||||
// Cached once at bar creation, not recomputed per frame — crates never
|
||||
// move once placed, so only the CAMERA'S projection of this fixed world
|
||||
// point needs updating every frame, not the point itself.
|
||||
worldTop: Vector3;
|
||||
idleTime: number;
|
||||
hiding: boolean;
|
||||
removeTimeout: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
// World-tracking DOM health bars for breakable crates: a bordered
|
||||
// rectangular bar (blue = current HP, black = already-lost HP, white = HP
|
||||
// lost on the hit that just landed, catching down to black after a beat),
|
||||
// plus a floating "-10" popup on every hit. Pure DOM+CSS (styling in
|
||||
// src/css/health-bar.css) — only the per-frame world->screen repositioning
|
||||
// and the hit-triggered width changes are JS.
|
||||
export class HealthBarC {
|
||||
private static initialized = false;
|
||||
private static root: HTMLElement;
|
||||
private static bars = new Map<BreakableProp, BarInstance>();
|
||||
|
||||
static init() {
|
||||
if (this.initialized) return;
|
||||
this.initialized = true;
|
||||
|
||||
this.root = document.getElementById("ui-safe-area") ?? document.body;
|
||||
UpdateController.Instance.onUpdate.addDelegate((delta: number) => this.update(delta));
|
||||
}
|
||||
|
||||
// previousHp/currentHp are both already post-clamp (0..maxHp) — the
|
||||
// caller (BreakablePropC.hit) applies the damage before calling this;
|
||||
// this only drives the DISPLAY of what just happened.
|
||||
static onHit(prop: BreakableProp, previousHp: number, currentHp: number, damage: number) {
|
||||
const bar = this.getOrCreateBar(prop);
|
||||
bar.idleTime = 0;
|
||||
this.cancelHide(bar);
|
||||
|
||||
const previousFraction = (previousHp / prop.maxHp) * 100;
|
||||
const currentFraction = (currentHp / prop.maxHp) * 100;
|
||||
|
||||
// The three-color effect is pure CSS transition timing, not a per-frame
|
||||
// JS animation (see health-bar.css): white first SNAPS to the pre-hit
|
||||
// value with no transition, then both white and blue are set to the
|
||||
// post-hit value — white with a built-in transition-delay so it
|
||||
// visibly lags behind blue for a moment (the "just lost, still white"
|
||||
// beat) before catching down to become part of the black background
|
||||
// underneath. Blue is layered on top of white so it never gets hidden
|
||||
// by white's lag.
|
||||
bar.whiteEl.style.transitionDuration = "0s";
|
||||
bar.whiteEl.style.transitionDelay = "0s";
|
||||
bar.whiteEl.style.width = `${previousFraction}%`;
|
||||
void bar.whiteEl.offsetWidth; // force reflow: commit the instant snap above before re-enabling the transition below
|
||||
|
||||
bar.whiteEl.style.transitionDuration = "";
|
||||
bar.whiteEl.style.transitionDelay = "";
|
||||
bar.whiteEl.style.width = `${currentFraction}%`;
|
||||
bar.blueEl.style.width = `${currentFraction}%`;
|
||||
|
||||
this.spawnDamagePopup(bar, damage);
|
||||
}
|
||||
|
||||
static onDestroyed(prop: BreakableProp) {
|
||||
const bar = this.bars.get(prop);
|
||||
if (!bar) return;
|
||||
this.removeBar(bar);
|
||||
}
|
||||
|
||||
private static update(delta: number) {
|
||||
if (this.bars.size === 0) return;
|
||||
|
||||
const camera = CameraC_internal.getCamera();
|
||||
const canvasRect = ThreeC.renderer.domElement.getBoundingClientRect();
|
||||
|
||||
for (const bar of this.bars.values()) {
|
||||
this.positionBar(bar, camera, canvasRect);
|
||||
|
||||
if (bar.hiding) continue;
|
||||
bar.idleTime += delta;
|
||||
if (bar.idleTime >= HIDE_DELAY_S) this.startHide(bar);
|
||||
}
|
||||
}
|
||||
|
||||
private static positionBar(bar: BarInstance, camera: Camera, canvasRect: DOMRect) {
|
||||
const { x, y } = projectWorldPointToScreen(camera, canvasRect, bar.worldTop);
|
||||
bar.container.style.left = `${x}px`;
|
||||
bar.container.style.top = `${y}px`;
|
||||
}
|
||||
|
||||
private static getOrCreateBar(prop: BreakableProp): BarInstance {
|
||||
const existing = this.bars.get(prop);
|
||||
if (existing) return existing;
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.className = "health-bar";
|
||||
|
||||
const border = document.createElement("div");
|
||||
border.className = "health-bar__border";
|
||||
|
||||
const track = document.createElement("div");
|
||||
track.className = "health-bar__track";
|
||||
|
||||
const white = document.createElement("div");
|
||||
white.className = "health-bar__white";
|
||||
|
||||
const blue = document.createElement("div");
|
||||
blue.className = "health-bar__blue";
|
||||
|
||||
track.appendChild(white);
|
||||
track.appendChild(blue);
|
||||
border.appendChild(track);
|
||||
container.appendChild(border);
|
||||
this.root.appendChild(container);
|
||||
|
||||
// Real geometry, not a guessed offset — the crate's own world bounding
|
||||
// box top, centered on X/Z, plus a small fixed gap above it.
|
||||
const box = new Box3().setFromObject(prop.root);
|
||||
const worldTop = new Vector3((box.min.x + box.max.x) / 2, box.max.y + BAR_HEIGHT_OFFSET, (box.min.z + box.max.z) / 2);
|
||||
|
||||
const fraction = (prop.currentHp / prop.maxHp) * 100;
|
||||
white.style.width = `${fraction}%`;
|
||||
blue.style.width = `${fraction}%`;
|
||||
|
||||
const bar: BarInstance = {
|
||||
prop,
|
||||
container,
|
||||
whiteEl: white,
|
||||
blueEl: blue,
|
||||
worldTop,
|
||||
idleTime: 0,
|
||||
hiding: false,
|
||||
removeTimeout: null,
|
||||
};
|
||||
|
||||
this.bars.set(prop, bar);
|
||||
return bar;
|
||||
}
|
||||
|
||||
private static cancelHide(bar: BarInstance) {
|
||||
if (bar.removeTimeout !== null) {
|
||||
clearTimeout(bar.removeTimeout);
|
||||
bar.removeTimeout = null;
|
||||
}
|
||||
if (bar.hiding) {
|
||||
bar.hiding = false;
|
||||
bar.container.classList.remove("is-hiding");
|
||||
}
|
||||
}
|
||||
|
||||
private static startHide(bar: BarInstance) {
|
||||
bar.hiding = true;
|
||||
bar.container.classList.add("is-hiding");
|
||||
bar.removeTimeout = setTimeout(() => this.removeBar(bar), FADE_OUT_MS);
|
||||
}
|
||||
|
||||
private static removeBar(bar: BarInstance) {
|
||||
if (bar.removeTimeout !== null) clearTimeout(bar.removeTimeout);
|
||||
bar.container.remove();
|
||||
this.bars.delete(bar.prop);
|
||||
}
|
||||
|
||||
private static spawnDamagePopup(bar: BarInstance, damage: number) {
|
||||
const popup = document.createElement("div");
|
||||
popup.className = "health-bar__damage";
|
||||
popup.textContent = `-${damage}`;
|
||||
// Random per-popup punch angle so every hit reads differently — the
|
||||
// CSS keyframes just read var(--damage-rotate). Always clockwise,
|
||||
// never negative/left.
|
||||
const rotateDeg = DAMAGE_ROTATE_MIN_DEG + Math.random() * (DAMAGE_ROTATE_MAX_DEG - DAMAGE_ROTATE_MIN_DEG);
|
||||
popup.style.setProperty("--damage-rotate", `${rotateDeg}deg`);
|
||||
bar.container.appendChild(popup);
|
||||
setTimeout(() => popup.remove(), DAMAGE_POPUP_LIFETIME_MS);
|
||||
}
|
||||
}
|
||||
+14
-24
@@ -2,6 +2,7 @@ import { Vector3 } from "three";
|
||||
import { CameraC_internal, UpdateController } from "@hitplay/playable_template";
|
||||
import { ThreeC } from "./ThreeC";
|
||||
import { resourceCounterBgSrc } from "../resources/images/images";
|
||||
import { projectScreenPointToWorldRay } from "../utils/screenWorldProjection";
|
||||
|
||||
// How far in front of the camera (world units, along the screen ray) to
|
||||
// place the projected anchor point — getWorldAnchorPosition() below finds
|
||||
@@ -30,14 +31,11 @@ export class HudC {
|
||||
UpdateController.Instance.onUpdate.addDelegate(() => this.update());
|
||||
}
|
||||
|
||||
// Where collected resources fly to, and where PayZoneC's deposit flight
|
||||
// into the Pay Zone originates from — the plate's own right edge,
|
||||
// projected into 3D via the real DOM position (see chat: "дістати
|
||||
// позицію правого края юайки"), not a hand-picked local offset off the
|
||||
// camera like before. Recomputed from scratch on every call rather than
|
||||
// cached: it's only called once per resource-fly event (not per frame),
|
||||
// cheap enough, and this way it stays correct through window
|
||||
// resize/orientation changes for free.
|
||||
// Where collected resources fly to — the plate's own right edge,
|
||||
// projected into 3D via its real DOM position, not a hand-picked local
|
||||
// offset off the camera. Recomputed on every call rather than cached:
|
||||
// only called once per resource-fly event (not per frame), and this way
|
||||
// it stays correct through resize/orientation changes for free.
|
||||
static getWorldAnchorPosition(): Vector3 {
|
||||
const canvasRect = ThreeC.renderer.domElement.getBoundingClientRect();
|
||||
const plateRect = this.plateEl.getBoundingClientRect();
|
||||
@@ -45,24 +43,16 @@ export class HudC {
|
||||
const screenX = plateRect.right;
|
||||
const screenY = plateRect.top + plateRect.height / 2;
|
||||
|
||||
// NDC relative to the actual canvas box, not window.innerWidth/Height —
|
||||
// the SDK letterboxes/centers the canvas into a fixed 9:16 area (see
|
||||
// #ui/#editor in main.css), so the canvas's own bounding rect is the
|
||||
// only thing guaranteed to line up with the camera's projection.
|
||||
const ndcX = ((screenX - canvasRect.left) / canvasRect.width) * 2 - 1;
|
||||
const ndcY = -((screenY - canvasRect.top) / canvasRect.height) * 2 + 1;
|
||||
|
||||
const camera = CameraC_internal.getCamera();
|
||||
const point = new Vector3(ndcX, ndcY, 0.5).unproject(camera);
|
||||
const direction = point.sub(camera.position).normalize();
|
||||
return camera.position.clone().addScaledVector(direction, ANCHOR_DISTANCE);
|
||||
const { origin, direction } = projectScreenPointToWorldRay(camera, canvasRect, screenX, screenY);
|
||||
return origin.addScaledVector(direction, ANCHOR_DISTANCE);
|
||||
}
|
||||
|
||||
// The displayed number is a live balance, not a lifetime total — it
|
||||
// should rise as resources are collected and fall as they're delivered
|
||||
// into the Pay Zone (see chat). Injected rather than importing
|
||||
// ResourceC/PayZoneC directly, since PayZoneC already imports HudC (for
|
||||
// getWorldAnchorPosition) and importing back would cycle.
|
||||
// The displayed number is a live balance, not a lifetime total — rises as
|
||||
// resources are collected, falls as they're delivered into the Pay Zone.
|
||||
// Injected rather than importing ResourceC/PayZoneC directly, since
|
||||
// PayZoneC already imports HudC (for getWorldAnchorPosition) and
|
||||
// importing back would cycle.
|
||||
static setBalanceGetter(getBalance: () => number) {
|
||||
this.balanceGetter = getBalance;
|
||||
}
|
||||
@@ -92,7 +82,7 @@ export class HudC {
|
||||
plate.appendChild(count);
|
||||
holder.appendChild(plate);
|
||||
|
||||
const uiRoot = document.getElementById("ui") ?? document.body;
|
||||
const uiRoot = document.getElementById("ui-safe-area") ?? document.body;
|
||||
uiRoot.appendChild(holder);
|
||||
|
||||
this.plateEl = plate;
|
||||
|
||||
+68
-94
@@ -3,58 +3,61 @@ import { CameraC_internal, TweenC, UpdateController } from "@hitplay/playable_te
|
||||
import { Easing, Tween } from "@tweenjs/tween.js";
|
||||
import { ThreeC } from "./ThreeC";
|
||||
import { RESOURCE_ICON_BASE_SCALE, ResourceC } from "./ResourceC";
|
||||
import { HudC } from "./HudC";
|
||||
import { PlayerC } from "./PlayerC";
|
||||
import { perspectiveCompensationScale } from "../utils/perspectiveScale";
|
||||
|
||||
// How many resources need to actually make it into the zone before it's
|
||||
// done and disappears — what happens after that is intentionally out of
|
||||
// scope for now (see chat).
|
||||
// How many resources need to actually make it into the zone before it
|
||||
// disappears.
|
||||
const RESOURCES_TO_CLOSE_ZONE = 20;
|
||||
|
||||
// While standing in the zone with resources still to deliver, one flies in
|
||||
// every DEPOSIT_INTERVAL seconds — a steady drain rather than a single
|
||||
// instant dump.
|
||||
// every DEPOSIT_INTERVAL seconds — a steady drain rather than an instant dump.
|
||||
const DEPOSIT_INTERVAL = 0.3;
|
||||
const DEPOSIT_FLIGHT_DURATION_MS = 500;
|
||||
// Redundant safety-net cleanup delay past the tween's own duration — see
|
||||
// the note in playDepositFlight on why this exists.
|
||||
// Redundant safety-net cleanup: the deposit tween occasionally never fires
|
||||
// onComplete and freezes mid-flight — cause not pinned down (not the usual
|
||||
// chain()-registration gotcha, this tween isn't chained). This plain
|
||||
// timeout removes the mesh regardless. Calling removeFromScene twice (if
|
||||
// onComplete also fires) is harmless — the second call just finds no
|
||||
// parent to detach from.
|
||||
const DEPOSIT_FLIGHT_CLEANUP_MARGIN_MS = 300;
|
||||
|
||||
const PAY_ZONE_SHRINK_MS = 400;
|
||||
|
||||
// Extra multiplier on top of RESOURCE_ICON_BASE_SCALE (see ResourceC), just
|
||||
// for this deposit flight — tune this, not the shared constant, if only
|
||||
// the fly-to-Pay-Zone token needs to look smaller/bigger (see chat: "де я
|
||||
// можу зменшити розмір ресурсу під час анімації заповнення пей зони?").
|
||||
const DEPOSIT_FLIGHT_SCALE_MULTIPLIER = 0.3;
|
||||
// Extra multiplier on top of RESOURCE_ICON_BASE_SCALE, local to this deposit
|
||||
// flight only. This flight now starts at the player's own head, roughly the
|
||||
// same camera distance as its destination (the camera rigidly follows the
|
||||
// player), so perspectiveCompensationScale barely changes its value over
|
||||
// the flight — unlike when this flight used to start at the HUD anchor
|
||||
// right next to the camera, where that term grew a lot on its own. Retune
|
||||
// this constant (not RESOURCE_ICON_BASE_SCALE) if only this flight's token
|
||||
// needs to look bigger/smaller.
|
||||
const DEPOSIT_FLIGHT_SCALE_MULTIPLIER = 1.3;
|
||||
|
||||
// Fill overlay: a flat quad laid over the zone's own footprint, growing
|
||||
// from its near edge (fixed, closest to the camera) toward the far edge as
|
||||
// deposits come in — a ground-level wipe, not a rising column (see chat:
|
||||
// two earlier attempts — a horizontal X wipe read as "right to left", then
|
||||
// a rising box read as "water filling a pool" — both wrong; this reads as
|
||||
// "bottom to top" on screen because +Z, this game's forward/"away from
|
||||
// camera" direction, projects toward the top of the screen under this
|
||||
// isometric-ish camera).
|
||||
// How high above the straight line to the Pay Zone the flight lobs, on top
|
||||
// of the ordinary from->to lerp.
|
||||
const DEPOSIT_FLIGHT_ARC_HEIGHT = 2;
|
||||
|
||||
// Fill overlay: a flat quad over the zone's own footprint, growing from its
|
||||
// near edge (fixed, closest to camera) toward the far edge as deposits come
|
||||
// in. Grows along world Z, not X or Y — under this camera, +Z (forward/
|
||||
// away-from-camera) projects toward the top of the screen, so a Z-growing
|
||||
// quad reads as "filling bottom to top."
|
||||
const FILL_COLOR = 0x5ce65c;
|
||||
const FILL_OPACITY = 0.55;
|
||||
// Sits just above the zone's own ground-level quad so it doesn't z-fight
|
||||
// with it, but still reads as "on the ground" rather than floating.
|
||||
// Sits just above the zone's own ground-level quad to avoid z-fighting.
|
||||
const FILL_Y_OFFSET = 0.02;
|
||||
|
||||
// Reuses the map's existing (already-visible) "UI_Interactive_Zone_02"
|
||||
// marker as both the pay zone's location and its visual — see CLAUDE.md.
|
||||
// ResourceC.getCollectedCount() keeps counting resources the moment
|
||||
// they're picked up near whatever crate dropped them (unrelated to this
|
||||
// zone); this controller separately drains that total into the zone,
|
||||
// resource by resource, only while the player is standing inside it.
|
||||
// Reuses the map's existing "UI_Interactive_Zone_02" marker as both the pay
|
||||
// zone's location and its visual. ResourceC.getCollectedCount() counts
|
||||
// resources the moment they're picked up, unrelated to this zone; this
|
||||
// controller separately drains that total into the zone, one at a time,
|
||||
// only while the player is standing inside it.
|
||||
export class PayZoneC {
|
||||
private static payZoneNode: Object3D | null = null;
|
||||
private static target: Object3D | null = null;
|
||||
// The zone's actual world-space footprint (an XZ rectangle), computed
|
||||
// once from its real geometry rather than guessed as a fixed radius — a
|
||||
// hardcoded radius previously undershot the visible dashed square, so
|
||||
// standing anywhere but right on its center pivot silently never
|
||||
// counted as "inside".
|
||||
// The zone's real world-space XZ footprint, computed from its actual
|
||||
// geometry rather than a guessed radius.
|
||||
private static zoneBounds: Box3 | null = null;
|
||||
private static closed = false;
|
||||
private static deposited = 0;
|
||||
@@ -74,10 +77,9 @@ export class PayZoneC {
|
||||
UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta));
|
||||
}
|
||||
|
||||
// A unit quad, later scaled/positioned per-frame in updateFillVisual — a
|
||||
// fixed width (full X span of the zone) but a depth (world Z) that grows
|
||||
// from 0 up to the zone's full Z span as `deposited` approaches
|
||||
// RESOURCES_TO_CLOSE_ZONE.
|
||||
// A unit quad, scaled/positioned per-frame in updateFillVisual — fixed
|
||||
// width (full X span of the zone), depth (world Z) grows from 0 to the
|
||||
// zone's full Z span as `deposited` approaches RESOURCES_TO_CLOSE_ZONE.
|
||||
private static buildFillMesh(bounds: Box3): Mesh {
|
||||
const mesh = new Mesh(
|
||||
new PlaneGeometry(1, 1),
|
||||
@@ -96,10 +98,9 @@ export class PayZoneC {
|
||||
return mesh;
|
||||
}
|
||||
|
||||
// Grows the fill quad from the zone's min-Z edge (fixed, nearest the
|
||||
// camera) toward its max-Z edge as `fraction` (0..1) increases — scaling
|
||||
// a centered quad also moves its center, so the Z position is re-derived
|
||||
// each time to keep that near edge pinned in place instead of drifting.
|
||||
// Grows the fill quad from the zone's min-Z edge toward its max-Z edge —
|
||||
// scaling a centered quad also moves its center, so the Z position is
|
||||
// re-derived each time to keep that near edge pinned instead of drifting.
|
||||
private static updateFillVisual() {
|
||||
if (!this.fillMesh || !this.zoneBounds) return;
|
||||
|
||||
@@ -111,20 +112,15 @@ export class PayZoneC {
|
||||
this.fillMesh.position.z = this.zoneBounds.min.z + filledDepth / 2;
|
||||
}
|
||||
|
||||
static getTargetWorldPosition(): Vector3 {
|
||||
return this.payZoneNode ? this.payZoneNode.getWorldPosition(new Vector3()) : new Vector3();
|
||||
}
|
||||
|
||||
// How many resources have actually been delivered so far — HudC reads
|
||||
// this (via an injected getter, not a direct import, to avoid a
|
||||
// HudC <-> PayZoneC import cycle) to show a live balance that rises on
|
||||
// HudC reads this (via an injected getter, not a direct import, to avoid
|
||||
// a HudC <-> PayZoneC import cycle) to show a live balance that rises on
|
||||
// collection and falls as resources leave for the Pay Zone.
|
||||
static getDepositedCount(): number {
|
||||
return this.deposited;
|
||||
}
|
||||
|
||||
// XZ containment against the zone's real footprint (ignores Y — the
|
||||
// zone lies flat on the ground, height doesn't matter).
|
||||
// zone lies flat on the ground).
|
||||
static isPlayerInside(playerPosition: Vector3): boolean {
|
||||
if (!this.zoneBounds) return false;
|
||||
return (
|
||||
@@ -146,55 +142,34 @@ export class PayZoneC {
|
||||
|
||||
this.depositCooldown = DEPOSIT_INTERVAL;
|
||||
this.deposited++;
|
||||
this.playDepositFlight(HudC.getWorldAnchorPosition(), this.payZoneNode);
|
||||
this.playDepositFlight(PlayerC.getHeadWorldPosition());
|
||||
this.updateFillVisual();
|
||||
|
||||
if (this.deposited >= RESOURCES_TO_CLOSE_ZONE) {
|
||||
this.closed = true;
|
||||
console.log(`[PayZoneC] zone full (${this.deposited} delivered) — closing`);
|
||||
this.playDisappear(this.payZoneNode);
|
||||
}
|
||||
}
|
||||
|
||||
// One resource, flying from the HUD counter (see chat: "від лічильника
|
||||
// до пей зони") into the zone itself — the player only needs to be
|
||||
// standing in the zone to trigger it, not to be the flight's origin.
|
||||
//
|
||||
// Traced via temporary logging (see chat): occasionally one of these
|
||||
// tweens never fires onComplete and freezes mid-flight forever — a
|
||||
// visible board-textured icon just hanging in the air (user report:
|
||||
// "текстура доски... постійно статично відображається"). Couldn't pin
|
||||
// down why inside tween.js/TweenC (a single, non-chained tween, added to
|
||||
// the group before .start() — the usual "chain() doesn't register the
|
||||
// second link" bug from Day 6 doesn't apply here). Rather than keep
|
||||
// digging, added a hard, redundant cleanup: a plain timeout that removes
|
||||
// the mesh regardless of whether the tween ever completes. Calling
|
||||
// removeFromScene twice (once from onComplete, once from the timeout, if
|
||||
// both fire) is harmless — the second call just finds no parent to
|
||||
// detach from.
|
||||
private static playDepositFlight(from: Vector3, payZoneNode: Object3D) {
|
||||
// One resource, flying from the player's head into the zone's real
|
||||
// geometric center — the player only needs to be standing in the zone to
|
||||
// trigger it, they're also the flight's origin.
|
||||
private static playDepositFlight(from: Vector3) {
|
||||
if (!this.zoneBounds) return;
|
||||
|
||||
const mesh = ResourceC.createVisual();
|
||||
const to = payZoneNode.getWorldPosition(new Vector3());
|
||||
// The zone's real geometric center, not the marker node's own pivot
|
||||
// (not guaranteed to sit exactly in the middle of the footprint).
|
||||
const to = new Vector3(
|
||||
(this.zoneBounds.min.x + this.zoneBounds.max.x) / 2,
|
||||
this.zoneBounds.min.y,
|
||||
(this.zoneBounds.min.z + this.zoneBounds.max.z) / 2
|
||||
);
|
||||
mesh.position.copy(from);
|
||||
ThreeC.addToScene(mesh);
|
||||
|
||||
// No rotation (see chat: "ротейту не має бути") — same as the
|
||||
// ground-to-HUD flight in ResourceC, this is position/scale only.
|
||||
//
|
||||
// Same perspective-compensation trick as ResourceC's toTarget phase,
|
||||
// just in reverse: this flight starts right at the HUD anchor (close
|
||||
// to the camera, see HudC.getWorldAnchorPosition) and moves out to the
|
||||
// Pay Zone (typically much farther away), so without this it would
|
||||
// visibly *shrink* as it recedes — pure perspective, not an actual
|
||||
// size change. Scaling by the ratio of current to starting
|
||||
// camera-distance cancels that, keeping it the one size the whole
|
||||
// flight (see chat: "розмір має бути таким самим як і в цій
|
||||
// анімації"). Anchored to RESOURCE_ICON_BASE_SCALE, not the mesh's own
|
||||
// default (1) — this token is a fresh ResourceC.createVisual(), not one
|
||||
// of ResourceC's own bounced-and-shrunk FlyingResources, so without
|
||||
// this it started noticeably larger than the resource that was just
|
||||
// sitting at that same HUD icon a moment ago (see chat: "дуже великий,
|
||||
// требя його уніфікувати").
|
||||
// Position/scale only, no rotation — same as ResourceC's ground-to-HUD
|
||||
// flight.
|
||||
const cameraPosition = CameraC_internal.getCamera().position;
|
||||
const startDistance = cameraPosition.distanceTo(from);
|
||||
|
||||
@@ -203,8 +178,11 @@ export class PayZoneC {
|
||||
.easing(Easing.Quadratic.InOut)
|
||||
.onUpdate(({ t }) => {
|
||||
mesh.position.lerpVectors(from, to, t);
|
||||
const currentDistance = cameraPosition.distanceTo(mesh.position);
|
||||
const perspectiveScale = startDistance > 1e-6 ? currentDistance / startDistance : 1;
|
||||
// Up-then-down lob layered on top of the straight lerp above — 0
|
||||
// at both ends, peaks at the midpoint.
|
||||
mesh.position.y += Math.sin(t * Math.PI) * DEPOSIT_FLIGHT_ARC_HEIGHT;
|
||||
|
||||
const perspectiveScale = perspectiveCompensationScale(cameraPosition, mesh.position, startDistance);
|
||||
mesh.scale.setScalar(perspectiveScale * RESOURCE_ICON_BASE_SCALE * DEPOSIT_FLIGHT_SCALE_MULTIPLIER);
|
||||
})
|
||||
.onComplete(() => ThreeC.removeFromScene(mesh));
|
||||
@@ -216,11 +194,7 @@ export class PayZoneC {
|
||||
}
|
||||
|
||||
// Shrinks the zone's own marker AND the fill overlay together, as one
|
||||
// piece — the fill previously just popped invisible instantly once the
|
||||
// marker's shrink finished (see chat: "прогрес заповненості не
|
||||
// зменшується разом з пей зоною"). Scaling the fill quad down from
|
||||
// whatever it currently is (full, since this only runs once the zone is
|
||||
// done) shrinks it toward its own center in lockstep with the marker.
|
||||
// piece, toward their own centers in lockstep.
|
||||
private static playDisappear(node: Object3D) {
|
||||
const fillMesh = this.fillMesh;
|
||||
const fillBaseScaleX = fillMesh?.scale.x ?? 0;
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
Delegate,
|
||||
Physics_internal,
|
||||
UpdateController,
|
||||
} from "@hitplay/playable_template";
|
||||
import { Physics_internal } from "@hitplay/playable_template";
|
||||
import { Box3, Mesh, Object3D, Quaternion, Vector3 } from "three";
|
||||
import { Body, Box, Sphere, Vec3 } from "cannon-es";
|
||||
|
||||
@@ -15,7 +11,6 @@ export enum PhysicsLayer {
|
||||
|
||||
export class PhysicsBody {
|
||||
private body: Body;
|
||||
private pair: PhysicsObjPair | null = null;
|
||||
|
||||
constructor(
|
||||
threeObj: Object3D,
|
||||
@@ -53,14 +48,6 @@ export class PhysicsBody {
|
||||
let worldQuat = threeObj.getWorldQuaternion(new Quaternion());
|
||||
this.body.quaternion.set(worldQuat.x, worldQuat.y, worldQuat.z, worldQuat.w);
|
||||
|
||||
// if you need sync three obj and physics body
|
||||
// if (isEnemy) {
|
||||
// let pair = new PhysicsObjPair(threeObj, this.body);
|
||||
// PhysicsC_Instance.addPhysicsPair(pair);
|
||||
|
||||
// this.pair = pair;
|
||||
// }
|
||||
|
||||
Physics_internal.physicsWorld &&
|
||||
Physics_internal.physicsWorld.addBody(this.body);
|
||||
|
||||
@@ -81,12 +68,6 @@ export class PhysicsBody {
|
||||
return new Box3().setFromObject(threeObj).getSize(new Vector3());
|
||||
}
|
||||
|
||||
disablePhysicsPair() {
|
||||
if (this.pair) {
|
||||
this.pair.destroyed = true;
|
||||
}
|
||||
}
|
||||
|
||||
getPhysicsBody() {
|
||||
return this.body;
|
||||
}
|
||||
@@ -99,31 +80,3 @@ export class PhysicsBody {
|
||||
(this.body as any) = null;
|
||||
}
|
||||
}
|
||||
|
||||
export class PhysicsObjPair {
|
||||
threeObj: Object3D;
|
||||
physicsObj: Body;
|
||||
destroyed: boolean;
|
||||
delegateId: null | Delegate<number>;
|
||||
|
||||
constructor(threeObj: Object3D, physicsObj: Body) {
|
||||
this.threeObj = threeObj;
|
||||
this.physicsObj = physicsObj;
|
||||
this.destroyed = false;
|
||||
|
||||
this.delegateId = UpdateController.Instance.onUpdate.addDelegate(() => {
|
||||
this.update();
|
||||
});
|
||||
}
|
||||
|
||||
update() {
|
||||
if (this.destroyed) return;
|
||||
|
||||
if (this.threeObj && this.physicsObj) {
|
||||
// @ts-ignore
|
||||
this.threeObj.position.copy(this.physicsObj.position);
|
||||
// @ts-ignore
|
||||
this.threeObj.quaternion.copy(this.physicsObj.quaternion);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,9 +195,15 @@ export class PlayerC {
|
||||
return this.batInHand;
|
||||
}
|
||||
|
||||
// Where PayZoneC's deposit flight launches from — PLAYER_HEIGHT is the
|
||||
// measured rig height (see its own comment above), not a guessed offset.
|
||||
static getHeadWorldPosition(): Vector3 {
|
||||
return this.object.position.clone().add(new Vector3(0, PLAYER_HEIGHT, 0));
|
||||
}
|
||||
|
||||
// Exactly one of {pistol, bat} is ever in hand, and the other is stowed —
|
||||
// the pistol has no separate "in hand" prop to show (see PlayerC chat
|
||||
// notes), so its own single mesh just toggles at its holster spot instead.
|
||||
// the pistol has no separate "in hand" prop, so its own single mesh just
|
||||
// toggles at its holster spot instead.
|
||||
private static equipPistol() {
|
||||
this.pistol.visible = true;
|
||||
this.batInHand.visible = false;
|
||||
@@ -335,7 +341,7 @@ export class PlayerC {
|
||||
// it while idle, rather than resetting to face forward the moment input
|
||||
// stops. rotateTowards caps the step at MAX_TURN_SPEED * delta radians
|
||||
// instead of interpolating a percentage of the remaining angle — except
|
||||
// for a near-180° reversal, which snaps immediately (see chat).
|
||||
// for a near-180° reversal, which snaps immediately.
|
||||
if (this.object.quaternion.angleTo(this.facingRotation) > OPPOSITE_TURN_THRESHOLD) {
|
||||
this.object.quaternion.copy(this.facingRotation);
|
||||
} else {
|
||||
@@ -349,7 +355,7 @@ export class PlayerC {
|
||||
// breakable it's stopped next to), or null otherwise — CameraFollowC uses
|
||||
// this to pan toward the same point, since it's a fixed world position
|
||||
// (not a rotation), so it can't produce the arc/orbit motion a
|
||||
// facing/quaternion-driven look-ahead did (see CameraFollowC chat notes).
|
||||
// facing/quaternion-driven look-ahead would.
|
||||
static getFacingTarget(): Vector3 | null {
|
||||
return this.facingTarget;
|
||||
}
|
||||
@@ -423,11 +429,10 @@ export class PlayerC {
|
||||
}
|
||||
|
||||
// Every breakable prop within interaction range AND within the frontal
|
||||
// swing arc (same cone as isFacing, see chat: hitting crates standing
|
||||
// behind the player didn't make sense for a forward bat swing) —
|
||||
// "unlimited targets in the hit zone" per the design, just not a full
|
||||
// 360° one. Non-breakable obstacles (walls) are excluded: there's
|
||||
// nothing for the attack to do to them.
|
||||
// swing arc (same cone as isFacing — a forward bat swing shouldn't hit
|
||||
// crates standing behind the player) — unlimited targets within that
|
||||
// cone, not a full 360° radius. Non-breakable obstacles (walls) are
|
||||
// excluded: there's nothing for the attack to do to them.
|
||||
private static findBreakableTargetsInZone(): ZoneTarget[] {
|
||||
const bounds = this.getCollisionBounds(this.object.position).expandByScalar(INTERACTION_REACH);
|
||||
const targets: ZoneTarget[] = [];
|
||||
|
||||
+11
-17
@@ -5,25 +5,19 @@ import { ThreeC } from "./ThreeC";
|
||||
import vfxLootableHitJson from "../resources/vfx/VFX_Lootable_Hit.json";
|
||||
import vfxLootableDestroyJson from "../resources/vfx/VFX_Lootable_Destroy.json";
|
||||
|
||||
// Real designer-authored VFX for breakable props — found sitting unused in
|
||||
// temp/ (exported from the quarks.art editor as plain Object3D.toJSON()
|
||||
// groups, each a couple of ParticleEmitter children with their own tuned
|
||||
// shape/speed/color/behaviors, referencing real textures like
|
||||
// "novalines.webp" for the ray-streak look). Replaces the earlier
|
||||
// hand-built SparkFxC, whose particles read as a soft dot-blob rather than
|
||||
// the rays the reference screenshots called for — no amount of tuning a
|
||||
// from-scratch system was going to match a real authored asset anyway.
|
||||
// Designer-authored VFX for breakable props — quarks.art editor exports
|
||||
// (plain Object3D.toJSON() groups, each a couple of ParticleEmitter
|
||||
// children with their own tuned shape/speed/color/behaviors).
|
||||
//
|
||||
// Loaded via plain Vite JSON imports + three.quarks' own QuarksLoader
|
||||
// directly, NOT the SDK's quarksLoader()/ConvertToBase64WhenRelease
|
||||
// (the pattern meshes.ts/images.ts use): quarksLoader() hardcodes
|
||||
// `base64String.split(",")[1]` then atob() on it, which assumes it's
|
||||
// always handed an actual base64 data: URI — true only once the build-time
|
||||
// AST plugin rewrites the ConvertToBase64WhenRelease() call; in plain
|
||||
// `vite dev` that call is a no-op passthrough returning a bare path
|
||||
// string, which has no comma and would break the split/atob chain. A
|
||||
// static JSON import sidesteps that entirely — Vite inlines JSON content
|
||||
// into the bundle natively in every mode (dev, build, and the single-file
|
||||
// directly, NOT the SDK's quarksLoader()/ConvertToBase64WhenRelease (the
|
||||
// pattern meshes.ts/images.ts use): quarksLoader() hardcodes
|
||||
// `base64String.split(",")[1]` then atob() on it, assuming it's always
|
||||
// handed an actual base64 data: URI — true only once the build-time AST
|
||||
// plugin rewrites the ConvertToBase64WhenRelease() call; in plain
|
||||
// `vite dev` that call is a no-op passthrough returning a bare path string
|
||||
// with no comma, breaking the split/atob chain. A static JSON import
|
||||
// sidesteps that — Vite inlines JSON natively in every mode (dev, build,
|
||||
// export), no fetch, no base64 round-trip, no dev/build inconsistency.
|
||||
const CLEANUP_DELAY_MS = 1500; // covers the longest emitter's duration+life in either prefab
|
||||
|
||||
|
||||
+63
-138
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,20 @@
|
||||
/* "gameFont" is referenced everywhere below (html, ui.css's widgets) —
|
||||
this @font-face is what makes it resolve to a real font instead of
|
||||
silently falling back to sans-serif. Vite's own asset pipeline inlines
|
||||
this url() as base64 automatically (assetsInlineLimit is effectively
|
||||
unlimited in this project's vite config) — no
|
||||
ConvertToBase64WhenRelease/images.ts-style indirection needed, that
|
||||
pattern is for assets loaded through JS/the SDK's resource loader, not
|
||||
plain CSS url() references. Only one weight exists in the source file
|
||||
(Black/900), so it's the one face used regardless of which font-weight
|
||||
a rule asks for. */
|
||||
@font-face {
|
||||
font-family: "gameFont";
|
||||
src: url("../resources/fonts/PassionOne-Black.otf") format("opentype");
|
||||
font-weight: 400 900;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
@@ -109,6 +126,29 @@ canvas {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* The 3D canvas is sized via ThreeC_internal's renderer.setSize(
|
||||
window.screenSize.width, window.screenSize.height) — in this project's
|
||||
default Adaptive orientation mode (see configUIParams/orientation.ts),
|
||||
window.screenSize equals the raw physical viewport, so the canvas fills
|
||||
the ENTIRE landscape screen edge-to-edge. Without this override,
|
||||
#ui/#editor stay pinned to the portrait-shaped 9:16 box above regardless
|
||||
of orientation, so every DOM HUD widget renders inside that narrow
|
||||
centered strip while the 3D world beneath it uses the full wide screen.
|
||||
This matches #ui/#editor to the same full-viewport footprint the canvas
|
||||
already has, so the two coordinate systems line up. Every widget's own
|
||||
`@media (orientation: landscape)` block in ui.css positions itself
|
||||
relative to THIS wider box. */
|
||||
@media (orientation: landscape) {
|
||||
#ui,
|
||||
#editor {
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform: none;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
#modal {
|
||||
z-index: 10000;
|
||||
}
|
||||
@@ -129,6 +169,48 @@ canvas {
|
||||
z-index: 900;
|
||||
}
|
||||
|
||||
/* The ring/thumb COLORS are set via JoystickC.init's `color` option (see
|
||||
beforeResourcesLoadedCb.ts) — nipplejs applies those directly as inline
|
||||
`background`, including the "back" ring's conic-gradient highlight that
|
||||
tracks the thumb (--joy-angle/--joy-force, driven from
|
||||
beforeResourcesLoadedCb.ts). Only what `color` can't express lives here,
|
||||
scoped under #joystick_zone so it can never affect anything else:
|
||||
- the ring's transparent middle — a plain `background` fill is a solid
|
||||
disc, so the "ring" shape (transparent hole + faded outer edge) comes
|
||||
from a mask instead, layered independently on top of whatever
|
||||
color/gradient `background` paints;
|
||||
- overriding nipplejs's own hardcoded inline `opacity:.5` on the thumb
|
||||
(see nipplejs's Joystick.ts buildEl) — needs !important since a
|
||||
same-specificity inline style otherwise always wins over an external
|
||||
rule regardless of source order.
|
||||
vmin/vw, not --lvh: JoystickC.init deliberately keeps this zone in
|
||||
physical screen space even under an orientation lock, so it should
|
||||
scale with the raw viewport, not the logical/rotated one everything
|
||||
under #ui uses. */
|
||||
#joystick_zone .front {
|
||||
opacity: 1 !important;
|
||||
/* nipplejs hardcodes the thumb at exactly half of JoystickC.init's
|
||||
"size" option, with no separate option to size it independently —
|
||||
this decouples the thumb's own size from the ring's "size". Override
|
||||
both width/height AND the margins together: nipplejs uses a negative
|
||||
margin equal to half ITS OWN thumb size to re-center it, so changing
|
||||
one without the other offsets it from the actual drag anchor point. */
|
||||
width: 35px !important;
|
||||
height: 35px !important;
|
||||
margin-left: -17.5px !important;
|
||||
margin-top: -17.5px !important;
|
||||
}
|
||||
|
||||
#joystick_zone .back {
|
||||
/* Absolute px stops, not percentages of the circle's own radius — with
|
||||
percentages, changing "size" (see beforeResourcesLoadedCb.ts) would
|
||||
stretch the ring band's thickness along with it. These numbers are
|
||||
independent of "size": retune them here directly if the ring ever
|
||||
needs to be thicker/thinner or sit closer to the outer edge. */
|
||||
-webkit-mask-image: radial-gradient(circle, transparent 43.4px, black 44.8px, black 46.9px, transparent 48.3px);
|
||||
mask-image: radial-gradient(circle, transparent 43.4px, black 44.8px, black 46.9px, transparent 48.3px);
|
||||
}
|
||||
|
||||
@media (orientation: landscape) {
|
||||
#videoPlayable {
|
||||
height: 100vh;
|
||||
|
||||
+325
-18
@@ -1,8 +1,62 @@
|
||||
#ui {
|
||||
/* flex-basis: 60%; */
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Every HUD widget's containing block, not #ui itself — one shared,
|
||||
orientation-aware margin instead of baking an edge-gap into every
|
||||
widget's own top/right/bottom/left.
|
||||
|
||||
position:absolute + inset (not padding!): padding on an ancestor does
|
||||
NOT inset position:absolute descendants — their containing block is the
|
||||
padding EDGE, so top:0/right:0 on a child sits flush against the
|
||||
ancestor's border regardless of padding (a well-known CSS trap).
|
||||
Shrinking this box's own extent via inset is what makes a child's "0"
|
||||
mean "0 from the safe edge". Widgets keep whatever extra offset they
|
||||
individually need beyond this shared baseline. */
|
||||
#ui-safe-area {
|
||||
--ui-safe-margin: 3.5%; /* portrait */
|
||||
position: absolute;
|
||||
inset: var(--ui-safe-margin);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (orientation: landscape) {
|
||||
#ui-safe-area {
|
||||
--ui-safe-margin: 2.5%;
|
||||
}
|
||||
}
|
||||
|
||||
/* House convention for every HUD widget below:
|
||||
1. The outer plate never sets width+height independently — always
|
||||
`width` (or a `--*-width` custom property driving it) plus
|
||||
`aspect-ratio: <real source image ratio>`, so it can't drift out of
|
||||
the original art's proportions at any width.
|
||||
2. Every child inside that plate is sized/positioned off of THAT one
|
||||
width — `%`/`left`/`top`/`width` are relative to the plate's own box
|
||||
for free, but anything not naturally relative to width (font-size,
|
||||
text-shadow/box-shadow offsets) is pinned via
|
||||
`calc(var(--*-width) * ratio)` or `em` (em rides along with
|
||||
font-size, itself tied to the width variable). Child `<img>` tags
|
||||
also get an explicit `aspect-ratio` matching their real source
|
||||
dimensions. Net effect: changing one `--*-width` rescales the whole
|
||||
widget — plate, icon, text, shadow — as one proportional unit.
|
||||
3. That root `--*-width` is set from `--lvh` (logical viewport height),
|
||||
never a raw `vh`/`vw`/`vmin`. Under the SDK's orientation lock
|
||||
(OrientationC), rotating the device wraps the page in a
|
||||
`transform: rotate(-90deg)` box so it still LOOKS portrait — raw
|
||||
viewport units ignore ancestor transforms and keep measuring the
|
||||
PHYSICAL screen, so anything sized in plain `vh` comes out wrong the
|
||||
instant the lock rotates things. OrientationC's `--lvw`/`--lvh` are
|
||||
the rotation-aware equivalents (equal to physical vw/vh when
|
||||
unlocked, so this costs nothing in that mode).
|
||||
4. Each widget also gets an explicit
|
||||
`@media (orientation: landscape) { ... }` block re-declaring its
|
||||
position and `--*-width` — a real media query reacts to the
|
||||
viewport's own aspect regardless of the lock/JS layer, so it's the
|
||||
more direct knob for landscape values independent of portrait. */
|
||||
|
||||
/* HudC — resource counter plate (dark background + diagonal wood-plank
|
||||
icon baked into resourceCounterBgSrc, see images.ts/temp/Tool_15.webp).
|
||||
184x71 source aspect ratio (~2.59:1) — aspect-ratio below derives height
|
||||
@@ -24,22 +78,30 @@
|
||||
.resource-hud {
|
||||
/* Single source of truth for the plate's scale — width below and the
|
||||
count's font-size (see .resource-hud__count) both derive from this one
|
||||
value, so resizing the whole HUD is a one-line change instead of
|
||||
hunting down every place a size was independently tuned in vh. */
|
||||
--hud-width: 9vh;
|
||||
value. --lvh, not a raw vh — see house convention rule 3 above. */
|
||||
--hud-width: calc(var(--lvh, 100vh) * 0.09);
|
||||
position: absolute;
|
||||
top: 6%;
|
||||
right: 10%;
|
||||
top: 15.7%;
|
||||
right: 0%;
|
||||
width: var(--hud-width);
|
||||
aspect-ratio: 184 / 71;
|
||||
pointer-events: none;
|
||||
z-index: 50;
|
||||
/* Runs once as soon as the element mounts — no JS timing/opacity code
|
||||
needed, the browser drives it (Day 8: "use built-in animations instead
|
||||
of changing elements through code"). */
|
||||
needed, the browser drives it. */
|
||||
animation: resource-hud-enter 400ms ease-out;
|
||||
}
|
||||
|
||||
/* Landscape override — see house convention rule 4. Mirrors the portrait
|
||||
values above as a starting point; retune once seen live. */
|
||||
@media (orientation: landscape) {
|
||||
.resource-hud {
|
||||
--hud-width: calc(var(--lvh, 100vh) * 0.16);
|
||||
top: 5%;
|
||||
right: 0%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes resource-hud-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -60,13 +122,11 @@
|
||||
}
|
||||
|
||||
/* Toggled by HudC whenever the displayed number actually changes — a pure
|
||||
CSS "pop" of the whole plate (background + number together), not a
|
||||
JS-driven tween (Day 8 technical note) and not just the digit (see
|
||||
chat: "не тільки на каунтер"). HudC removes and re-adds this class
|
||||
(forcing a reflow in between) each time, which is what lets the
|
||||
animation restart cleanly even if it retriggers before the previous run
|
||||
finished — e.g. PayZoneC draining one resource every 0.3s while the
|
||||
player stands in the zone. */
|
||||
CSS "pop" of the whole plate (background + number together), not just
|
||||
the digit. HudC removes and re-adds this class (forcing a reflow in
|
||||
between) each time, which lets the animation restart cleanly even if it
|
||||
retriggers before the previous run finished — e.g. PayZoneC draining one
|
||||
resource every 0.3s while the player stands in the zone. */
|
||||
.resource-hud__plate.is-bumping {
|
||||
animation: resource-hud-bump 220ms ease-out;
|
||||
}
|
||||
@@ -85,18 +145,265 @@
|
||||
|
||||
.resource-hud__count {
|
||||
position: absolute;
|
||||
left: 16%;
|
||||
top: 46%;
|
||||
left: 22%;
|
||||
top: 48%;
|
||||
width: 46%;
|
||||
transform: translateY(-50%);
|
||||
text-align: center;
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
font-weight: normal;
|
||||
/* Scales with --hud-width (set on .resource-hud, inherited down through
|
||||
.resource-hud__plate) instead of a fixed vh value, so it tracks
|
||||
whatever the plate is currently sized to — ratio matches the 2.1vh
|
||||
that read well at the original 12vh plate width (2.1/12 ≈ 0.175). */
|
||||
font-size: calc(var(--hud-width) * 0.175);
|
||||
font-family: gameFont, sans-serif;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||
/* em, not a fixed px offset — rides along with font-size (already tied
|
||||
to --hud-width above), so the shadow scales with everything else
|
||||
instead of staying a fixed size while the text around it grows/shrinks. */
|
||||
text-shadow: 0 0.05em 0.1em rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
/* WeaponHudC — bottom-left octagon plate (ZombiePunk_Icon-Bottom.webp) +
|
||||
weapon icon (Tool_2.webp, the bat) + "LVL 0" text. Square source art, so
|
||||
aspect-ratio:1 instead of a second fixed dimension — same reasoning as
|
||||
.resource-hud above. */
|
||||
.weapon-hud {
|
||||
/* Single source of truth for the plate's scale, same pattern as
|
||||
--hud-width on .resource-hud — the icon size and level text's
|
||||
font-size both derive from this one value. --lvh, not a raw vh —
|
||||
see house convention rule 3 above. */
|
||||
--weapon-hud-width: calc(var(--lvh, 100vh) * 0.08);
|
||||
position: absolute;
|
||||
left: 0%;
|
||||
bottom: 26%;
|
||||
width: var(--weapon-hud-width);
|
||||
aspect-ratio: 1;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100%;
|
||||
pointer-events: none;
|
||||
z-index: 50;
|
||||
animation: weapon-hud-enter 400ms ease-out;
|
||||
}
|
||||
|
||||
/* Landscape override — see house convention rule 4. Mirrors the portrait
|
||||
values above as a starting point; retune once seen live. */
|
||||
@media (orientation: landscape) {
|
||||
.weapon-hud {
|
||||
--weapon-hud-width: calc(var(--lvh, 100vh) * 0.15);
|
||||
left: 0%;
|
||||
bottom: 28%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes weapon-hud-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(1rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.weapon-hud__icon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
width: 72%;
|
||||
/* Tool_2.webp's real source is 1024x1024 — explicit, not left to the
|
||||
browser's default img sizing, so it can't silently distort even if
|
||||
something else sets an explicit height on it later. */
|
||||
aspect-ratio: 1;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.weapon-hud__level {
|
||||
position: absolute;
|
||||
bottom: 6%;
|
||||
left: 7%;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: #ffffff;
|
||||
font-weight: normal;
|
||||
font-size: calc(var(--weapon-hud-width) * 0.20);
|
||||
font-family: gameFont, sans-serif;
|
||||
/* em, not px — see .resource-hud__count's identical note above. */
|
||||
text-shadow: 0 0.05em 0.1em rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
/* ZombieInvasionHudC — top-center title + timer bar (see reference
|
||||
screenshot, temp/ResourceBackground_Wood.webp). No track/bar art was
|
||||
supplied (only the head icon), so unlike .resource-hud/.weapon-hud
|
||||
above, .zombie-invasion__bar is a plain CSS pill, not a real background
|
||||
image — flag if that reads as visually off from the reference.
|
||||
Everything still keys off --zombie-invasion-width, same convention as
|
||||
the other widgets. */
|
||||
.zombie-invasion {
|
||||
/* --lvh, not a raw vh — see house convention rule 3 above. */
|
||||
--zombie-invasion-width: calc(var(--lvh, 100vh) * 0.27);
|
||||
position: absolute;
|
||||
top: 0%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: var(--zombie-invasion-width);
|
||||
pointer-events: none;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
/* Landscape override — see house convention rule 4. Mirrors the portrait
|
||||
values above as a starting point; retune once seen live. */
|
||||
@media (orientation: landscape) {
|
||||
.zombie-invasion {
|
||||
--zombie-invasion-width: calc(var(--lvh, 100vh) * 0.47);
|
||||
top: 0%;
|
||||
}
|
||||
}
|
||||
|
||||
.zombie-invasion__title {
|
||||
text-align: center;
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
font-size: calc(var(--zombie-invasion-width) * 0.12);
|
||||
-webkit-text-stroke: calc(var(--zombie-invasion-width) * 0.004) #000000;
|
||||
text-shadow: 0 0.05em 0.1em rgba(0, 0, 0, 0.6);
|
||||
margin: 0 0 calc(var(--zombie-invasion-width) * 0.045) 0;
|
||||
}
|
||||
|
||||
.zombie-invasion__bar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: calc(var(--zombie-invasion-width) * 0.11);
|
||||
/* Dark, empty-track color — the green fill (.zombie-invasion__fill) is a
|
||||
separate element layered on top, not this element's own background. A
|
||||
solid green background here instead would make the bar always look
|
||||
100% full regardless of actual progress. */
|
||||
background: #16240f;
|
||||
border: calc(var(--zombie-invasion-width) * 0.012) solid #17390f;
|
||||
border-radius: 999px;
|
||||
box-shadow: inset 0 0 calc(var(--zombie-invasion-width) * 0.02) rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Toggled once, ~6s before a 120s timer completes (see
|
||||
ZombieInvasionHudC.startTimer) — a slow, continuous pulse to flag "this
|
||||
is about to fill," with no code turning it back off once the timer
|
||||
finishes. Same duration on both the bar's scale-pulse and the flash's
|
||||
opacity-pulse so they read as one synced beat. */
|
||||
.zombie-invasion__bar.is-almost-full {
|
||||
animation: zombie-invasion-pulse 0.7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes zombie-invasion-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
}
|
||||
|
||||
/* Clips the fill/flash to the pill shape without also clipping the head
|
||||
below — the head has to live OUTSIDE this wrapper (as .zombie-invasion__bar's
|
||||
other direct child, not this one's) since it's now deliberately bigger
|
||||
than the bar itself and needs to overhang above/below it. */
|
||||
.zombie-invasion__track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Grows from the right edge toward the left, tracking the head's position
|
||||
exactly: the head moves from left:100% to left:0% over TIMER_DURATION_S,
|
||||
this grows from width:0% to width:100% over the same duration starting
|
||||
at the same instant (see ZombieInvasionHudC.startTimer) — since it's
|
||||
anchored with `right:0`, its *left* edge at any moment is always
|
||||
`100% - width`, which is exactly the head's current `left` value. They
|
||||
stay in lockstep without needing any per-frame sync code. */
|
||||
.zombie-invasion__fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: #5ce65c; /* same green as PayZoneC's fill quad — project-wide consistency */
|
||||
transition-property: width;
|
||||
transition-timing-function: linear;
|
||||
}
|
||||
|
||||
.zombie-invasion__flash {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #ff3b30;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.zombie-invasion__flash.is-almost-full {
|
||||
animation: zombie-invasion-flash 0.7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes zombie-invasion-flash {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
/* Peak opacity kept low — a subtle warning tint, not a hard strobe. */
|
||||
50% {
|
||||
opacity: 0.32;
|
||||
}
|
||||
}
|
||||
|
||||
.zombie-invasion__head {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
/* Right edge at the start — see ZombieInvasionHudC.startTimer, which
|
||||
flips this to 0% (left edge) once, letting transition-duration below
|
||||
carry it there over TIMER_DURATION_S. */
|
||||
left: 100%;
|
||||
/* Bigger than the bar's own height (0.15, above) on purpose — meant to
|
||||
visibly overhang the pill above/below, not sit flush inside it. */
|
||||
width: calc(var(--zombie-invasion-width) * 0.17);
|
||||
aspect-ratio: 1;
|
||||
transform: translate(-50%, -50%);
|
||||
transition-property: left;
|
||||
transition-timing-function: linear;
|
||||
filter: drop-shadow(0 0 calc(var(--zombie-invasion-width) * 0.01) rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
|
||||
/* InstallBanner (SDK component — @hitplay/playable_template's
|
||||
InstallBanner.ts, wired up in beforeResourcesLoadedCb.ts) — a light
|
||||
visual restyle only, NOT a rebuild: it has real click ->
|
||||
AnalyticsC.sendDownload() -> Template.end() wiring and its own
|
||||
designer-configurable upload_file/position system.
|
||||
|
||||
Everything here only ADDS properties the SDK's own injected <style>
|
||||
(built at runtime, appended to <head> after this stylesheet loads)
|
||||
doesn't already set on these classes — .bannerLogo/.bannerButton/
|
||||
.bannerButtonText have no filter/text-stroke/text-shadow of their own,
|
||||
so there's nothing here to lose a cascade fight against. Position/size
|
||||
(left/top/height/--*Scale) stay fully owned by InstallBanner's own
|
||||
JS/CSS — not touched. */
|
||||
.bannerLogo {
|
||||
/* --lvh, not raw vmin — same rotation-safety reasoning as house
|
||||
convention rule 3 above (vmin has the identical problem as vh/vw). */
|
||||
filter: drop-shadow(0 calc(var(--lvh, 100vh) * 0.003) calc(var(--lvh, 100vh) * 0.006) rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
|
||||
/* This rule is dead in a real build — the SDK's own minifier deduplicates
|
||||
the 4-times-repeated "bannerButton" substring (itself +
|
||||
Holder/Img/Text), so the bare class name no longer exists verbatim in
|
||||
the minified bundle for purgecss to find, and purgecss strips this rule
|
||||
as unused. .bannerLogo/.bannerButtonText survive (only 2 derivatives
|
||||
each, below the dedup threshold). Kept here for clarity/symmetry, not
|
||||
because it takes effect. */
|
||||
.bannerButton {
|
||||
filter: drop-shadow(0 calc(var(--lvh, 100vh) * 0.003) calc(var(--lvh, 100vh) * 0.006) rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
|
||||
.bannerButtonText {
|
||||
font-weight: bold;
|
||||
-webkit-text-stroke: 0.05em #000000;
|
||||
text-shadow: 0 0.05em 0.1em rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
+11
-1
@@ -11,6 +11,7 @@
|
||||
|
||||
<link href="./css/main.css" rel="stylesheet" />
|
||||
<link href="./css/ui.css" rel="stylesheet" />
|
||||
<link href="./css/health-bar.css" rel="stylesheet" />
|
||||
<style id="animations"></style>
|
||||
<!-- ⛔ DO NOT EDIT: critical loader cover. Full-screen opaque so the game
|
||||
never flashes before @hitplay/playable_template paints the loader.
|
||||
@@ -29,7 +30,16 @@
|
||||
</div>
|
||||
|
||||
<div id="constructor">
|
||||
<div id="ui"></div>
|
||||
<div id="ui">
|
||||
<!-- All game HUD widgets (HudC/WeaponHudC/ZombieInvasionHudC) mount
|
||||
here, not directly into #ui — see ui.css's #ui-safe-area rule:
|
||||
this box is #ui inset by --ui-safe-margin (separate portrait/
|
||||
landscape values), so every widget's own top/right/bottom/left
|
||||
is measured from an edge that's already pulled in from the true
|
||||
viewport edge by one shared, orientation-aware amount instead
|
||||
of each widget baking its own edge-margin into its own numbers. -->
|
||||
<div id="ui-safe-area"></div>
|
||||
</div>
|
||||
<div id="editor"></div>
|
||||
</div>
|
||||
<script id="dev-start">
|
||||
|
||||
Binary file not shown.
@@ -6,3 +6,15 @@ import { ConvertToBase64WhenRelease } from "@hitplay/ads_common";
|
||||
// CSS-drawn pill background; the plank icon is part of this image, not a
|
||||
// separate <img>.
|
||||
export const resourceCounterBgSrc = ConvertToBase64WhenRelease("./resource_counter_bg.webp");
|
||||
|
||||
// Weapon-level HUD widget (bottom-left octagon plate + weapon icon + "LVL"
|
||||
// text, see reference screenshot) — see WeaponHudC.
|
||||
export const weaponHudBgSrc = ConvertToBase64WhenRelease("./weapon_hud_bg.webp");
|
||||
export const weaponIconBatSrc = ConvertToBase64WhenRelease("./weapon_icon_bat.webp");
|
||||
|
||||
// Zombie-head marker for the top "ZOMBIE INVASION" timer bar (see
|
||||
// ZombieInvasionHudC) — source is temp/REF_1.png, despite the .png name
|
||||
// it's actually WebP data (RIFF/WEBP header, confirmed by reading the raw
|
||||
// bytes), so it's stored here with a matching .webp extension instead of
|
||||
// carrying the misleading source extension forward.
|
||||
export const zombieHeadIconSrc = ConvertToBase64WhenRelease("./zombie_head_icon.webp");
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
@@ -36,6 +36,70 @@ export const beforeResourcesLoadedCb = () => {
|
||||
restJoystick: true,
|
||||
restOpacity: 0,
|
||||
follow: false,
|
||||
// nipplejs's own default is 100; the inner thumb is always exactly
|
||||
// half of this by nipplejs's own rule — irrelevant here since .front's
|
||||
// size is overridden independently in main.css.
|
||||
size: 105,
|
||||
// "front" (the thumb/finger position) is a single flat color. Its
|
||||
// actual on-screen SIZE is overridden separately in main.css, since
|
||||
// nipplejs ties it to a hardcoded half of "size" above with no
|
||||
// independent option. "back" (the ring) is a single flat, darkened
|
||||
// color around most of its circumference, except a wide arc right
|
||||
// where the thumb sits, which brightens toward the thumb's own color
|
||||
// as if lit by it. --joy-angle (WHERE) and --joy-force (HOW STRONG)
|
||||
// are live CSS vars driven from onJoysticMove below. The ring's
|
||||
// transparent middle and the thumb's forced-opaque override
|
||||
// (nipplejs hardcodes the thumb at 50% opacity) also live in
|
||||
// main.css, since `color` can't express either.
|
||||
//
|
||||
// Alpha is fixed (not interpolated to 0) at every stop, only the RGB
|
||||
// channels shift with --joy-force — interpolating alpha down to 0
|
||||
// at rest made the highlight's position read as a see-through gap
|
||||
// instead of matching the base color. Alpha itself still ramps up
|
||||
// slightly with force (0.75 -> 0.95) so the highlight reads as the
|
||||
// thumb's actual saturated color rather than diluted by the sandy
|
||||
// background showing through it.
|
||||
color: {
|
||||
back: "conic-gradient(from var(--joy-angle, 0deg), rgba(calc(70 + 185 * var(--joy-force, 0)), calc(55 + 85 * var(--joy-force, 0)), calc(45 - 5 * var(--joy-force, 0)), calc(0.75 + 0.2 * var(--joy-force, 0))) 0deg, rgba(70, 55, 45, 0.75) 85deg, rgba(70, 55, 45, 0.75) 275deg, rgba(calc(70 + 185 * var(--joy-force, 0)), calc(55 + 85 * var(--joy-force, 0)), calc(45 - 5 * var(--joy-force, 0)), calc(0.75 + 0.2 * var(--joy-force, 0))) 360deg)",
|
||||
front: "#ff8c28",
|
||||
},
|
||||
});
|
||||
|
||||
// Keeps --joy-angle/--joy-force (read by the conic-gradient above)
|
||||
// tracking the thumb in real time.
|
||||
//
|
||||
// Angle: uses the thumb's own pixel offset from center (frontPosition —
|
||||
// what nipplejs itself uses to `translate()` the thumb) through
|
||||
// Math.atan2, not nipplejs's own `angle` field (flipped into a
|
||||
// "human-readable" convention internally and rotated again for
|
||||
// orientation-lock logical space — neither matches plain CSS/screen
|
||||
// angle math). atan2 and CSS conic-gradient both sweep clockwise as the
|
||||
// angle increases, but their zero points differ by 90° (atan2's 0° is
|
||||
// 3 o'clock/east; conic-gradient's `from 0deg` is 12 o'clock/north) — the
|
||||
// fix is a flat +90 added once, not a sign flip.
|
||||
//
|
||||
// Force: nipplejs documents `force` as normalized 0..1, but it's
|
||||
// actually computed from the RAW pre-clamp touch distance, while
|
||||
// frontPosition (and so the angle above) uses the CLAMPED distance — so
|
||||
// force keeps climbing past 1 the further you drag beyond the ring's
|
||||
// edge even though the thumb itself is visibly pinned there. Every
|
||||
// calc() above assumes force is 0..1, so it's clamped once here rather
|
||||
// than in every calc() in the gradient string.
|
||||
//
|
||||
// `data.instance` isn't in the SDK's own (stale) type declaration for
|
||||
// this event, though it's really there on every nipplejs 1.x payload —
|
||||
// the cast below bridges that gap.
|
||||
JoystickC.onJoysticMove.addDelegate(({ data }) => {
|
||||
const { instance, force } = data as unknown as {
|
||||
instance?: { frontPosition: { x: number; y: number }; ui: { el: HTMLElement } };
|
||||
force?: number;
|
||||
};
|
||||
if (!instance) return;
|
||||
const { x, y } = instance.frontPosition;
|
||||
const angleDeg = Math.atan2(y, x) * (180 / Math.PI) + 90;
|
||||
const clampedForce = Math.min(Math.max(force ?? 0, 0), 1);
|
||||
instance.ui.el.style.setProperty("--joy-angle", `${angleDeg}deg`);
|
||||
instance.ui.el.style.setProperty("--joy-force", `${clampedForce}`);
|
||||
});
|
||||
|
||||
Template.updateVariableConfig.addDelegate(([category, variable, value]) => {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { AdditiveBlending, Color, MeshBasicMaterial } from "three";
|
||||
|
||||
// Crate/resource meshes use unlit MeshBasicMaterial already at pure white
|
||||
// (their texture supplies the actual color) — lerping that color toward
|
||||
// white is a silent no-op, which is why animating a mesh's own
|
||||
// material.color never produces a visible flash. A same-geometry clone
|
||||
// layered on top with additive blending, opacity animated instead of
|
||||
// color, works regardless of the base mesh's own color/texture.
|
||||
export const FLASH_COLOR = new Color(0xffffff);
|
||||
|
||||
export function createFlashOverlayMaterial(): MeshBasicMaterial {
|
||||
return new MeshBasicMaterial({
|
||||
color: FLASH_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
blending: AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Vector3 } from "three";
|
||||
|
||||
// Cancels pure perspective foreshortening/growth as an object's distance
|
||||
// from the camera changes over an animation: scaling by (current / starting
|
||||
// distance) keeps its on-screen size pinned to whatever it was when the
|
||||
// flight started, regardless of how the world-space distance to the camera
|
||||
// changes in between.
|
||||
export function perspectiveCompensationScale(cameraPosition: Vector3, currentPoint: Vector3, startDistance: number): number {
|
||||
const currentDistance = cameraPosition.distanceTo(currentPoint);
|
||||
return startDistance > 1e-6 ? currentDistance / startDistance : 1;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Camera, Vector3 } from "three";
|
||||
|
||||
// Both directions below read the renderer canvas's own getBoundingClientRect()
|
||||
// as the canonical screen-space reference, not window.innerWidth/Height —
|
||||
// the SDK letterboxes/centers the canvas into a fixed aspect area, so the
|
||||
// canvas's own box is the only thing guaranteed to line up with the
|
||||
// camera's projection.
|
||||
|
||||
export function projectScreenPointToWorldRay(
|
||||
camera: Camera,
|
||||
canvasRect: DOMRect,
|
||||
screenX: number,
|
||||
screenY: number
|
||||
): { origin: Vector3; direction: Vector3 } {
|
||||
const ndcX = ((screenX - canvasRect.left) / canvasRect.width) * 2 - 1;
|
||||
const ndcY = -((screenY - canvasRect.top) / canvasRect.height) * 2 + 1;
|
||||
const point = new Vector3(ndcX, ndcY, 0.5).unproject(camera);
|
||||
const direction = point.sub(camera.position).normalize();
|
||||
return { origin: camera.position.clone(), direction };
|
||||
}
|
||||
|
||||
export function projectWorldPointToScreen(camera: Camera, canvasRect: DOMRect, worldPoint: Vector3): { x: number; y: number } {
|
||||
const ndc = worldPoint.clone().project(camera);
|
||||
return {
|
||||
x: ((ndc.x + 1) / 2) * canvasRect.width + canvasRect.left,
|
||||
y: ((1 - ndc.y) / 2) * canvasRect.height + canvasRect.top,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { TweenC } from "@hitplay/playable_template";
|
||||
import { Tween } from "@tweenjs/tween.js";
|
||||
|
||||
// tween.js's .chain() only tells the first tween to call the second's
|
||||
// .start() on completion — it does NOT add the second tween to any Group.
|
||||
// TweenC's update loop only ticks tweens added via TweenC.add(), so an
|
||||
// un-added chained tween marks itself "playing" but never receives an
|
||||
// update() and freezes at its start value forever. Both links must be
|
||||
// added individually before the first is started.
|
||||
export function chainAndRegisterTweens<A extends object, B extends object>(first: Tween<A>, second: Tween<B>): Tween<A> {
|
||||
first.chain(second);
|
||||
TweenC.add(first);
|
||||
TweenC.add(second);
|
||||
return first;
|
||||
}
|
||||
Reference in New Issue
Block a user