From 65e7c496b767324ca058ac1dd7311d2dafada8c0 Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Fri, 7 Nov 2025 15:17:49 -0600 Subject: [PATCH] Add ShipStatus system with automatic gauge updates and fuel consumption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented a comprehensive ship status management system with event-driven gauge updates and integrated fuel consumption for both linear and angular thrust. **New File: src/shipStatus.ts** - ShipStatus class with Observable pattern for status change events - Manages fuel, hull, and ammo values with automatic clamping (0-1 range) - Configurable max values for each resource type - Public getters: fuel, hull, ammo, getValues() - Setter methods: setFuel(), setHull(), setAmmo() with automatic event firing - Convenience methods: addFuel(), consumeFuel(), damageHull(), repairHull(), addAmmo(), consumeAmmo() - Status check methods: isFuelEmpty(), isDestroyed(), isAmmoEmpty() - Utility methods: reset(), setMaxValues(), dispose() - ShipStatusChangeEvent interface with statusType, oldValue, newValue, delta fields **Modified: src/scoreboard.ts** - Integrated ShipStatus instance as private _shipStatus - Constructor subscribes to ShipStatus.onStatusChanged observable - Added public shipStatus getter to expose status manager - Created createGaugesDisplay() method with 3 bar gauges (FUEL, HULL, AMMO) - Created createGaugeBar() helper for individual gauge construction - Added getBarColor() with smooth RGB gradient: green (1.0) -> yellow (0.5) -> red (0.0) - Renamed public methods to private: updateFuelBar(), updateHullBar(), updateAmmoBar() - Observable subscription automatically updates gauge visuals when status changes - Added dispose() method for cleanup of ShipStatus and observables - Updated initialize() to retrieve and setup screen/gauges meshes from GLB - Set initial test values to full (1.0) for all gauges **Modified: src/shipPhysics.ts** - Added ShipStatus import and private _shipStatus property - Added setShipStatus() method to connect status manager - Modified applyForces() to check fuel availability before applying linear force - Linear thrust fuel consumption: linearMagnitude (0-1) * 0.005 per frame - Added fuel check and consumption for angular thrust (rotation) - Angular thrust fuel consumption: normalized angularMagnitude (0-1) * 0.005 per frame - Forces only applied when fuel > 0 **Modified: src/ship.ts** - Connected ShipPhysics to Scoreboard's ShipStatus via setShipStatus() - Called immediately after physics initialization (line 148) This creates a fully integrated system where: 1. Ship movement (linear and angular) consumes fuel proportional to thrust 2. Fuel depletion prevents further thrust application 3. Gauge displays automatically update via observable events with color coding 4. Other systems can monitor/modify ship status through the same interface 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/scoreboard.ts | 251 ++++++++++++++++++++++++++++++++++++++++++--- src/ship.ts | 18 +--- src/shipPhysics.ts | 86 ++++++++++------ src/shipStatus.ts | 210 +++++++++++++++++++++++++++++++++++++ 4 files changed, 507 insertions(+), 58 deletions(-) create mode 100644 src/shipStatus.ts diff --git a/src/scoreboard.ts b/src/scoreboard.ts index b34f82f..5108e7f 100644 --- a/src/scoreboard.ts +++ b/src/scoreboard.ts @@ -1,12 +1,14 @@ -import {AdvancedDynamicTexture, Control, StackPanel, TextBlock} from "@babylonjs/gui"; +import {AdvancedDynamicTexture, Control, StackPanel, TextBlock, Rectangle, Container} from "@babylonjs/gui"; import {DefaultScene} from "./defaultScene"; import { - AbstractMesh, Mesh, + Mesh, MeshBuilder, - Observable, StandardMaterial, + Observable, Vector3, } from "@babylonjs/core"; import debugLog from './debug'; +import { ShipStatus } from './shipStatus'; + export type ScoreEvent = { score: number, message: string, @@ -21,8 +23,32 @@ export class Scoreboard { private _active = false; private _done = false; public readonly onScoreObservable: Observable = new Observable(); - constructor() { + // Gauge bar fill rectangles + private _fuelBar: Rectangle | null = null; + private _hullBar: Rectangle | null = null; + private _ammoBar: Rectangle | null = null; + + // Ship status manager + private _shipStatus: ShipStatus; + + constructor() { + this._shipStatus = new ShipStatus(); + + // Subscribe to status changes to automatically update gauges + this._shipStatus.onStatusChanged.add((event) => { + switch (event.statusType) { + case 'fuel': + this.updateFuelBar(event.newValue); + break; + case 'hull': + this.updateHullBar(event.newValue); + break; + case 'ammo': + this.updateAmmoBar(event.newValue); + break; + } + }); } public get done() { return this._done; @@ -30,26 +56,60 @@ export class Scoreboard { public set done(value: boolean) { this._done = value; } + + /** + * Get the ship status manager + */ + public get shipStatus(): ShipStatus { + return this._shipStatus; + } + public setRemainingCount(count: number) { this._remaining = count; } - public initialize(baseMesh: Mesh) { + public initialize(): void { const scene = DefaultScene.MainScene; const parent = scene.getNodeById('ship'); debugLog('Scoreboard parent:', parent); debugLog('Initializing scoreboard'); - let scoreboard = null; + let scoreboard: Mesh | null = null; - if (baseMesh) { - scoreboard = baseMesh; + // Retrieve and setup screen mesh from the loaded GLB + const screen = scene.getMaterialById("Screen")?.getBindedMeshes()[0] as Mesh; + if (screen) { + // Setup screen mesh: adjust pivot point and rotation + const oldParent = screen.parent; + screen.setParent(null); + screen.setPivotPoint(screen.getBoundingInfo().boundingSphere.center); + screen.setParent(oldParent); + screen.rotation.y = Math.PI; + + scoreboard = screen; scoreboard.material.dispose(); - //scoreboard.material = new StandardMaterial("scoreboard", scene); + } - } else { + // Retrieve and setup gauges mesh from the loaded GLB + const gauges = scene.getMaterialById("Gauges")?.getBindedMeshes()[0] as Mesh; + + if (gauges) { + // Setup gauges mesh: adjust pivot point and rotation + const oldParent = gauges.parent; + gauges.setParent(null); + gauges.setPivotPoint(gauges.getBoundingInfo().boundingSphere.center); + gauges.setParent(oldParent); + //gauges.rotation.z = Math.PI; + + // Create gauges display + this.createGaugesDisplay(gauges); + } + + // Fallback: create a plane if screen mesh not found + if (!scoreboard) { + console.error('Screen mesh not found, creating fallback plane'); scoreboard = MeshBuilder.CreatePlane("scoreboard", {width: 1, height: 1}, scene); - scoreboard.parent =parent; + scoreboard.parent = parent; scoreboard.position.y = 1.05; scoreboard.position.z = 2.1; @@ -66,7 +126,7 @@ export class Scoreboard { const advancedTexture = AdvancedDynamicTexture.CreateForMesh(scoreboard, 512, 512); - advancedTexture.background = "green"; + advancedTexture.background = "black"; advancedTexture.hasAlpha = false; const scoreText = this.createText(); @@ -122,4 +182,171 @@ export class Scoreboard { private calculateScore() { return Math.floor(this._score); } + + /** + * Create the gauges display with 3 bar gauges (Fuel, Hull, Ammo) + */ + private createGaugesDisplay(gaugesMesh: Mesh): void { + // Store reference to old material to dispose after new one is created + const oldMaterial = gaugesMesh.material; + + // Create AdvancedDynamicTexture for the gauges mesh + // This creates a new StandardMaterial and assigns it to the mesh + const gaugesTexture = AdvancedDynamicTexture.CreateForMesh(gaugesMesh, 512, 512); + gaugesTexture.coordinatesIndex = 2; + + + + gaugesTexture.background = "#444444"; + gaugesTexture.hasAlpha = false; + + // Now dispose the old material after the new one is assigned + if (oldMaterial) { + oldMaterial.dispose(true, true); + } + + debugLog('Gauges texture created, material:', gaugesMesh.material?.name); + + // Create a vertical stack panel for the gauges + const panel = new StackPanel('GaugesPanel'); + panel.rotation = Math.PI; + panel.isVertical = true; + panel.width = "100%"; + panel.height = "100%"; + + // Create the three gauges + this._fuelBar = this.createGaugeBar("FUEL", "#00FF00", panel); + this._hullBar = this.createGaugeBar("HULL", "#00FF00", panel); + this._ammoBar = this.createGaugeBar("AMMO", "#00FF00", panel); + + gaugesTexture.addControl(panel); + + let i = 0; + // Force the texture to update + //gaugesTexture.markAsDirty(); + + // Set initial values to full (for testing visibility) + this._shipStatus.setFuel(1); + this._shipStatus.setHull(1); + this._shipStatus.setAmmo(1); + + debugLog('Gauges display created with initial test values'); + } + + /** + * Create a single gauge bar with label + */ + private createGaugeBar(label: string, color: string, parent: Container): Rectangle { + // Container for this gauge (label + bar) + const gaugeContainer = new StackPanel(); + gaugeContainer.isVertical = true; + gaugeContainer.height = "140px"; + gaugeContainer.width = "100%"; + gaugeContainer.paddingBottom = "15px"; + + // Label text + const labelText = new TextBlock(); + labelText.text = label; + labelText.color = "#FFFFFF"; + labelText.fontSize = "60px"; + labelText.height = "70px"; + labelText.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT; + gaugeContainer.addControl(labelText); + + // Bar background (border and empty space) + const barBackground = new Rectangle(); + barBackground.height = "50px"; + barBackground.width = "100%"; + barBackground.thickness = 3; + barBackground.color = "#FFFFFF"; + barBackground.background = "#333333"; + barBackground.cornerRadius = 5; + + // Bar fill (the actual gauge) + const barFill = new Rectangle(); + barFill.height = "100%"; + barFill.width = "100%"; // Will be updated dynamically + barFill.thickness = 0; + barFill.background = color; + barFill.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT; + barFill.cornerRadius = 3; + + barBackground.addControl(barFill); + gaugeContainer.addControl(barBackground); + + parent.addControl(gaugeContainer); + + return barFill; + } + + /** + * Get bar color based on value with smooth gradient + * Green (1.0) -> Yellow (0.5) -> Red (0.0) + */ + private getBarColor(value: number): string { + // Clamp value between 0 and 1 + value = Math.max(0, Math.min(1, value)); + + let red: number, green: number; + + if (value >= 0.5) { + // Interpolate from yellow (0.5) to green (1.0) + // At 0.5: RGB(255, 255, 0) yellow + // At 1.0: RGB(0, 255, 0) green + const t = (value - 0.5) * 2; // 0 to 1 range + red = Math.round(255 * (1 - t)); + green = 255; + } else { + // Interpolate from red (0.0) to yellow (0.5) + // At 0.0: RGB(255, 0, 0) red + // At 0.5: RGB(255, 255, 0) yellow + const t = value * 2; // 0 to 1 range + red = 255; + green = Math.round(255 * t); + } + + // Convert to hex + const redHex = red.toString(16).padStart(2, '0'); + const greenHex = green.toString(16).padStart(2, '0'); + return `#${redHex}${greenHex}00`; + } + /** + * Internal method to update fuel gauge bar + */ + private updateFuelBar(value: number): void { + if (this._fuelBar) { + this._fuelBar.width = `${value * 100}%`; + this._fuelBar.background = this.getBarColor(value); + } + } + + /** + * Internal method to update hull gauge bar + */ + private updateHullBar(value: number): void { + if (this._hullBar) { + this._hullBar.width = `${value * 100}%`; + this._hullBar.background = this.getBarColor(value); + } + } + + /** + * Internal method to update ammo gauge bar + */ + private updateAmmoBar(value: number): void { + if (this._ammoBar) { + this._ammoBar.width = `${value * 100}%`; + this._ammoBar.background = this.getBarColor(value); + } + } + + /** + * Dispose of scoreboard resources + */ + public dispose(): void { + if (this._shipStatus) { + this._shipStatus.dispose(); + } + this.onScoreObservable.clear(); + } } \ No newline at end of file diff --git a/src/ship.ts b/src/ship.ts index 1a66b00..b6c1dd7 100644 --- a/src/ship.ts +++ b/src/ship.ts @@ -145,6 +145,7 @@ export class Ship { // Initialize physics controller this._physics = new ShipPhysics(); + this._physics.setShipStatus(this._scoreboard.shipStatus); // Setup physics update loop (every 10 frames) DefaultScene.MainScene.onAfterRenderObservable.add(() => { @@ -174,21 +175,8 @@ export class Ship { centerGap: 0.5, }); - // Setup scoreboard on screen - console.log(data.meshes.get("Screen")); - const screen = DefaultScene.MainScene - .getMaterialById("Screen") - .getBindedMeshes()[0] as AbstractMesh; - console.log(screen); - const old = screen.parent; - screen.setParent(null); - screen.setPivotPoint(screen.getBoundingInfo().boundingSphere.center); - screen.setParent(old); - screen.rotation.y = Math.PI; - console.log(screen.rotation); - console.log(screen.scaling); - - this._scoreboard.initialize(screen as any); + // Initialize scoreboard (it will retrieve and setup its own screen mesh) + this._scoreboard.initialize(); } /** diff --git a/src/shipPhysics.ts b/src/shipPhysics.ts index 459d0ff..7d728ae 100644 --- a/src/shipPhysics.ts +++ b/src/shipPhysics.ts @@ -1,5 +1,6 @@ import { PhysicsBody, TransformNode, Vector2, Vector3 } from "@babylonjs/core"; import { GameConfig } from "./gameConfig"; +import { ShipStatus } from "./shipStatus"; export interface InputState { leftStick: Vector2; @@ -16,6 +17,14 @@ export interface ForceApplicationResult { * Reads physics parameters from GameConfig for runtime tuning */ export class ShipPhysics { + private _shipStatus: ShipStatus | null = null; + + /** + * Set the ship status instance for fuel consumption tracking + */ + public setShipStatus(shipStatus: ShipStatus): void { + this._shipStatus = shipStatus; + } /** * Apply forces to the ship based on input state * @param inputState - Current input state (stick positions) @@ -49,24 +58,31 @@ export class ShipPhysics { if (Math.abs(leftStick.y) > 0.1) { linearMagnitude = Math.abs(leftStick.y); - // Only apply force if we haven't reached max velocity - if (currentSpeed < config.maxLinearVelocity) { - // Get local direction (Z-axis for forward/backward thrust) - const localDirection = new Vector3(0, 0, -leftStick.y); - // Transform to world space - const worldDirection = Vector3.TransformNormal( - localDirection, - transformNode.getWorldMatrix() - ); - const force = worldDirection.scale(config.linearForceMultiplier); + // Check if we have fuel before applying force + if (this._shipStatus && this._shipStatus.fuel > 0) { + // Only apply force if we haven't reached max velocity + if (currentSpeed < config.maxLinearVelocity) { + // Get local direction (Z-axis for forward/backward thrust) + const localDirection = new Vector3(0, 0, -leftStick.y); + // Transform to world space + const worldDirection = Vector3.TransformNormal( + localDirection, + transformNode.getWorldMatrix() + ); + const force = worldDirection.scale(config.linearForceMultiplier); - // Calculate thrust point: center of mass + offset (0, 1, 0) in world space - const thrustPoint = Vector3.TransformCoordinates( - physicsBody.getMassProperties().centerOfMass.add(new Vector3(0, 1, 0)), - transformNode.getWorldMatrix() - ); + // Calculate thrust point: center of mass + offset (0, 1, 0) in world space + const thrustPoint = Vector3.TransformCoordinates( + physicsBody.getMassProperties().centerOfMass.add(new Vector3(0, 1, 0)), + transformNode.getWorldMatrix() + ); - physicsBody.applyForce(force, thrustPoint); + physicsBody.applyForce(force, thrustPoint); + + // Consume fuel: normalized magnitude (0-1) * 0.01 = max consumption of 0.01 per frame + const fuelConsumption = linearMagnitude * 0.005; + this._shipStatus.consumeFuel(fuelConsumption); + } } } @@ -78,24 +94,32 @@ export class ShipPhysics { // Apply angular forces if any stick has significant rotation input if (angularMagnitude > 0.1) { - const currentAngularSpeed = currentAngularVelocity.length(); + // Check if we have fuel before applying torque + if (this._shipStatus && this._shipStatus.fuel > 0) { + const currentAngularSpeed = currentAngularVelocity.length(); - // Only apply torque if we haven't reached max angular velocity - if (currentAngularSpeed < config.maxAngularVelocity) { - const yaw = -leftStick.x; - const pitch = rightStick.y; - const roll = rightStick.x; + // Only apply torque if we haven't reached max angular velocity + if (currentAngularSpeed < config.maxAngularVelocity) { + const yaw = -leftStick.x; + const pitch = rightStick.y; + const roll = rightStick.x; - // Create torque in local space, then transform to world space - const localTorque = new Vector3(pitch, yaw, roll).scale( - config.angularForceMultiplier - ); - const worldTorque = Vector3.TransformNormal( - localTorque, - transformNode.getWorldMatrix() - ); + // Create torque in local space, then transform to world space + const localTorque = new Vector3(pitch, yaw, roll).scale( + config.angularForceMultiplier + ); + const worldTorque = Vector3.TransformNormal( + localTorque, + transformNode.getWorldMatrix() + ); - physicsBody.applyAngularImpulse(worldTorque); + physicsBody.applyAngularImpulse(worldTorque); + + // Consume fuel: normalized magnitude (0-3 max) / 3 * 0.01 = max consumption of 0.01 per frame + const normalizedAngularMagnitude = Math.min(angularMagnitude / 3.0, 1.0); + const fuelConsumption = normalizedAngularMagnitude * 0.005; + this._shipStatus.consumeFuel(fuelConsumption); + } } } diff --git a/src/shipStatus.ts b/src/shipStatus.ts new file mode 100644 index 0000000..2c30bfe --- /dev/null +++ b/src/shipStatus.ts @@ -0,0 +1,210 @@ +import { Observable } from "@babylonjs/core"; + +/** + * Event data for ship status changes + */ +export interface ShipStatusChangeEvent { + statusType: "fuel" | "hull" | "ammo"; + oldValue: number; + newValue: number; + delta: number; +} + +/** + * Ship status values container + */ +export interface ShipStatusValues { + fuel: number; + hull: number; + ammo: number; +} + +/** + * Manages ship status values (fuel, hull integrity, ammo) + * Provides observable events for changes and automatic clamping to 0-1 range + */ +export class ShipStatus { + private _fuel: number = 1.0; + private _hull: number = 1.0; + private _ammo: number = 1.0; + + // Maximum values for each resource + private _maxFuel: number = 1.0; + private _maxHull: number = 1.0; + private _maxAmmo: number = 1.0; + + // Observable for status changes + public readonly onStatusChanged: Observable = + new Observable(); + + /** + * Get current fuel level (0-1) + */ + public get fuel(): number { + return this._fuel; + } + + /** + * Get current hull integrity (0-1) + */ + public get hull(): number { + return this._hull; + } + + /** + * Get current ammo level (0-1) + */ + public get ammo(): number { + return this._ammo; + } + + /** + * Get all status values + */ + public getValues(): ShipStatusValues { + return { + fuel: this._fuel, + hull: this._hull, + ammo: this._ammo, + }; + } + + /** + * Set fuel level directly (clamped to 0-1) + */ + public setFuel(value: number): void { + const oldValue = this._fuel; + this._fuel = Math.max(0, Math.min(this._maxFuel, value)); + + if (oldValue !== this._fuel) { + this.onStatusChanged.notifyObservers({ + statusType: "fuel", + oldValue, + newValue: this._fuel, + delta: this._fuel - oldValue, + }); + } + } + + /** + * Set hull integrity directly (clamped to 0-1) + */ + public setHull(value: number): void { + const oldValue = this._hull; + this._hull = Math.max(0, Math.min(this._maxHull, value)); + + if (oldValue !== this._hull) { + this.onStatusChanged.notifyObservers({ + statusType: "hull", + oldValue, + newValue: this._hull, + delta: this._hull - oldValue, + }); + } + } + + /** + * Set ammo level directly (clamped to 0-1) + */ + public setAmmo(value: number): void { + const oldValue = this._ammo; + this._ammo = Math.max(0, Math.min(this._maxAmmo, value)); + + if (oldValue !== this._ammo) { + this.onStatusChanged.notifyObservers({ + statusType: "ammo", + oldValue, + newValue: this._ammo, + delta: this._ammo - oldValue, + }); + } + } + + /** + * Increment fuel by delta amount + */ + public addFuel(delta: number): void { + this.setFuel(this._fuel + delta); + } + + /** + * Decrement fuel by delta amount + */ + public consumeFuel(delta: number): void { + this.setFuel(this._fuel - delta); + } + + /** + * Damage hull by delta amount + */ + public damageHull(delta: number): void { + this.setHull(this._hull - delta); + } + + /** + * Repair hull by delta amount + */ + public repairHull(delta: number): void { + this.setHull(this._hull + delta); + } + + /** + * Increment ammo by delta amount + */ + public addAmmo(delta: number): void { + this.setAmmo(this._ammo + delta); + } + + /** + * Decrement ammo by delta amount (fire weapon) + */ + public consumeAmmo(delta: number): void { + this.setAmmo(this._ammo - delta); + } + + /** + * Check if fuel is depleted + */ + public isFuelEmpty(): boolean { + return this._fuel <= 0; + } + + /** + * Check if hull is destroyed + */ + public isDestroyed(): boolean { + return this._hull <= 0; + } + + /** + * Check if ammo is depleted + */ + public isAmmoEmpty(): boolean { + return this._ammo <= 0; + } + + /** + * Reset all values to full + */ + public reset(): void { + this.setFuel(this._maxFuel); + this.setHull(this._maxHull); + this.setAmmo(this._maxAmmo); + } + + /** + * Set maximum values for resources + */ + public setMaxValues(fuel?: number, hull?: number, ammo?: number): void { + if (fuel !== undefined) this._maxFuel = fuel; + if (hull !== undefined) this._maxHull = hull; + if (ammo !== undefined) this._maxAmmo = ammo; + } + + /** + * Dispose observables + */ + public dispose(): void { + this.onStatusChanged.clear(); + } +}