213 lines
9.0 KiB
TypeScript
213 lines
9.0 KiB
TypeScript
import { Box3, DoubleSide, Group, Material, Mesh, Object3D, Vector3 } from "three";
|
|
import * as TWEEN from "@tweenjs/tween.js";
|
|
import { CameraC_internal } from "@hitplay/playable_template";
|
|
import { ThreeC } from "./ThreeC";
|
|
import { GameEvents } from "../core/GameEvents";
|
|
import { GameLoop } from "../core/GameLoop";
|
|
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;
|
|
|
|
// Listen for health display events
|
|
GameEvents.onHealthDisplay.addDelegate(({ crate, healthFraction }) => this.showDamage(crate, healthFraction));
|
|
GameEvents.onHealthHide.addDelegate((crate) => this.hide(crate));
|
|
|
|
GameLoop.register((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); }
|
|
}
|
|
}
|
|
}
|
|
}
|