feat:
-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:
@@ -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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user