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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Oleksandr Vlasiuk
2026-08-13 18:49:04 +03:00
parent 63609eea2c
commit eddcfe1af5
29 changed files with 1354 additions and 484 deletions
@@ -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]) => {