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(); 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); } } } } }