-Add UI (Health bars, Resources UI, HUD Interface, Weapon U, Zoombie Invasion progress Indicator)

-Refactor code structure for improved readability and maintainability
This commit is contained in:
24Play-Mykyta-Slobodianiuk
2026-06-08 18:33:28 +03:00
parent d6fc6be717
commit 78d85c3799
31 changed files with 6308 additions and 624 deletions
+205
View File
@@ -0,0 +1,205 @@
import { Box3, DoubleSide, Group, Material, Mesh, Object3D, Vector3 } from "three";
import * as TWEEN from "@tweenjs/tween.js";
import { UpdateController, CameraC_internal } from "@24tools/playable_template";
import { ThreeC } from "./ThreeC";
import type { Crate } from "./LootableC";
// --- Placement ---
const HEIGHT_ABOVE = 1.4; // world Y offset of the bar above the crate origin
const BAR_SCALE = 1.2; // world scale of the cloned bar (prototype is ~1m wide)
// --- Fill animation ---
const FOREGROUND_MS = 120; // Foreground (current health) drops fast — animates first
const MIDDLEGROUND_DELAY = 120; // Middleground starts a touch later…
const MIDDLEGROUND_MS = 600; // …and catches up slowly behind it (the "lost chunk" sliver)
// --- Show / hide ---
const FADE_IN_MS = 150;
const FADE_OUT_MS = 700; // slow, soft fade-out
const HOLD_S = 3.0; // stay visible this long after the last hit, then fade out
// One bar instance bound to a crate. The two fills slide their right edge while
// keeping the left edge pinned: foreground leads (fast), middleground trails.
interface Bar {
group: Group; // billboarded container, positioned above the crate
foreground: Mesh; // lead fill (snaps to the new health quickly)
middleground: Mesh; // catch-up fill (trails behind the foreground)
foregroundBaseX: number; // foreground resting local X
foregroundMinX: number; // foreground geometry left edge (anchor for the shrink)
middlegroundBaseX: number;
middlegroundMinX: number;
materials: Material[]; // all 3 cloned materials (driven together by the fade)
alpha: number; // current fade level (0 hidden … 1 shown)
idleSeconds: number; // time since the last hit (drives the auto fade-out)
visible: boolean;
fadeTween: TWEEN.Tween<{ alpha: number }> | null; // live fade (stopped before re-firing)
foregroundTween: TWEEN.Tween<{ scaleX: number }> | null; // live fill tweens (stopped before re-firing)
middlegroundTween: TWEEN.Tween<{ scaleX: number }> | null;
}
/**
* In-world health bars above crates, cloned from the GLB "UI" prototype
* (Background track + Foreground + Middleground). A bar appears on the first hit
* and fades out when the crate is left alone. On each hit the Foreground snaps to
* the new health quickly and the Middleground trails behind it.
*/
export class HealthBarC {
private static protoNode: Object3D | null = null;
private static bars = new Map<Crate, Bar>();
private static tweens = new TWEEN.Group();
static init(prototype: Object3D | null) {
if (!prototype) {
console.warn("[HealthBar] no prototype (UI node) found — bars disabled");
return;
}
this.protoNode = prototype;
UpdateController.Instance.onUpdate.addDelegate((delta: number) => this.update(delta));
}
/** Show/refresh the bar for a crate at the given health fraction (0..1). */
static showDamage(crate: Crate, fraction: number) {
if (!this.protoNode) return;
let bar = this.bars.get(crate);
if (!bar) {
const built = this.build();
if (!built) return;
bar = built;
this.bars.set(crate, bar);
}
bar.idleSeconds = 0;
if (!bar.visible) { bar.visible = true; this.fade(bar, 1, FADE_IN_MS); }
const health = Math.max(0, Math.min(1, fraction));
// Foreground first (fast), Middleground catching up behind it (slower, delayed).
bar.foregroundTween = this.tweenFill(
bar.foregroundTween, bar.foreground, bar.foregroundBaseX, bar.foregroundMinX, health, FOREGROUND_MS, 0);
bar.middlegroundTween = this.tweenFill(
bar.middlegroundTween, bar.middleground, bar.middlegroundBaseX, bar.middlegroundMinX, health, MIDDLEGROUND_MS, MIDDLEGROUND_DELAY);
}
/** Crate gone (broken): fade the bar out and drop it. */
static hide(crate: Crate) {
const bar = this.bars.get(crate);
if (!bar) return;
this.bars.delete(crate);
this.fade(bar, 0, FADE_OUT_MS, () => {
ThreeC.removeFromScene(bar.group);
bar.materials.forEach(material => material.dispose());
});
}
// Clone the prototype, make its materials fade-able, center it, and add it to the
// scene. The bar is positioned over its crate every frame in update(), so build
// itself needs nothing from the crate.
private static build(): Bar | null {
const group = new Group();
const ui = this.protoNode!.clone(true);
ui.quaternion.identity(); // drop the prototype's authored tilt — we billboard instead
ui.visible = true;
ui.traverse(o => { o.visible = true; });
const foreground = ui.getObjectByName("UI_Foreground") as Mesh | undefined;
const middleground = ui.getObjectByName("UI_Middleground") as Mesh | undefined;
const background = ui.getObjectByName("UI_Background") as Mesh | undefined;
if (!foreground || !middleground || !background) {
console.warn("[HealthBar] prototype missing UI_Foreground/Middleground/Background");
return null;
}
// Keep the GLB's original look: clone each mesh's authored (textured, unlit)
// material and only make it fade-able + force the layer order (Foreground on
// top of Middleground on top of Background). depthTest off so the bar always
// draws over the scene; DoubleSide so it shows no matter how it's billboarded.
const materials: Material[] = [];
const prepMaterial = (mesh: Mesh, renderOrder: number) => {
const material = (mesh.material as Material).clone();
material.transparent = true;
material.opacity = 0; // start hidden; the fade-in brings it up
material.depthTest = false;
material.depthWrite = false;
material.side = DoubleSide;
mesh.material = material;
mesh.renderOrder = renderOrder;
materials.push(material);
};
prepMaterial(background, 0);
prepMaterial(middleground, 1);
prepMaterial(foreground, 2);
// Anchor data so the fills shrink from the right (left edge stays put).
const leftEdgeX = (mesh: Mesh) => { mesh.geometry.computeBoundingBox(); return mesh.geometry.boundingBox!.min.x; };
const foregroundMinX = leftEdgeX(foreground), middlegroundMinX = leftEdgeX(middleground);
const foregroundBaseX = foreground.position.x, middlegroundBaseX = middleground.position.x;
// Center the bar content on the group origin (the prototype meshes sit offset).
ui.updateMatrixWorld(true);
const center = new Box3().setFromObject(ui).getCenter(new Vector3());
ui.position.sub(center);
group.add(ui);
group.scale.setScalar(BAR_SCALE);
ThreeC.addToScene(group);
return {
group, foreground, middleground,
foregroundBaseX, foregroundMinX, middlegroundBaseX, middlegroundMinX,
materials, alpha: 0, idleSeconds: 0, visible: false,
fadeTween: null, foregroundTween: null, middlegroundTween: null,
};
}
// Animate one fill to a fraction, keeping its left edge fixed (right edge moves).
private static tweenFill(
prev: TWEEN.Tween<{ scaleX: number }> | null,
mesh: Mesh, baseX: number, minX: number, targetFraction: number, ms: number, delay: number,
) {
prev?.stop();
const state = { scaleX: mesh.scale.x };
return new TWEEN.Tween(state, this.tweens)
.to({ scaleX: targetFraction }, ms)
.delay(delay)
.easing(TWEEN.Easing.Quadratic.Out)
.onUpdate(({ scaleX }) => {
mesh.scale.x = scaleX;
mesh.position.x = baseX + minX * (1 - scaleX); // pin the left edge as it shrinks
})
.start();
}
// Fade all of a bar's materials to a target alpha. Stops any in-flight fade
// first so a re-show mid-fade-out doesn't fight it (fixes the flicker on the
// next hit right after the bar started disappearing).
private static fade(bar: Bar, to: number, ms: number, onDone?: () => void) {
bar.fadeTween?.stop();
const state = { alpha: bar.alpha };
bar.fadeTween = new TWEEN.Tween(state, this.tweens)
.to({ alpha: to }, ms)
.easing(TWEEN.Easing.Quadratic.Out)
.onUpdate(({ alpha }) => {
bar.alpha = alpha;
bar.materials.forEach(material => { material.opacity = alpha; });
})
.onComplete(() => { bar.fadeTween = null; onDone?.(); })
.start();
}
// Each frame: billboard active bars to the camera, keep them above their crate,
// fade out idle bars, and advance the tweens.
private static update(delta: number) {
this.tweens.update();
const camera = CameraC_internal.camera;
for (const [crate, bar] of this.bars) {
crate.root.getWorldPosition(bar.group.position);
bar.group.position.y += HEIGHT_ABOVE;
if (camera) bar.group.quaternion.copy(camera.quaternion);
if (bar.visible) {
bar.idleSeconds += delta;
if (bar.idleSeconds >= HOLD_S) { bar.visible = false; this.fade(bar, 0, FADE_OUT_MS); }
}
}
}
}