Utility and polishing + bag fixes
This commit is contained in:
@@ -7,11 +7,13 @@ import { installBanner } from "./installBanner";
|
||||
//@ts-ignore
|
||||
import { screenOrientation } from "./orientation";
|
||||
import { globalSettings } from "./globalSettings";
|
||||
import { gameplay } from "./gameplay";
|
||||
|
||||
export const configUIParams: ConfigUiParamsCategories[][] = [
|
||||
analytics,
|
||||
installBanner,
|
||||
globalSettings,
|
||||
gameplay,
|
||||
sounds,
|
||||
screenOrientation,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ConfigUiParamsCategories } from "@hitplay/ads_common";
|
||||
|
||||
// Platform-editable versions of the same values DebugConfigC's dev-only
|
||||
// panel exposes (press "C") — these are what actually take effect in a
|
||||
// real build; DebugConfigC.init() seeds its own state from this category
|
||||
// at startup so the debug panel starts out showing whatever's configured
|
||||
// here, not a separate hardcoded default.
|
||||
export const gameplay: ConfigUiParamsCategories[] = [
|
||||
{
|
||||
id: "gameplay",
|
||||
name: "Gameplay tuning",
|
||||
params: [
|
||||
{
|
||||
id: "hit_damage",
|
||||
name: "Damage per hit",
|
||||
type: "int",
|
||||
values: [0, 100, 10],
|
||||
},
|
||||
{
|
||||
id: "damage_number_style",
|
||||
name: "Damage number animation",
|
||||
type: "select",
|
||||
values: [
|
||||
["Punch (default)", "punch", true],
|
||||
["Float + fade", "float"],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pay_zone_ripple_enabled",
|
||||
name: "Pay Zone landing ripple",
|
||||
type: "bool",
|
||||
values: [true],
|
||||
},
|
||||
{
|
||||
id: "zombie_invasion_timer_s",
|
||||
name: "Zombie Invasion timer (s)",
|
||||
type: "int",
|
||||
values: [5, 300, 120],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -1,5 +1,21 @@
|
||||
import { ConfigUiParamsCategories } from "@hitplay/ads_common";
|
||||
|
||||
// Each array entry is [min, max, default] for ONE axis, in X/Y/Z order —
|
||||
// the runtime value the game reads back is a plain [x, y, z] (verified
|
||||
// against the SDK's own formConfigForPlayable pipeline).
|
||||
//
|
||||
// camera_position_* is the camera's offset RELATIVE TO THE PLAYER, not an
|
||||
// absolute world position: this is a follow camera (see CameraFollowC), so
|
||||
// an absolute position would be meaningless — it's overwritten every frame.
|
||||
// The shipped defaults used to be absolute-camera values inherited from the
|
||||
// SDK's static-camera template (e.g. Z: 42) and were never applied to
|
||||
// anything, so nothing depended on them.
|
||||
//
|
||||
// camera_rotation_* is the camera's fixed angle in degrees. Y: 180 is not
|
||||
// arbitrary — the rig sits BEHIND the player on -Z and looks back toward
|
||||
// +Z, while a three.js camera looks down -Z by default, so the yaw flip is
|
||||
// inherent to this offset convention. Defaults below reproduce the exact
|
||||
// angle the old hardcoded lookAt() produced for offset (0, 8, -6).
|
||||
export const globalSettings: ConfigUiParamsCategories[] = [
|
||||
{
|
||||
id: "global",
|
||||
@@ -43,17 +59,17 @@ export const globalSettings: ConfigUiParamsCategories[] = [
|
||||
[
|
||||
-20,
|
||||
30,
|
||||
-2
|
||||
0
|
||||
],
|
||||
[
|
||||
-5,
|
||||
40,
|
||||
5
|
||||
8
|
||||
],
|
||||
[
|
||||
-20,
|
||||
70,
|
||||
42
|
||||
-6
|
||||
]
|
||||
]
|
||||
},
|
||||
@@ -67,12 +83,12 @@ export const globalSettings: ConfigUiParamsCategories[] = [
|
||||
[
|
||||
-360,
|
||||
360,
|
||||
-25
|
||||
53
|
||||
],
|
||||
[
|
||||
-360,
|
||||
360,
|
||||
0
|
||||
180
|
||||
],
|
||||
[
|
||||
-360,
|
||||
@@ -101,17 +117,17 @@ export const globalSettings: ConfigUiParamsCategories[] = [
|
||||
[
|
||||
-20,
|
||||
30,
|
||||
-2
|
||||
0
|
||||
],
|
||||
[
|
||||
-5,
|
||||
40,
|
||||
6
|
||||
8
|
||||
],
|
||||
[
|
||||
-20,
|
||||
70,
|
||||
37
|
||||
-6
|
||||
]
|
||||
]
|
||||
},
|
||||
@@ -125,12 +141,12 @@ export const globalSettings: ConfigUiParamsCategories[] = [
|
||||
[
|
||||
-360,
|
||||
360,
|
||||
-60
|
||||
53
|
||||
],
|
||||
[
|
||||
-360,
|
||||
360,
|
||||
0
|
||||
180
|
||||
],
|
||||
[
|
||||
-360,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PhysicsBody, PhysicsLayer } from "./PhysicsC";
|
||||
import { ResourceC } from "./ResourceC";
|
||||
import { PropVfxC } from "./PropVfxC";
|
||||
import { HealthBarC } from "./HealthBarC";
|
||||
import { DebugConfigC } from "./DebugConfigC";
|
||||
import { chainAndRegisterTweens } from "../utils/tweenChain";
|
||||
import { createFlashOverlayMaterial } from "../utils/flashOverlayMaterial";
|
||||
|
||||
@@ -61,7 +62,6 @@ const SHADOW_MESH_PATTERN = /shadow/i;
|
||||
// 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.
|
||||
@@ -117,15 +117,17 @@ export class BreakablePropC {
|
||||
return this.byColliderNode.get(node);
|
||||
}
|
||||
|
||||
// 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 ->
|
||||
// Every hit takes the debug panel's configured damage (default 10, see
|
||||
// DebugConfigC) off currentHp; the visual stage is derived from the
|
||||
// remaining HP fraction (see below), and reaching 0 HP destroys it.
|
||||
// Every hit plays feedback + updates HealthBarC; a stage advance (or the
|
||||
// destroying hit) also drops resources. `hitDirection` (attacker ->
|
||||
// target, XZ, normalized) drives which way the crate leans/topples.
|
||||
static hit(prop: BreakableProp, hitDirection: Vector3) {
|
||||
const damage = DebugConfigC.getHitDamage();
|
||||
const previousHp = prop.currentHp;
|
||||
prop.currentHp = Math.max(0, prop.currentHp - HIT_DAMAGE);
|
||||
HealthBarC.onHit(prop, previousHp, prop.currentHp, HIT_DAMAGE);
|
||||
prop.currentHp = Math.max(0, prop.currentHp - damage);
|
||||
HealthBarC.onHit(prop, previousHp, prop.currentHp, damage);
|
||||
|
||||
// A bit above the root's ground-level pivot, so the burst visually
|
||||
// comes from around the crate's body rather than the floor under it.
|
||||
|
||||
+14
-18
@@ -1,26 +1,22 @@
|
||||
import { CameraC_internal, Helper, Template } from "@hitplay/playable_template";
|
||||
import { CameraC_internal, Template } from "@hitplay/playable_template";
|
||||
import { PerspectiveCamera } from "three";
|
||||
|
||||
export class CameraC extends CameraC_internal {
|
||||
// FOV only, deliberately. This used to also copy camera_position_p/_l and
|
||||
// camera_rotation_p/_l onto the camera, and because it re-runs live on
|
||||
// every camera_* edit (see beforeResourcesLoadedCb's updateVariableConfig
|
||||
// listener), it permanently stomped the angle CameraFollowC owns — with
|
||||
// nothing to ever put it back for the rest of the session.
|
||||
//
|
||||
// Those fields are now real, live controls again, but they belong to
|
||||
// CameraFollowC.applyRigFromConfig(), which is the only writer of
|
||||
// camera.rotation. Two writers is precisely what broke before, so resist
|
||||
// adding position/rotation back here.
|
||||
static setCamera(portraitOrientation: boolean) {
|
||||
const CATEGORY = Template.getCategory("global");
|
||||
if (this.camera !== null) {
|
||||
let position = portraitOrientation
|
||||
? Helper.returnVectorCamera(CATEGORY["camera_position_p"] as number[])
|
||||
: Helper.returnVectorCamera(CATEGORY["camera_position_l"] as number[]);
|
||||
const rotation = portraitOrientation
|
||||
? Helper.returnEulerCamera(CATEGORY["camera_rotation_p"] as number[])
|
||||
: Helper.returnEulerCamera(CATEGORY["camera_rotation_l"] as number[]);
|
||||
this.camera.rotation.x = rotation.x;
|
||||
this.camera.rotation.y = rotation.y;
|
||||
this.camera.rotation.z = rotation.z;
|
||||
this.camera.position.copy(position.clone());
|
||||
if (this.camera instanceof PerspectiveCamera) {
|
||||
this.camera.fov = portraitOrientation
|
||||
? Number(CATEGORY["camera_fov_p"])
|
||||
: Number(CATEGORY["camera_fov_l"]);
|
||||
this.camera.updateProjectionMatrix();
|
||||
}
|
||||
if (this.camera !== null && this.camera instanceof PerspectiveCamera) {
|
||||
this.camera.fov = portraitOrientation ? Number(CATEGORY["camera_fov_p"]) : Number(CATEGORY["camera_fov_l"]);
|
||||
this.camera.updateProjectionMatrix();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Object3D, Vector3 } from "three";
|
||||
import { CameraC_internal, UpdateController } from "@hitplay/playable_template";
|
||||
import { MathUtils, Object3D, Vector3 } from "three";
|
||||
import { CameraC_internal, Template, UpdateController } from "@hitplay/playable_template";
|
||||
|
||||
// How fast the camera closes the gap to its target position, per second.
|
||||
// Higher = snappier, lower = floatier. Framerate-independent (see the
|
||||
@@ -30,6 +30,14 @@ const MOVEMENT_EPSILON_SQ = 0.0005 * 0.0005;
|
||||
export class CameraFollowC {
|
||||
private static target: Object3D | null = null;
|
||||
private static offset = new Vector3();
|
||||
// Used only when the platform config can't supply a rig (missing or
|
||||
// malformed values) — see applyRigFromConfig.
|
||||
private static fallbackOffset = new Vector3();
|
||||
// The camera's fixed angle, in degrees, mirroring camera_rotation_p/_l.
|
||||
// Kept in sync whenever the rig changes from ANY source (config edit,
|
||||
// orientation flip, debug panel) so there is only ever one answer to
|
||||
// "what angle is the camera at".
|
||||
private static rotationDeg = new Vector3();
|
||||
private static previousTargetPosition = new Vector3();
|
||||
// The last direction actually walked in, held indefinitely — see update()
|
||||
// for why this must never get reset to zero on its own.
|
||||
@@ -38,26 +46,118 @@ export class CameraFollowC {
|
||||
// Injected (avoids importing PlayerC directly) — see setFacingTargetGetter.
|
||||
private static facingTargetGetter: (() => Vector3 | null) | null = null;
|
||||
|
||||
static init(target: Object3D, offset: Vector3) {
|
||||
static init(target: Object3D, fallbackOffset: Vector3) {
|
||||
this.target = target;
|
||||
this.offset = offset.clone();
|
||||
this.fallbackOffset.copy(fallbackOffset);
|
||||
this.previousTargetPosition.copy(target.position);
|
||||
this.desiredLookAhead.set(0, 0, 0);
|
||||
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 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);
|
||||
// Offset and angle both come from the platform config (see
|
||||
// applyRigFromConfig) rather than being derived here from a hardcoded
|
||||
// offset — that's what makes the Scene settings sliders real controls
|
||||
// instead of dead ones. update() below still never touches rotation:
|
||||
// the angle is fixed until something explicitly changes the rig, so
|
||||
// this stays a linear-shift follow camera, not an orbiting one.
|
||||
this.applyRigFromConfig();
|
||||
|
||||
// Start already framed instead of easing in from wherever the camera
|
||||
// happened to be constructed.
|
||||
CameraC_internal.getCamera().position.copy(target.position).add(this.offset);
|
||||
|
||||
UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta));
|
||||
}
|
||||
|
||||
// The single place the camera rig is read out of the platform's "Scene
|
||||
// settings". Called at init, whenever one of those fields is edited live
|
||||
// on the platform (see beforeResourcesLoadedCb's updateVariableConfig
|
||||
// listener) and on every orientation flip (see resizeCb), so portrait and
|
||||
// landscape can carry different rigs.
|
||||
//
|
||||
// Nothing else may write camera.rotation — an earlier version had
|
||||
// CameraC.setCamera() applying these same fields on every camera_* edit
|
||||
// while this class also owned the angle, and the two writers permanently
|
||||
// fought over it. One owner, one path.
|
||||
static applyRigFromConfig() {
|
||||
const CATEGORY = Template.getCategory("global");
|
||||
const portrait = window.screenSize.portrait;
|
||||
|
||||
const offset = this.readVec3(CATEGORY[portrait ? "camera_position_p" : "camera_position_l"]);
|
||||
const rotation = this.readVec3(CATEGORY[portrait ? "camera_rotation_p" : "camera_rotation_l"]);
|
||||
|
||||
this.offset.copy(offset ?? this.fallbackOffset);
|
||||
|
||||
if (rotation) {
|
||||
this.rotationDeg.copy(rotation);
|
||||
this.applyRotation();
|
||||
} else {
|
||||
// No usable angle configured — fall back to the old behaviour of
|
||||
// deriving one that frames the player from the current offset, so a
|
||||
// broken/absent config degrades to "looks right" rather than to the
|
||||
// empty-sky shot an untuned angle used to give.
|
||||
this.frameTargetAndStoreRotation();
|
||||
}
|
||||
}
|
||||
|
||||
private static readVec3(value: unknown): Vector3 | null {
|
||||
if (!Array.isArray(value) || value.length < 3) return null;
|
||||
const [x, y, z] = value.map(Number);
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return null;
|
||||
return new Vector3(x, y, z);
|
||||
}
|
||||
|
||||
private static applyRotation() {
|
||||
CameraC_internal.getCamera().rotation.set(
|
||||
MathUtils.degToRad(this.rotationDeg.x),
|
||||
MathUtils.degToRad(this.rotationDeg.y),
|
||||
MathUtils.degToRad(this.rotationDeg.z)
|
||||
);
|
||||
}
|
||||
|
||||
// Points the camera at the target from the current offset and records the
|
||||
// resulting angle, so rotationDeg never goes stale relative to what's
|
||||
// actually on screen.
|
||||
private static frameTargetAndStoreRotation() {
|
||||
if (!this.target) return;
|
||||
|
||||
const camera = CameraC_internal.getCamera();
|
||||
camera.position.copy(this.target.position).add(this.offset).add(this.smoothedLookAhead);
|
||||
camera.lookAt(this.target.position);
|
||||
this.rotationDeg.set(
|
||||
MathUtils.radToDeg(camera.rotation.x),
|
||||
MathUtils.radToDeg(camera.rotation.y),
|
||||
MathUtils.radToDeg(camera.rotation.z)
|
||||
);
|
||||
}
|
||||
|
||||
// Exposed for the debug config panel (see DebugConfigC, press "C") to
|
||||
// tweak the rig live — re-applies position+lookAt immediately, the same
|
||||
// one-time geometry setup init() does above, just callable again on
|
||||
// demand instead of only once. Doesn't reintroduce the per-frame
|
||||
// lookAt/orbiting problem documented in init() and update() below: this
|
||||
// only fires when a human explicitly drags a debug slider, never on its
|
||||
// own every frame.
|
||||
static setOffset(offset: Vector3) {
|
||||
this.offset.copy(offset);
|
||||
// Re-derives the angle so dragging distance/angle keeps the player
|
||||
// framed, and keeps rotationDeg matching what's on screen. Editing the
|
||||
// platform's own rotation fields goes through applyRigFromConfig
|
||||
// instead and wins the next time either is touched — the debug panel is
|
||||
// a local comparison tool, not a second source of truth.
|
||||
this.frameTargetAndStoreRotation();
|
||||
}
|
||||
|
||||
// Debug-panel-facing convenience over setOffset: angle is the pitch
|
||||
// looking down at the target (0deg = level with it, 90deg = straight
|
||||
// down), distance is how far back along that same line of sight — the
|
||||
// two numbers a person actually wants to drag, rather than raw X/Y/Z.
|
||||
// X is always 0 in this project's offset convention (no yaw/roll, see
|
||||
// TestSceneC's CAMERA_OFFSET), so this only reconstructs Y/Z.
|
||||
static setAngleAndDistance(angleDeg: number, distance: number) {
|
||||
const angleRad = (angleDeg * Math.PI) / 180;
|
||||
this.setOffset(new Vector3(0, distance * Math.sin(angleRad), -distance * Math.cos(angleRad)));
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Vector3 } from "three";
|
||||
import GUI from "lil-gui";
|
||||
import { Template } from "@hitplay/playable_template";
|
||||
import { CameraFollowC } from "./CameraFollowC";
|
||||
import { ZombieInvasionHudC, TIMER_DURATION_S } from "./ZombieInvasionHudC";
|
||||
|
||||
export type DamageNumberStyle = "punch" | "float";
|
||||
|
||||
// Press this to show/hide the panel — never visible by default, and never
|
||||
// part of #ui/#ui-safe-area (lil-gui mounts its own root straight onto
|
||||
// document.body), so it can't show up in an actual export/playable build,
|
||||
// only while someone is actively debugging with devtools/a live preview.
|
||||
const TOGGLE_KEY = "c";
|
||||
|
||||
// Last-resort rig if the platform config can't be read at all — the panel
|
||||
// seeds from Scene settings instead (see readCameraRigFromConfig), the same
|
||||
// source CameraFollowC uses, so the two can't drift apart.
|
||||
const FALLBACK_CAMERA_OFFSET = new Vector3(0, 8, -6);
|
||||
|
||||
// The camera rig as the panel prefers to express it: pitch down at the
|
||||
// player, and distance back along that line of sight — the two numbers
|
||||
// worth dragging, versus raw X/Y/Z. X is 0 in this project's offset
|
||||
// convention (no yaw/roll in the rig itself), so only Y/Z are involved.
|
||||
function readCameraRigFromConfig(): { angleDeg: number; distance: number } {
|
||||
const CATEGORY = Template.getCategory("global");
|
||||
const raw = CATEGORY[window.screenSize.portrait ? "camera_position_p" : "camera_position_l"];
|
||||
const offset =
|
||||
Array.isArray(raw) && raw.length >= 3 && raw.every((n) => Number.isFinite(Number(n)))
|
||||
? new Vector3(Number(raw[0]), Number(raw[1]), Number(raw[2]))
|
||||
: FALLBACK_CAMERA_OFFSET;
|
||||
|
||||
return {
|
||||
angleDeg: (Math.atan2(offset.y, -offset.z) * 180) / Math.PI,
|
||||
distance: Math.hypot(offset.y, offset.z),
|
||||
};
|
||||
}
|
||||
|
||||
// Internal dev-only toggle panel for comparing animation/logic variants
|
||||
// with the team before committing to one — not part of the game's real
|
||||
// UI. Add new controls here as more variants need comparing; this file
|
||||
// only owns the panel itself and its current values, not what each
|
||||
// controller does with them.
|
||||
export class DebugConfigC {
|
||||
private static gui: GUI | null = null;
|
||||
private static visible = false;
|
||||
private static state = {
|
||||
damageNumberStyle: "punch" as DamageNumberStyle,
|
||||
hitDamage: 10,
|
||||
rippleEffectEnabled: true,
|
||||
cameraAngleDeg: 0,
|
||||
cameraDistance: 0,
|
||||
zombieInvasionTimerS: TIMER_DURATION_S,
|
||||
};
|
||||
|
||||
static init() {
|
||||
if (this.gui) return;
|
||||
|
||||
// Seeds the panel's starting values from the platform's own
|
||||
// configUIParams (see configUIParams/gameplay.ts) — so this shows
|
||||
// whatever's actually configured for the real build, not a separate
|
||||
// hardcoded default that could silently drift from it. Only read
|
||||
// once, at startup, not kept live-synced afterward — a producer
|
||||
// editing these on the platform and a dev comparing variants via this
|
||||
// panel are two separate workflows that aren't expected to run at the
|
||||
// same time.
|
||||
const CATEGORY = Template.getCategory("gameplay");
|
||||
if (CATEGORY["hit_damage"] !== undefined) this.state.hitDamage = Number(CATEGORY["hit_damage"]);
|
||||
if (CATEGORY["damage_number_style"] !== undefined) {
|
||||
this.state.damageNumberStyle = CATEGORY["damage_number_style"] as DamageNumberStyle;
|
||||
}
|
||||
if (CATEGORY["pay_zone_ripple_enabled"] !== undefined) {
|
||||
this.state.rippleEffectEnabled = Boolean(CATEGORY["pay_zone_ripple_enabled"]);
|
||||
}
|
||||
if (CATEGORY["zombie_invasion_timer_s"] !== undefined) {
|
||||
this.state.zombieInvasionTimerS = Number(CATEGORY["zombie_invasion_timer_s"]);
|
||||
}
|
||||
|
||||
// Camera rig comes from the same Scene settings CameraFollowC reads, so
|
||||
// the panel opens showing the real current rig rather than a duplicated
|
||||
// constant that could silently drift from it.
|
||||
const rig = readCameraRigFromConfig();
|
||||
this.state.cameraAngleDeg = rig.angleDeg;
|
||||
this.state.cameraDistance = rig.distance;
|
||||
|
||||
this.gui = new GUI({ title: `Debug config (press "${TOGGLE_KEY.toUpperCase()}" to hide)` });
|
||||
this.gui
|
||||
.add(this.state, "damageNumberStyle", { "Punch (default)": "punch", "Float + fade": "float" })
|
||||
.name("Damage number animation");
|
||||
// Plain number input, not a min/max slider (lil-gui only renders the
|
||||
// two-handle slider once BOTH min and max are set) — min(0) alone just
|
||||
// clamps typed/dragged values without changing the control's look.
|
||||
this.gui.add(this.state, "hitDamage").name("Damage per hit").min(0).step(1);
|
||||
this.gui.add(this.state, "rippleEffectEnabled").name("Pay Zone landing ripple");
|
||||
|
||||
// Live camera rig — angle (pitch looking down at the player) and
|
||||
// distance along that same line of sight, not raw X/Y/Z (see
|
||||
// CameraFollowC.setAngleAndDistance for why those two are what's
|
||||
// actually worth dragging). onChange (not onFinishChange) so the
|
||||
// camera actually moves while dragging, not just once released.
|
||||
this.gui
|
||||
.add(this.state, "cameraAngleDeg", 0, 90, 0.5)
|
||||
.name("Camera angle (deg)")
|
||||
.onChange(() => this.applyCameraRig());
|
||||
this.gui
|
||||
.add(this.state, "cameraDistance", 2, 25, 0.5)
|
||||
.name("Camera distance")
|
||||
.onChange(() => this.applyCameraRig());
|
||||
|
||||
// onFinishChange, not onChange — restarting the CSS transition on
|
||||
// every intermediate drag tick would look chaotic; only re-run it once
|
||||
// the number is actually settled.
|
||||
this.gui
|
||||
.add(this.state, "zombieInvasionTimerS", 5, 300, 1)
|
||||
.name("Zombie Invasion timer (s)")
|
||||
.onFinishChange((value: number) => ZombieInvasionHudC.restartTimer(value));
|
||||
|
||||
this.gui.hide();
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key.toLowerCase() === TOGGLE_KEY) this.toggle();
|
||||
});
|
||||
}
|
||||
|
||||
static getDamageNumberStyle(): DamageNumberStyle {
|
||||
return this.state.damageNumberStyle;
|
||||
}
|
||||
|
||||
static getHitDamage(): number {
|
||||
return this.state.hitDamage;
|
||||
}
|
||||
|
||||
static isRippleEffectEnabled(): boolean {
|
||||
return this.state.rippleEffectEnabled;
|
||||
}
|
||||
|
||||
// Not read reactively from ZombieInvasionHudC itself (that would cycle —
|
||||
// this file already imports ZombieInvasionHudC for restartTimer/
|
||||
// TIMER_DURATION_S) — TestSceneC reads this once, at startup, and passes
|
||||
// it into ZombieInvasionHudC.init() directly, same DI pattern already
|
||||
// used elsewhere in this project to sidestep import cycles (e.g.
|
||||
// ResourceC.setFlyTarget).
|
||||
static getZombieInvasionTimerS(): number {
|
||||
return this.state.zombieInvasionTimerS;
|
||||
}
|
||||
|
||||
private static applyCameraRig() {
|
||||
CameraFollowC.setAngleAndDistance(this.state.cameraAngleDeg, this.state.cameraDistance);
|
||||
}
|
||||
|
||||
private static toggle() {
|
||||
this.visible = !this.visible;
|
||||
if (this.visible) this.gui?.show();
|
||||
else this.gui?.hide();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { CameraC_internal, UpdateController } from "@hitplay/playable_template";
|
||||
import { ThreeC } from "./ThreeC";
|
||||
import type { BreakableProp } from "./BreakablePropC";
|
||||
import { projectWorldPointToScreen } from "../utils/screenWorldProjection";
|
||||
import { DebugConfigC } from "./DebugConfigC";
|
||||
|
||||
// If a bar's crate stops getting hit and the player walks away before it's
|
||||
// destroyed, the bar lingers this long before fading out — reset to 0 on
|
||||
@@ -14,14 +15,18 @@ const FADE_OUT_MS = 250;
|
||||
// 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;
|
||||
// Kept in sync with health-bar.css's health-bar-damage-punch/-float
|
||||
// animation durations, so the popup element is removed right as whichever
|
||||
// CSS animation is active finishes, not before/after (see
|
||||
// DebugConfigC.getDamageNumberStyle — the debug panel's damage-number
|
||||
// switch).
|
||||
const DAMAGE_POPUP_LIFETIME_MS = 490;
|
||||
const DAMAGE_POPUP_FLOAT_LIFETIME_MS = 700;
|
||||
|
||||
// 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).
|
||||
// Magnitude never past the 2 o'clock mark (2 * 30deg/hour = 60deg); the
|
||||
// sign (clockwise vs counter-clockwise) is randomized per popup, same
|
||||
// magnitude range on both sides.
|
||||
const DAMAGE_ROTATE_MIN_DEG = 10;
|
||||
const DAMAGE_ROTATE_MAX_DEG = 50;
|
||||
|
||||
@@ -37,6 +42,12 @@ interface BarInstance {
|
||||
idleTime: number;
|
||||
hiding: boolean;
|
||||
removeTimeout: ReturnType<typeof setTimeout> | null;
|
||||
// The currently-showing damage popup (if any) and its pending removal
|
||||
// timeout — tracked so a new hit landing before the old popup finishes
|
||||
// (e.g. the attack's two hits per swing, close together) can remove it
|
||||
// immediately instead of the two overlapping on screen.
|
||||
damagePopup: HTMLElement | null;
|
||||
damagePopupTimeout: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
// World-tracking DOM health bars for breakable crates: a bordered
|
||||
@@ -160,9 +171,19 @@ export class HealthBarC {
|
||||
idleTime: 0,
|
||||
hiding: false,
|
||||
removeTimeout: null,
|
||||
damagePopup: null,
|
||||
damagePopupTimeout: null,
|
||||
};
|
||||
|
||||
this.bars.set(prop, bar);
|
||||
|
||||
// Positioned synchronously right away — otherwise the container sits at
|
||||
// CSS's default position:fixed placement (top-left of the viewport,
|
||||
// since .health-bar sets no left/top) until the next update() tick, and
|
||||
// flashes there for a frame before jumping to its real spot above the
|
||||
// crate.
|
||||
this.positionBar(bar, CameraC_internal.getCamera(), ThreeC.renderer.domElement.getBoundingClientRect());
|
||||
|
||||
return bar;
|
||||
}
|
||||
|
||||
@@ -190,15 +211,49 @@ export class HealthBarC {
|
||||
}
|
||||
|
||||
private static spawnDamagePopup(bar: BarInstance, damage: number) {
|
||||
// A new hit landing before the previous popup finished (e.g. the
|
||||
// attack's two hits per swing, close together) replaces it immediately
|
||||
// instead of the two overlapping on screen.
|
||||
if (bar.damagePopup) {
|
||||
if (bar.damagePopupTimeout !== null) clearTimeout(bar.damagePopupTimeout);
|
||||
bar.damagePopup.remove();
|
||||
}
|
||||
|
||||
const popup = document.createElement("div");
|
||||
popup.className = "health-bar__damage";
|
||||
popup.textContent = `-${damage}`;
|
||||
|
||||
// Text lives in its own inline-block child (.gamefont-x, see
|
||||
// main.css) so the global font-width squeeze doesn't fight this
|
||||
// element's own animated transform (health-bar-damage-punch owns
|
||||
// .health-bar__damage's `transform` completely, every keyframe).
|
||||
const popupText = document.createElement("span");
|
||||
popupText.className = "gamefont-x";
|
||||
popupText.textContent = `-${damage}`;
|
||||
popup.appendChild(popupText);
|
||||
|
||||
// Debug-panel switch (see DebugConfigC, press "C") between the default
|
||||
// punch animation and a plain float-up-and-fade — for comparing the
|
||||
// two with the team, not a real gameplay option.
|
||||
const isFloatStyle = DebugConfigC.getDamageNumberStyle() === "float";
|
||||
popup.classList.toggle("style-float", isFloatStyle);
|
||||
|
||||
// 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`);
|
||||
// CSS keyframes just read var(--damage-rotate). Sign (clockwise vs
|
||||
// counter-clockwise) is random too, same magnitude range either way.
|
||||
// Unused by the float style, harmless to always set.
|
||||
const magnitude = DAMAGE_ROTATE_MIN_DEG + Math.random() * (DAMAGE_ROTATE_MAX_DEG - DAMAGE_ROTATE_MIN_DEG);
|
||||
const sign = Math.random() < 0.5 ? 1 : -1;
|
||||
popup.style.setProperty("--damage-rotate", `${sign * magnitude}deg`);
|
||||
bar.container.appendChild(popup);
|
||||
setTimeout(() => popup.remove(), DAMAGE_POPUP_LIFETIME_MS);
|
||||
|
||||
bar.damagePopup = popup;
|
||||
bar.damagePopupTimeout = setTimeout(
|
||||
() => {
|
||||
popup.remove();
|
||||
bar.damagePopup = null;
|
||||
bar.damagePopupTimeout = null;
|
||||
},
|
||||
isFloatStyle ? DAMAGE_POPUP_FLOAT_LIFETIME_MS : DAMAGE_POPUP_LIFETIME_MS
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+11
-4
@@ -18,7 +18,7 @@ const ANCHOR_DISTANCE = 2.5;
|
||||
export class HudC {
|
||||
private static initialized = false;
|
||||
private static plateEl: HTMLElement;
|
||||
private static countEl: HTMLElement;
|
||||
private static countTextEl: HTMLElement;
|
||||
private static lastDisplayedCount = -1;
|
||||
private static balanceGetter: (() => number) | null = null;
|
||||
|
||||
@@ -77,7 +77,14 @@ export class HudC {
|
||||
|
||||
const count = document.createElement("span");
|
||||
count.className = "resource-hud__count";
|
||||
count.textContent = "0";
|
||||
|
||||
// Text lives in its own inline-block child (.gamefont-x, see main.css)
|
||||
// so the global font-width squeeze doesn't need to touch this
|
||||
// element's own layout/positioning.
|
||||
const countText = document.createElement("span");
|
||||
countText.className = "gamefont-x";
|
||||
countText.textContent = "0";
|
||||
count.appendChild(countText);
|
||||
|
||||
plate.appendChild(count);
|
||||
holder.appendChild(plate);
|
||||
@@ -86,7 +93,7 @@ export class HudC {
|
||||
uiRoot.appendChild(holder);
|
||||
|
||||
this.plateEl = plate;
|
||||
this.countEl = count;
|
||||
this.countTextEl = countText;
|
||||
}
|
||||
|
||||
private static update() {
|
||||
@@ -94,7 +101,7 @@ export class HudC {
|
||||
const count = this.balanceGetter();
|
||||
if (count === this.lastDisplayedCount) return;
|
||||
this.lastDisplayedCount = count;
|
||||
this.countEl.textContent = String(count);
|
||||
this.countTextEl.textContent = String(count);
|
||||
|
||||
// The actual "pop" is a CSS animation on the whole plate (.is-bumping,
|
||||
// see ui.css), not just the digit — this only toggles the class,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Box3, Mesh, MeshBasicMaterial, Object3D, PlaneGeometry, Vector3 } from "three";
|
||||
import { Box3, Mesh, MeshBasicMaterial, Object3D, PlaneGeometry, RingGeometry, Vector3 } from "three";
|
||||
import { CameraC_internal, TweenC, UpdateController } from "@hitplay/playable_template";
|
||||
import { Easing, Tween } from "@tweenjs/tween.js";
|
||||
import { ThreeC } from "./ThreeC";
|
||||
import { RESOURCE_ICON_BASE_SCALE, ResourceC } from "./ResourceC";
|
||||
import { PlayerC } from "./PlayerC";
|
||||
import { perspectiveCompensationScale } from "../utils/perspectiveScale";
|
||||
import { createFlashOverlayMaterial } from "../utils/flashOverlayMaterial";
|
||||
import { DebugConfigC } from "./DebugConfigC";
|
||||
|
||||
// How many resources need to actually make it into the zone before it
|
||||
// disappears.
|
||||
@@ -12,15 +14,15 @@ 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 an instant dump.
|
||||
const DEPOSIT_INTERVAL = 0.3;
|
||||
const DEPOSIT_FLIGHT_DURATION_MS = 500;
|
||||
const DEPOSIT_INTERVAL = 0.12;
|
||||
const DEPOSIT_FLIGHT_DURATION_MS = 280;
|
||||
// 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 DEPOSIT_FLIGHT_CLEANUP_MARGIN_MS = 200;
|
||||
|
||||
const PAY_ZONE_SHRINK_MS = 400;
|
||||
|
||||
@@ -38,6 +40,22 @@ const DEPOSIT_FLIGHT_SCALE_MULTIPLIER = 1.3;
|
||||
// of the ordinary from->to lerp.
|
||||
const DEPOSIT_FLIGHT_ARC_HEIGHT = 2;
|
||||
|
||||
// Landing ripple: a thin ring that pops in at the token's arrival point and
|
||||
// expands while fading out, like a drop landing in water — feedback for
|
||||
// the flight's end instead of the token just cutting out at ground level.
|
||||
// Deliberately understated (modest peak opacity, no color tint beyond the
|
||||
// same white-additive flash convention used elsewhere) rather than a loud
|
||||
// splash.
|
||||
const RIPPLE_START_SCALE = 0.25;
|
||||
const RIPPLE_END_SCALE = 1.6;
|
||||
const RIPPLE_DURATION_MS = 550;
|
||||
const RIPPLE_MAX_OPACITY = 0.35;
|
||||
const RIPPLE_Y_OFFSET = 0.03; // just above the ground, avoids z-fighting
|
||||
// A unit ring (radius 1), scaled per-instance above — built once and
|
||||
// shared read-only across every ripple, same pattern as SparkleFxC's
|
||||
// shared star geometry.
|
||||
const rippleGeometry = new RingGeometry(0.55, 1, 48);
|
||||
|
||||
// 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/
|
||||
@@ -185,7 +203,12 @@ export class PayZoneC {
|
||||
const perspectiveScale = perspectiveCompensationScale(cameraPosition, mesh.position, startDistance);
|
||||
mesh.scale.setScalar(perspectiveScale * RESOURCE_ICON_BASE_SCALE * DEPOSIT_FLIGHT_SCALE_MULTIPLIER);
|
||||
})
|
||||
.onComplete(() => ThreeC.removeFromScene(mesh));
|
||||
.onComplete(() => {
|
||||
ThreeC.removeFromScene(mesh);
|
||||
// Debug-panel switch (see DebugConfigC, press "C") for comparing
|
||||
// with/without the ripple — not a real gameplay option.
|
||||
if (DebugConfigC.isRippleEffectEnabled()) this.playLandingRipple(to);
|
||||
});
|
||||
|
||||
TweenC.add(flight);
|
||||
flight.start();
|
||||
@@ -193,6 +216,29 @@ export class PayZoneC {
|
||||
setTimeout(() => ThreeC.removeFromScene(mesh), DEPOSIT_FLIGHT_DURATION_MS + DEPOSIT_FLIGHT_CLEANUP_MARGIN_MS);
|
||||
}
|
||||
|
||||
private static playLandingRipple(position: Vector3) {
|
||||
const mesh = new Mesh(rippleGeometry, createFlashOverlayMaterial());
|
||||
mesh.rotation.x = -Math.PI / 2; // lie flat on the ground, facing up
|
||||
mesh.position.copy(position);
|
||||
mesh.position.y += RIPPLE_Y_OFFSET;
|
||||
mesh.scale.setScalar(RIPPLE_START_SCALE);
|
||||
ThreeC.addToScene(mesh);
|
||||
|
||||
const material = mesh.material as MeshBasicMaterial;
|
||||
|
||||
const ripple = new Tween({ t: 0 })
|
||||
.to({ t: 1 }, RIPPLE_DURATION_MS)
|
||||
.easing(Easing.Quadratic.Out)
|
||||
.onUpdate(({ t }) => {
|
||||
mesh.scale.setScalar(RIPPLE_START_SCALE + (RIPPLE_END_SCALE - RIPPLE_START_SCALE) * t);
|
||||
material.opacity = RIPPLE_MAX_OPACITY * (1 - t);
|
||||
})
|
||||
.onComplete(() => ThreeC.removeFromScene(mesh));
|
||||
|
||||
TweenC.add(ripple);
|
||||
ripple.start();
|
||||
}
|
||||
|
||||
// Shrinks the zone's own marker AND the fill overlay together, as one
|
||||
// piece, toward their own centers in lockstep.
|
||||
private static playDisappear(node: Object3D) {
|
||||
|
||||
@@ -13,14 +13,22 @@ import { PropVfxC } from "./PropVfxC";
|
||||
import { WeaponTrailC } from "./WeaponTrailC";
|
||||
import { SparkleFxC } from "./SparkleFxC";
|
||||
import { HealthBarC } from "./HealthBarC";
|
||||
import { DebugConfigC } from "./DebugConfigC";
|
||||
|
||||
// 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, 8, -6);
|
||||
//
|
||||
// This is now only the FALLBACK rig: the live one comes from the platform's
|
||||
// Scene settings (camera_position_p/_l, see CameraFollowC.applyRigFromConfig),
|
||||
// and these numbers are also what those fields default to. It stays here so
|
||||
// a missing or malformed config still yields a framed shot rather than a
|
||||
// black screen.
|
||||
export const CAMERA_OFFSET = new Vector3(0, 8, -6);
|
||||
|
||||
export class TestSceneC {
|
||||
static init() {
|
||||
DebugConfigC.init();
|
||||
PropVfxC.init();
|
||||
SparkleFxC.init();
|
||||
HealthBarC.init();
|
||||
@@ -60,9 +68,12 @@ export class TestSceneC {
|
||||
WeaponHudC.init();
|
||||
|
||||
// Top-center "ZOMBIE INVASION" timer bar — also fully independent,
|
||||
// starts its own 2-minute slide immediately on init (see
|
||||
// ZombieInvasionHudC).
|
||||
ZombieInvasionHudC.init();
|
||||
// starts its own timer slide immediately on init (see
|
||||
// ZombieInvasionHudC). Duration comes from DebugConfigC (itself seeded
|
||||
// from the platform's own configUIParams, see gameplay.ts) rather than
|
||||
// ZombieInvasionHudC's own hardcoded default, so a platform-configured
|
||||
// value actually takes effect in a real build.
|
||||
ZombieInvasionHudC.init(DebugConfigC.getZombieInvasionTimerS());
|
||||
}
|
||||
|
||||
private static createMap() {
|
||||
|
||||
@@ -33,7 +33,14 @@ export class WeaponHudC {
|
||||
|
||||
const level = document.createElement("span");
|
||||
level.className = "weapon-hud__level";
|
||||
level.textContent = "LVL 0";
|
||||
|
||||
// Text lives in its own inline-block child (.gamefont-x, see
|
||||
// main.css) so the global font-width squeeze doesn't need to touch
|
||||
// this element's own layout/positioning.
|
||||
const levelText = document.createElement("span");
|
||||
levelText.className = "gamefont-x";
|
||||
levelText.textContent = "LVL 0";
|
||||
level.appendChild(levelText);
|
||||
|
||||
holder.appendChild(icon);
|
||||
holder.appendChild(level);
|
||||
|
||||
@@ -3,13 +3,15 @@ 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;
|
||||
// duplicated as a magic number in ui.css. Exported so DebugConfigC's timer
|
||||
// slider (see restartTimer) starts at this same default instead of
|
||||
// duplicating the number.
|
||||
export const TIMER_DURATION_S = 120;
|
||||
|
||||
// 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
|
||||
// warn the player it's about to fill. 0.2 = last 20%, i.e. the last 24s of
|
||||
// a 120s timer.
|
||||
const WARNING_FRACTION = 0.05;
|
||||
const WARNING_FRACTION = 0.2;
|
||||
|
||||
// Top-center "ZOMBIE INVASION" timer bar (title + a zombie-head marker
|
||||
// sliding right-to-left over TIMER_DURATION_S). No bar/track art exists —
|
||||
@@ -25,12 +27,38 @@ export class ZombieInvasionHudC {
|
||||
private static fillEl: HTMLElement;
|
||||
private static flashEl: HTMLElement;
|
||||
private static headEl: HTMLElement;
|
||||
private static warningTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
static init() {
|
||||
// durationS defaults to TIMER_DURATION_S but is normally passed in
|
||||
// explicitly by TestSceneC, sourced from DebugConfigC.getZombieInvasionTimerS()
|
||||
// (itself seeded from the platform's own configUIParams) — not read from
|
||||
// DebugConfigC directly in here, since DebugConfigC already imports this
|
||||
// file for restartTimer/TIMER_DURATION_S and importing back would cycle.
|
||||
static init(durationS: number = TIMER_DURATION_S) {
|
||||
if (this.initialized) return;
|
||||
this.initialized = true;
|
||||
this.buildDom();
|
||||
this.startTimer();
|
||||
this.startTimer(durationS);
|
||||
}
|
||||
|
||||
// Exposed for the debug config panel (see DebugConfigC, press "C") to
|
||||
// re-run the timer at a different duration for live comparison — snaps
|
||||
// the head/fill back to their starting position (duration forced to 0s
|
||||
// for that one frame) and clears any pending warning-flash timeout
|
||||
// before re-triggering the CSS transition, same as the very first call
|
||||
// from init() above.
|
||||
static restartTimer(durationS: number) {
|
||||
if (this.warningTimeout !== null) clearTimeout(this.warningTimeout);
|
||||
this.barEl.classList.remove("is-almost-full");
|
||||
this.flashEl.classList.remove("is-almost-full");
|
||||
|
||||
this.headEl.style.transitionDuration = "0s";
|
||||
this.fillEl.style.transitionDuration = "0s";
|
||||
this.headEl.style.left = "100%";
|
||||
this.fillEl.style.width = "0%";
|
||||
void this.headEl.offsetWidth; // force reflow: commit the instant snap-back before startTimer re-enables the transition
|
||||
|
||||
this.startTimer(durationS);
|
||||
}
|
||||
|
||||
// .zombie-invasion__track wraps fill+flash in its own overflow:hidden
|
||||
@@ -43,7 +71,14 @@ export class ZombieInvasionHudC {
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.className = "zombie-invasion__title";
|
||||
title.textContent = "ZOMBIE INVASION";
|
||||
|
||||
// Text lives in its own inline-block child (.gamefont-x, see
|
||||
// main.css) so the global font-width squeeze doesn't need to touch
|
||||
// this element's own layout/positioning.
|
||||
const titleText = document.createElement("span");
|
||||
titleText.className = "gamefont-x";
|
||||
titleText.textContent = "ZOMBIE INVASION";
|
||||
title.appendChild(titleText);
|
||||
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "zombie-invasion__bar";
|
||||
@@ -84,15 +119,15 @@ export class ZombieInvasionHudC {
|
||||
// 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`;
|
||||
private static startTimer(durationS: number) {
|
||||
this.headEl.style.transitionDuration = `${durationS}s`;
|
||||
this.fillEl.style.transitionDuration = `${durationS}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(() => {
|
||||
const warningDelayMs = durationS * 1000 * (1 - WARNING_FRACTION);
|
||||
this.warningTimeout = setTimeout(() => {
|
||||
this.barEl.classList.add("is-almost-full");
|
||||
this.flashEl.classList.add("is-almost-full");
|
||||
}, warningDelayMs);
|
||||
|
||||
+57
-22
@@ -15,7 +15,18 @@
|
||||
position: fixed;
|
||||
transform: translate(-20%, 200%);
|
||||
pointer-events: none;
|
||||
z-index: 50;
|
||||
/* ⚠️ Found and fixed: this used to be z-index:50, the SAME as
|
||||
.resource-hud/.weapon-hud/.zombie-invasion (see ui.css) — with equal
|
||||
z-index, stacking falls back to DOM order, and health-bar elements
|
||||
are only created lazily on a crate's first hit (getOrCreateBar),
|
||||
appended to #ui-safe-area well AFTER those three widgets (built once
|
||||
at scene init) — so a health bar landing over a HUD widget on screen
|
||||
always painted ON TOP of it. Lower than every HUD widget's z-index on
|
||||
purpose: the HUD widgets must always win that stacking fight. The
|
||||
only thing intentionally allowed to render over the UI is a
|
||||
collected resource's own flight — a 3D mesh on the WebGL canvas, a
|
||||
completely separate layer from this DOM overlay, unaffected by this. */
|
||||
z-index: 40;
|
||||
/* 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. */
|
||||
@@ -82,45 +93,46 @@
|
||||
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);
|
||||
/* +15% over the previous 0.288, for more contrast/visibility. */
|
||||
font-size: calc(var(--health-bar-width) * 0.331);
|
||||
/* 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;
|
||||
+35% over the previous 0.024 for a more pronounced border, on top of
|
||||
already scaling with the font-size bump above. */
|
||||
-webkit-text-stroke: calc(var(--health-bar-width) * 0.0324 * var(--game-font-stroke-width)) #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;
|
||||
gently instead of both phases reading the same speed. Total duration
|
||||
shortened from 920ms to 490ms (see chat: "числа швидше зникають") —
|
||||
the punch (0-28%) and settle (28-60%) keep the SAME absolute
|
||||
millisecond timing as before (138ms/156ms), only the fade at the end
|
||||
got shorter. */
|
||||
animation: health-bar-damage-punch 490ms 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. */
|
||||
/* Sharp punch, THEN fade — decoupled on purpose: pops in already fully
|
||||
opaque (no fade-in), 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), settles
|
||||
back to scale 1/rotate 0 while STAYING fully opaque, and fades out
|
||||
immediately from there — no hold/hang at full opacity before the fade
|
||||
starts (see chat: "прибери взагалі зависання числа на одному місці в
|
||||
кінці"). */
|
||||
@keyframes health-bar-damage-punch {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) scale(1) rotate(0deg);
|
||||
}
|
||||
18% {
|
||||
28% {
|
||||
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;
|
||||
60% {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) scale(1) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
@@ -128,3 +140,26 @@
|
||||
transform: translateX(-50%) scale(1) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Alternate style, toggled from HealthBarC.spawnDamagePopup via the debug
|
||||
config panel (see DebugConfigC.ts, press "C") — plain float-up-and-fade,
|
||||
no punch/rotate, kept only for side-by-side comparison with the default
|
||||
above. Compound selector (two classes, not one) so it reliably beats
|
||||
.health-bar__damage's own animation regardless of source order. */
|
||||
.health-bar__damage.style-float {
|
||||
animation: health-bar-damage-float 700ms ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes health-bar-damage-float {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
15% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(calc(var(--health-bar-height) * -3));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,36 @@
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Single dial for how horizontally/vertically compressed or stretched
|
||||
every gameFont text run looks — PassionOne-Black has no condensed
|
||||
variant/variable-width axis to select via `font-stretch`, so a real
|
||||
per-character resize has to come from scaling the rendered text
|
||||
itself (see .gamefont-x below). 1 = no change on that axis, lower =
|
||||
narrower/shorter, higher = wider/taller. Tune these two values only. */
|
||||
--game-font-stretch-x: 0.80;
|
||||
--game-font-stretch-y: 1.1;
|
||||
/* Multiplier on every gameFont-adjacent text's -webkit-text-stroke width
|
||||
across the project (zombie-invasion__title, health-bar__damage,
|
||||
bannerButtonText — see ui.css) — each already has its own base
|
||||
thickness calc()'d from its own widget's width variable, this just
|
||||
scales all of them together. 1 = current thickness, higher = bolder
|
||||
border, lower = thinner. */
|
||||
--game-font-stroke-width: 1.3;
|
||||
}
|
||||
|
||||
/* Wrap gameFont text CONTENT in this class (not the outer positioned/
|
||||
animated element around it) to resize character width/height without
|
||||
touching layout or fighting whatever transform/animation the outer
|
||||
element already has (e.g. health-bar__damage's punch keyframes, which
|
||||
own that element's `transform` completely). `transform` has no effect
|
||||
on plain inline boxes, hence inline-block. */
|
||||
.gamefont-x {
|
||||
display: inline-block;
|
||||
transform: scale(var(--game-font-stretch-x), var(--game-font-stretch-y));
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
|
||||
+4
-4
@@ -221,8 +221,8 @@
|
||||
|
||||
.weapon-hud__level {
|
||||
position: absolute;
|
||||
bottom: 6%;
|
||||
left: 7%;
|
||||
bottom: 8%;
|
||||
left: 2%;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: #ffffff;
|
||||
@@ -266,7 +266,7 @@
|
||||
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;
|
||||
-webkit-text-stroke: calc(var(--zombie-invasion-width) * 0.004 * var(--game-font-stroke-width)) #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;
|
||||
}
|
||||
@@ -404,6 +404,6 @@
|
||||
|
||||
.bannerButtonText {
|
||||
font-weight: bold;
|
||||
-webkit-text-stroke: 0.05em #000000;
|
||||
-webkit-text-stroke: calc(0.05em * var(--game-font-stroke-width)) #000000;
|
||||
text-shadow: 0 0.05em 0.1em rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
Binary file not shown.
+19
-2
@@ -1,3 +1,20 @@
|
||||
import { FontFamily, formFontFamily } from "@hitplay/ads_common";
|
||||
import { formFontFamily } from "@hitplay/ads_common";
|
||||
|
||||
export const customFont: undefined | Promise<FontFamily> = undefined
|
||||
// This exact shape — `export const customFont = formFontFamily(name, path)`
|
||||
// with two string-literal arguments — is required verbatim: @hitplay/ads_common's
|
||||
// export tooling (createFontFamilyByPath, and the "local" branch of
|
||||
// insertFontFamily) statically parses THIS FILE'S SOURCE TEXT via Babel, not
|
||||
// its runtime behavior, looking for precisely this pattern to register our
|
||||
// font with the platform/export pipeline. This was left as a bare
|
||||
// `undefined` stub — the platform never received any custom font, so
|
||||
// "font_custom_default" was always empty and gameFont never showed up in
|
||||
// the platform's own preview, even though our own main.css's separate
|
||||
// @font-face (an unrelated mechanism, see main.css) always rendered gameFont
|
||||
// correctly in a plain `npm run export`/`vite build`.
|
||||
//
|
||||
// Path is relative to THIS file's own directory (src/fonts/) per the tool's
|
||||
// convention — PassionOne-Black.otf is duplicated here from resources/fonts/
|
||||
// specifically for this; the two consumers (this file and main.css's
|
||||
// @font-face) use unrelated path-resolution rules, so one copy can't serve
|
||||
// both.
|
||||
export const customFont = formFontFamily("gameFont", "./PassionOne-Black.otf");
|
||||
|
||||
+12
-1
@@ -24,7 +24,18 @@ window.setupConfig = async function (config) {
|
||||
config: config || formConfigForPlayable(formConfigUI({
|
||||
type_default: Template.templateType,
|
||||
params_categories: formConfigUIParams(...configUIParams),
|
||||
font_custom_default: await customFont
|
||||
// formFontFamily's XHR fetch (see customFont.ts) has no timeout/error
|
||||
// handling of its own and would hang forever if the font file isn't
|
||||
// actually reachable relative to wherever this page is running — true
|
||||
// for this project's single-file export target, where nothing else is
|
||||
// served alongside the final HTML at all. Racing it against a short
|
||||
// timeout means a failed/unreachable fetch degrades to no custom font
|
||||
// instead of blocking Template.initConfig().init() — and so the whole
|
||||
// game — from ever running.
|
||||
font_custom_default: await Promise.race([
|
||||
customFont,
|
||||
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), 2000))
|
||||
])
|
||||
})),
|
||||
resources,
|
||||
afterResourcesLoadedCb,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
InstallBanner,
|
||||
} from "@hitplay/playable_template";
|
||||
import { CameraC } from "../controllers/CameraC";
|
||||
import { CameraFollowC } from "../controllers/CameraFollowC";
|
||||
import { ThreeC } from "../controllers/ThreeC";
|
||||
import { Color } from "three";
|
||||
import { Vec3 } from "cannon-es";
|
||||
@@ -114,17 +115,19 @@ export const beforeResourcesLoadedCb = () => {
|
||||
break;
|
||||
|
||||
default:
|
||||
if (
|
||||
[
|
||||
"camera_fov_p",
|
||||
"camera_position_p",
|
||||
"camera_rotation_p",
|
||||
"camera_fov_l",
|
||||
"camera_position_l",
|
||||
"camera_rotation_l",
|
||||
].includes(variable)
|
||||
) {
|
||||
// The camera's config is split between two owners on purpose:
|
||||
// FOV lives on the camera itself (CameraC), while the rig —
|
||||
// offset and angle — belongs to CameraFollowC, which is the only
|
||||
// thing allowed to write camera.rotation. Routing each field to
|
||||
// its own owner is what keeps them from stomping each other.
|
||||
if (["camera_fov_p", "camera_fov_l"].includes(variable)) {
|
||||
CameraC.setCamera(window.screenSize.portrait);
|
||||
} else if (
|
||||
["camera_position_p", "camera_position_l", "camera_rotation_p", "camera_rotation_l"].includes(variable)
|
||||
) {
|
||||
// No-ops safely before the scene exists: CameraFollowC reads
|
||||
// the same config again in its own init().
|
||||
CameraFollowC.applyRigFromConfig();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { Template3d } from "@hitplay/playable_template";
|
||||
import { CameraC } from "../controllers/CameraC";
|
||||
import { CameraFollowC } from "../controllers/CameraFollowC";
|
||||
|
||||
export const resizeCb = () => {
|
||||
Template3d.resize();
|
||||
|
||||
// Scene settings carry a separate rig and FOV per orientation
|
||||
// (camera_*_p vs camera_*_l), so both have to be re-read after a flip —
|
||||
// window.screenSize.portrait is already updated by the resize above.
|
||||
// Without this the playable would keep portrait's camera in landscape.
|
||||
CameraC.setCamera(window.screenSize.portrait);
|
||||
CameraFollowC.applyRigFromConfig();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user