space-game/src/ship/ship.ts
Michael Mainguy 749cc18211 Refactor scoring system to additive model starting at 0
- Replace multiplier-based scoring with additive system
- Score builds from asteroid destruction based on size and timing
- Small asteroids (<10 scale): 1000 pts, Medium (10-20): 500 pts, Large (>20): 250 pts
- Timing multiplier: 3x in first 1/3 of par time, 2x in middle, 1x in last third
- End-game bonuses only applied at game end (hull, fuel, accuracy)
- Add scale property to ScoreEvent for point calculation
- Update status screen to show "CURRENT SCORE" during play, "FINAL SCORE" at end
- Refactor star ratings display into individual columns
- Fix button clipping on hover with clipChildren = false
- Add reusable button hover effects utility

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 16:39:27 -06:00

827 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
Color3,
FreeCamera,
HavokPlugin,
Mesh,
Observable,
PhysicsActivationControl,
PhysicsAggregate,
PhysicsMotionType,
PhysicsShapeType,
TransformNode,
Vector2,
Vector3,
WebXRInputSource,
} from "@babylonjs/core";
import type { AudioEngineV2 } from "@babylonjs/core";
import { DefaultScene } from "../core/defaultScene";
import { GameConfig } from "../core/gameConfig";
import { Sight } from "./sight";
import log from "../core/logger";
import { Scoreboard } from "../ui/hud/scoreboard";
import loadAsset from "../utils/loadAsset";
import { KeyboardInput } from "./input/keyboardInput";
import { ControllerInput } from "./input/controllerInput";
import { ShipPhysics } from "./shipPhysics";
import { ShipAudio } from "./shipAudio";
import { VoiceAudioSystem } from "./voiceAudioSystem";
import { WeaponSystem } from "./weaponSystem";
import { StatusScreen } from "../ui/hud/statusScreen";
import { GameStats } from "../game/gameStats";
import { getAnalytics } from "../analytics";
import { InputControlManager } from "./input/inputControlManager";
export class Ship {
private _ship: TransformNode;
private _scoreboard: Scoreboard;
private _camera: FreeCamera;
private _audioEngine: AudioEngineV2;
private _sight: Sight;
// New modular systems
private _keyboardInput: KeyboardInput;
private _controllerInput: ControllerInput;
private _physics: ShipPhysics;
private _audio: ShipAudio;
private _voiceAudio: VoiceAudioSystem;
private _weapons: WeaponSystem;
private _statusScreen: StatusScreen;
private _gameStats: GameStats;
// Frame counter for physics updates
private _frameCount: number = 0;
// Resupply system
private _landingAggregate: PhysicsAggregate | null = null;
private _resupplyTimer: number = 0;
private _isInLandingZone: boolean = false;
private _isReplayMode: boolean;
// Observable for replay requests
public onReplayRequestObservable: Observable<void> = new Observable<void>();
// Observable for mission brief trigger dismissal
private _onMissionBriefTriggerObservable: Observable<void> = new Observable<void>();
// Observable for collision events (for hint system)
private _onCollisionObservable: Observable<{ collisionType: string }> = new Observable<{ collisionType: string }>();
// Auto-show status screen flag
private _statusScreenAutoShown: boolean = false;
// Flag to prevent game end checks until gameplay has started
private _gameplayStarted: boolean = false;
// Scene observer references (for cleanup)
private _physicsObserver: any = null;
private _renderObserver: any = null;
constructor(audioEngine?: AudioEngineV2, isReplayMode: boolean = false) {
this._audioEngine = audioEngine;
this._isReplayMode = isReplayMode;
}
public get scoreboard(): Scoreboard {
return this._scoreboard;
}
public get gameStats(): GameStats {
return this._gameStats;
}
public get statusScreen(): StatusScreen {
return this._statusScreen;
}
public get keyboardInput(): KeyboardInput {
return this._keyboardInput;
}
public get isInLandingZone(): boolean {
return this._isInLandingZone;
}
/**
* Start gameplay - enables game end condition checking
* Call this after level initialization is complete
*/
public startGameplay(): void {
this._gameplayStarted = true;
log.debug('[Ship] Gameplay started - game end conditions now active');
}
public get onMissionBriefTriggerObservable(): Observable<void> {
return this._onMissionBriefTriggerObservable;
}
public get onCollisionObservable(): Observable<{ collisionType: string }> {
return this._onCollisionObservable;
}
public get velocity(): Vector3 {
if (this._ship?.physicsBody) {
return this._ship.physicsBody.getLinearVelocity();
}
return Vector3.Zero();
}
public set position(newPosition: Vector3) {
const body = this._ship.physicsBody;
// Physics body might not exist yet if called before initialize() completes
if (!body) {
// Just set position directly on transform node
this._ship.position.copyFrom(newPosition);
return;
}
body.disablePreStep = false;
body.transformNode.position.copyFrom(newPosition);
DefaultScene.MainScene.onAfterRenderObservable.addOnce(() => {
body.disablePreStep = true;
});
}
public setLinearVelocity(velocity: Vector3): void {
if (this._ship?.physicsBody) {
this._ship.physicsBody.setLinearVelocity(velocity);
}
}
public setAngularVelocity(velocity: Vector3): void {
if (this._ship?.physicsBody) {
this._ship.physicsBody.setAngularVelocity(velocity);
}
}
public async initialize(initialPosition?: Vector3) {
this._scoreboard = new Scoreboard();
this._scoreboard.setShip(this); // Pass ship reference for velocity reading
this._gameStats = new GameStats();
this._ship = new TransformNode("shipBase", DefaultScene.MainScene);
const data = await loadAsset("ship.glb");
this._ship = data.container.transformNodes[0];
// Set position BEFORE creating physics body to avoid collision race condition
if (initialPosition) {
this._ship.position.copyFrom(initialPosition);
}
// Create physics if enabled
const config = GameConfig.getInstance();
if (config.physicsEnabled) {
log.info("Physics Enabled for Ship");
if (this._ship) {
const agg = new PhysicsAggregate(
this._ship,
PhysicsShapeType.MESH,
{
mass: 10,
mesh: data.container.rootNodes[0].getChildMeshes()[0] as Mesh,
},
DefaultScene.MainScene
);
agg.body.setMotionType(PhysicsMotionType.DYNAMIC);
agg.body.setLinearDamping(config.shipPhysics.linearDamping);
agg.body.setAngularDamping(config.shipPhysics.angularDamping);
agg.body.setAngularVelocity(new Vector3(0, 0, 0));
agg.body.setCollisionCallbackEnabled(true);
// Debug: Log center of mass before override
const massProps = agg.body.getMassProperties();
log.info(`[Ship] Original center of mass (local): ${massProps.centerOfMass.toString()}`);
log.info(`[Ship] Mass: ${massProps.mass}`);
log.info(`[Ship] Inertia: ${massProps.inertia.toString()}`);
// Override center of mass to origin to prevent thrust from causing torque
// (mesh-based physics was calculating offset center of mass from geometry)
agg.body.setMassProperties({
mass: 10,
centerOfMass: new Vector3(0, 0, 0),
inertia: massProps.inertia,
inertiaOrientation: massProps.inertiaOrientation
});
log.info(`[Ship] Center of mass overridden to: ${agg.body.getMassProperties().centerOfMass.toString()}`);
// Configure physics sleep behavior from config
// (disabling sleep prevents abrupt stops at zero linear velocity)
if (config.shipPhysics.alwaysActive) {
const physicsPlugin = DefaultScene.MainScene.getPhysicsEngine()?.getPhysicsPlugin() as HavokPlugin;
if (physicsPlugin) {
physicsPlugin.setActivationControl(agg.body, PhysicsActivationControl.ALWAYS_ACTIVE);
}
}
// Register collision handler for energy-based hull damage
const observable = agg.body.getCollisionObservable();
observable.add((collisionEvent) => {
// Only calculate damage on collision start to avoid double-counting
if (collisionEvent.type === 'COLLISION_STARTED') {
// Get collision bodies
const shipBody = collisionEvent.collider;
const otherBody = collisionEvent.collidedAgainst;
// Get velocities
const shipVelocity = shipBody.getLinearVelocity();
const otherVelocity = otherBody.getLinearVelocity();
// Calculate relative velocity
const relativeVelocity = shipVelocity.subtract(otherVelocity);
const relativeSpeed = relativeVelocity.length();
// Get masses
const shipMass = 10; // Known ship mass from aggregate creation
const otherMass = otherBody.getMassProperties().mass;
// Calculate reduced mass for collision
const reducedMass = (shipMass * otherMass) / (shipMass + otherMass);
// Calculate kinetic energy of collision
const kineticEnergy = 0.5 * reducedMass * relativeSpeed * relativeSpeed;
// Convert energy to damage (tuning factor)
// 1000 units of energy = 0.01 (1%) damage
const ENERGY_TO_DAMAGE_FACTOR = 0.01 / 1000;
const damage = Math.min(kineticEnergy * ENERGY_TO_DAMAGE_FACTOR, 0.5); // Cap at 50% per hit
// Apply damage if above minimum threshold
if (this._scoreboard?.shipStatus && damage > 0.001) {
this._scoreboard.shipStatus.damageHull(damage);
log.debug(`Collision damage: ${damage.toFixed(4)} (energy: ${kineticEnergy.toFixed(1)}, speed: ${relativeSpeed.toFixed(1)} m/s)`);
// Play collision sound
if (this._audio) {
this._audio.playCollisionSound();
}
// Notify collision observable for hint system
this._onCollisionObservable.notifyObservers({
collisionType: 'any'
});
}
}
});
} else {
log.warn("No geometry mesh found, cannot create physics");
}
}
// Initialize audio system
if (this._audioEngine) {
this._audio = new ShipAudio(this._audioEngine);
await this._audio.initialize();
// Initialize voice audio system
this._voiceAudio = new VoiceAudioSystem();
await this._voiceAudio.initialize(this._audioEngine);
// Subscribe voice system to ship status events
this._voiceAudio.subscribeToEvents(this._scoreboard.shipStatus);
}
// Initialize weapon system
this._weapons = new WeaponSystem(DefaultScene.MainScene);
this._weapons.initialize();
this._weapons.setShipStatus(this._scoreboard.shipStatus);
this._weapons.setGameStats(this._gameStats);
this._weapons.setScoreObservable(this._scoreboard.onScoreObservable);
if (this._ship.physicsBody) {
this._weapons.setShipBody(this._ship.physicsBody);
}
// Initialize input systems (skip in replay mode)
if (!this._isReplayMode) {
this._keyboardInput = new KeyboardInput(DefaultScene.MainScene);
this._keyboardInput.setup();
this._controllerInput = new ControllerInput();
// Register input systems with InputControlManager
const inputManager = InputControlManager.getInstance();
inputManager.registerInputSystems(this._keyboardInput, this._controllerInput);
// Wire up shooting events
this._keyboardInput.onShootObservable.add(() => {
this.handleShoot();
});
this._controllerInput.onShootObservable.add(() => {
this.handleShoot();
});
// Wire up status screen toggle event
this._controllerInput.onStatusScreenToggleObservable.add(() => {
if (this._statusScreen) {
if (this._statusScreen.isVisible) {
// Hide status screen - InputControlManager will handle control re-enabling
this._statusScreen.hide();
} else {
// Show status screen (manual pause, not game end)
// InputControlManager will handle control disabling
this._statusScreen.show(false);
}
}
});
// Wire up inspector toggle event (Y button)
this._controllerInput.onInspectorToggleObservable.add(() => {
import('@babylonjs/inspector').then(() => {
const scene = DefaultScene.MainScene;
if (scene.debugLayer.isVisible()) {
scene.debugLayer.hide();
} else {
scene.debugLayer.show({ overlay: true, showExplorer: true });
}
});
});
// Wire up camera adjustment events
this._keyboardInput.onCameraChangeObservable.add((cameraKey) => {
if (cameraKey === 1) {
this._camera.position.x = 15;
this._camera.rotation.y = -Math.PI / 2;
}
});
this._controllerInput.onCameraAdjustObservable.add((adjustment) => {
if (DefaultScene.XR?.baseExperience?.camera) {
const camera = DefaultScene.XR.baseExperience.camera;
if (adjustment.direction === "down") {
camera.position.y = camera.position.y - 0.1;
} else {
camera.position.y = camera.position.y + 0.1;
}
}
});
}
// Initialize physics controller
this._physics = new ShipPhysics();
this._physics.setShipStatus(this._scoreboard.shipStatus);
this._physics.setGameStats(this._gameStats);
// Setup physics update loop (every 10 frames)
let p = 0;
this._physicsObserver = DefaultScene.MainScene.onAfterPhysicsObservable.add(() => {
this.updatePhysics();
});
let renderFrameCount = 0;
this._renderObserver = DefaultScene.MainScene.onAfterRenderObservable.add(() => {
// Update voice audio system (checks for completed sounds and plays next in queue)
if (this._voiceAudio) {
this._voiceAudio.update();
}
// Update projectiles (shape casting collision detection)
if (this._weapons) {
const deltaTime = DefaultScene.MainScene.getEngine().getDeltaTime() / 1000;
this._weapons.update(deltaTime);
}
// Check game end conditions every 30 frames (~0.5 sec at 60fps)
if (renderFrameCount++ % 30 === 0) {
this.checkGameEndConditions();
}
});
// Setup camera
this._camera = new FreeCamera(
"Flat Camera",
new Vector3(0, 1.5, 0),
DefaultScene.MainScene
);
this._camera.parent = this._ship;
// Rotate camera 180 degrees around Y to compensate for inverted ship GLB model
this._camera.rotation = new Vector3(0, Math.PI, 0);
// Set as active camera if XR is not available
if (!DefaultScene.XR && !this._isReplayMode) {
DefaultScene.MainScene.activeCamera = this._camera;
//this._camera.attachControl(DefaultScene.MainScene.getEngine().getRenderingCanvas(), true);
log.debug('Flat camera set as active camera');
}
// Create sight reticle
this._sight = new Sight(DefaultScene.MainScene, this._ship, {
position: new Vector3(0, 0.1, 125),
circleRadius: 2,
crosshairLength: 1.5,
lineThickness: 0.1,
color: Color3.Green(),
renderingGroupId: 3,
centerGap: 0.5,
});
// Initialize scoreboard (it will retrieve and setup its own screen mesh)
this._scoreboard.initialize();
// Subscribe to score events to track asteroids destroyed
this._scoreboard.onScoreObservable.add((event) => {
// Each score event represents an asteroid destroyed, pass scale for point calc
this._gameStats.recordAsteroidDestroyed(event.scale || 1);
// Track asteroid destruction in analytics
try {
const analytics = getAnalytics();
analytics.track('asteroid_destroyed', {
weaponType: 'laser',
distance: 0,
asteroidSize: event.scale || 0,
remainingCount: this._scoreboard.remaining
}, { sampleRate: 0.2 }); // Sample 20% of asteroid events to reduce data
} catch (error) {
// Analytics not initialized or failed - don't break gameplay
log.debug('Analytics tracking failed:', error);
}
});
// Subscribe to ship status changes to track hull damage
this._scoreboard.shipStatus.onStatusChanged.add((event) => {
if (event.statusType === "hull" && event.delta < 0) {
// Hull damage (delta is negative)
const damageAmount = Math.abs(event.delta);
this._gameStats.recordHullDamage(damageAmount);
// Track hull damage in analytics
try {
const analytics = getAnalytics();
analytics.track('hull_damage', {
damageAmount: damageAmount,
remainingHull: this._scoreboard.shipStatus.hull,
damagePercent: damageAmount,
source: 'asteroid_collision' // Default assumption
});
} catch (error) {
log.debug('Analytics tracking failed:', error);
}
}
});
// Initialize status screen with callbacks
this._statusScreen = new StatusScreen(
DefaultScene.MainScene,
this._ship,
this._gameStats,
() => this.handleReplayRequest(),
() => this.handleExitVR(),
() => this.handleResume(),
() => this.handleNextLevel()
);
this._statusScreen.initialize();
}
/**
* Handle replay button click from status screen
*/
private handleReplayRequest(): void {
log.debug('Replay button clicked - notifying observers');
this.onReplayRequestObservable.notifyObservers();
}
/**
* Handle exit VR button click from status screen
*/
private async handleExitVR(): Promise<void> {
log.debug('Exit VR button clicked - navigating to home');
try {
// Ensure the app UI is visible before navigating (safety net)
const appElement = document.getElementById('app');
if (appElement) {
appElement.style.display = 'block';
}
const headerElement = document.getElementById('appHeader');
if (headerElement) {
headerElement.style.display = 'block';
}
// Navigate back to home route
// The PlayLevel component's onDestroy will handle cleanup
const { navigate } = await import('svelte-routing');
navigate('/', { replace: true });
} catch (error) {
log.error('Failed to navigate, falling back to reload:', error);
window.location.reload();
}
}
/**
* Handle resume button click from status screen
*/
private handleResume(): void {
log.debug('Resume button clicked - hiding status screen');
// InputControlManager will handle re-enabling controls when status screen hides
this._statusScreen.hide();
}
/**
* Handle next level button click from status screen
*/
private handleNextLevel(): void {
log.debug('Next Level button clicked - navigating to level selector');
// Navigate back to level selector (root route)
window.location.hash = '#/';
window.location.reload();
}
/**
* Check game-ending conditions and auto-show status screen
* Conditions:
* 1. Ship outside landing zone AND hull < 0.01 (death)
* 2. Ship outside landing zone AND fuel < 0.01 AND velocity < 1 (stranded)
* 3. All asteroids destroyed AND ship inside landing zone (victory)
*/
private checkGameEndConditions(): void {
// Skip if gameplay hasn't started yet (prevents false triggers during initialization)
if (!this._gameplayStarted) {
return;
}
// Skip if already auto-shown or status screen doesn't exist
if (this._statusScreenAutoShown || !this._statusScreen || !this._scoreboard) {
return;
}
// Skip if no physics body yet
if (!this._ship?.physicsBody) {
return;
}
// Get current ship status
const hull = this._scoreboard.shipStatus.hull;
const fuel = this._scoreboard.shipStatus.fuel;
const asteroidsRemaining = this._scoreboard.remaining;
// Calculate total linear velocity
const linearVelocity = this._ship.physicsBody.getLinearVelocity();
const totalVelocity = linearVelocity.length();
// Check condition 1: Death by hull damage (outside landing zone)
if (!this._isInLandingZone && hull < 0.01) {
log.debug('Game end condition met: Hull critical outside landing zone');
this._statusScreen.show(true, false, 'death'); // Game ended, not victory, death reason
// InputControlManager will handle disabling controls when status screen shows
this._statusScreenAutoShown = true;
return;
}
// Check condition 2: Stranded (outside landing zone, no fuel, low velocity)
if (!this._isInLandingZone && fuel < 0.01 && totalVelocity < 5) {
log.debug('Game end condition met: Stranded (no fuel, low velocity)');
this._statusScreen.show(true, false, 'stranded'); // Game ended, not victory, stranded reason
// InputControlManager will handle disabling controls when status screen shows
this._statusScreenAutoShown = true;
return;
}
// Check condition 3: Victory (all asteroids destroyed, inside landing zone)
// Must have had asteroids to destroy in the first place (prevents false victory on init)
if (asteroidsRemaining <= 0 && this._isInLandingZone && this._scoreboard.hasAsteroidsToDestroy && this._ship.physicsBody.getLinearVelocity().length() < 5) {
log.debug('Game end condition met: Victory (all asteroids destroyed)');
this._statusScreen.show(true, true, 'victory'); // Game ended, VICTORY!
// InputControlManager will handle disabling controls when status screen shows
this._statusScreenAutoShown = true;
return;
}
}
/**
* Update physics based on combined input from all input sources
*/
private updatePhysics(): void {
if (!this._ship?.physicsBody) {
return;
}
// Check if we're in VR mode
const inVRMode = DefaultScene.XR?.baseExperience?.state === 2; // WebXRState.IN_XR = 2
// Combine input from keyboard and controller
const keyboardState = this._keyboardInput?.getInputState() || {
leftStick: Vector2.Zero(),
rightStick: Vector2.Zero(),
};
const controllerState = this._controllerInput?.getInputState() || {
leftStick: Vector2.Zero(),
rightStick: Vector2.Zero(),
};
// Merge inputs with smooth deadzone scaling (controller takes priority if active, keyboard disabled in VR)
// Deadzone: 0.1-0.15 range with linear scaling (avoids abrupt cliff effect)
const leftMagnitude = controllerState.leftStick.length();
const rightMagnitude = controllerState.rightStick.length();
// Scale factor: 0% at 0.1, 100% at 0.15, linear interpolation between
const leftScale = Math.max(0, Math.min(1, (leftMagnitude - 0.1) / 0.05));
const rightScale = Math.max(0, Math.min(1, (rightMagnitude - 0.1) / 0.05));
const combinedInput = {
leftStick:
leftMagnitude > 0.1
? controllerState.leftStick.scale(leftScale)
: (inVRMode ? Vector2.Zero() : keyboardState.leftStick),
rightStick:
rightMagnitude > 0.1
? controllerState.rightStick.scale(rightScale)
: (inVRMode ? Vector2.Zero() : keyboardState.rightStick),
};
// Apply forces and get magnitudes for audio
const forceMagnitudes = this._physics.applyForces(
combinedInput,
this._ship.physicsBody,
this._ship
);
// Update audio based on force magnitudes
if (this._audio) {
this._audio.updateThrustAudio(
forceMagnitudes.linearMagnitude,
forceMagnitudes.angularMagnitude
);
}
// Handle resupply when in landing zone
this.updateResupply();
}
/**
* Update resupply system - replenishes resources at 0.1 per second when in landing zone
*/
private updateResupply(): void {
if (!this._landingAggregate || !this._ship?.physicsBody) {
return;
}
// Check if ship mesh intersects with landing zone mesh
const wasInZone = this._isInLandingZone;
// Get the meshes from the transform nodes
const shipMesh = this._ship.getChildMeshes()[0];
const landingMesh = this._landingAggregate.transformNode as Mesh;
// Use mesh intersection for accurate zone detection
if (shipMesh && landingMesh) {
this._isInLandingZone = shipMesh.intersectsMesh(landingMesh, false);
} else {
// Fallback: if meshes not available, assume not in zone
this._isInLandingZone = false;
}
// Log zone transitions
if (this._isInLandingZone && !wasInZone) {
log.debug("Ship entered landing zone - resupply active");
} else if (!this._isInLandingZone && wasInZone) {
log.debug("Ship exited landing zone - resupply inactive");
}
// Resupply at 0.1 per second if in zone
if (this._isInLandingZone && this._scoreboard?.shipStatus) {
// Physics update runs every 10 frames at 60fps = 6 times per second
// 0.1 per second / 6 updates per second = 0.01666... per update
const resupplyRate = 1 / 600;
const status = this._scoreboard.shipStatus;
// Replenish fuel
if (status.fuel < 1.0) {
status.addFuel(resupplyRate);
}
// Repair hull
if (status.hull < 1.0) {
status.repairHull(resupplyRate);
}
// Replenish ammo
if (status.ammo < 1.0) {
status.addAmmo(resupplyRate);
}
}
}
/**
* Handle shooting from any input source
*/
private handleShoot(): void {
// If controls are disabled, fire mission brief trigger observable instead of shooting
const inputManager = InputControlManager.getInstance();
if (!inputManager.shipControlsEnabled) {
log.debug('[Ship] Controls disabled - firing mission brief trigger observable');
this._onMissionBriefTriggerObservable.notifyObservers();
return;
}
if (this._audio) {
this._audio.playWeaponSound();
}
if (this._weapons && this._ship && this._ship.physicsBody) {
// Clone world matrix to ensure consistent calculations
const worldMatrix = this._ship.getWorldMatrix().clone();
// Get ship velocities
const linearVelocity = this._ship.physicsBody.getLinearVelocity().clone();
const angularVelocity = this._ship.physicsBody.getAngularVelocity();
// Spawn offset in local space (must match weaponSystem.ts)
const localSpawnOffset = new Vector3(0, 0.5, 9.4);
// Transform spawn offset to world space
const worldSpawnOffset = Vector3.TransformCoordinates(localSpawnOffset, worldMatrix);
// Calculate tangential velocity at spawn point: ω × r
//const tangentialVelocity = angularVelocity.cross(worldSpawnOffset);
// Velocity at spawn point = ship velocity + tangential from rotation
//const velocityAtSpawn = linearVelocity.add(tangentialVelocity);
// Get forward direction using world matrix (same method as thrust)
const localForward = new Vector3(0, 0, 1);
const worldForward = Vector3.TransformNormal(localForward, worldMatrix);
log.debug(worldForward);
// Final projectile velocity: muzzle velocity in forward direction + ship velocity
const projectileVelocity = worldForward.scale(1000).add(linearVelocity);
log.debug(`Velocity - ${projectileVelocity}`);
this._weapons.fire(worldSpawnOffset, projectileVelocity);
}
}
public get transformNode() {
return this._ship;
}
/**
* Set the landing zone for resupply
*/
public setLandingZone(landingAggregate: PhysicsAggregate): void {
this._landingAggregate = landingAggregate;
// Listen for trigger events for debugging (actual detection uses mesh intersection)
landingAggregate.body.getCollisionObservable().add((collisionEvent) => {
// Check if the collision is with our ship
if (collisionEvent.collider === this._ship.physicsBody) {
log.debug("Physics trigger fired for landing zone");
}
});
}
/**
* Add a VR controller to the input system
*/
public addController(controller: WebXRInputSource) {
log.debug(
"Ship.addController called for:",
controller.inputSource.handedness
);
if (this._controllerInput) {
this._controllerInput.addController(controller);
}
}
/**
* Dispose of ship resources
*/
public dispose(): void {
// Remove scene observers first to stop update loops
if (this._physicsObserver) {
DefaultScene.MainScene?.onAfterPhysicsObservable.remove(this._physicsObserver);
this._physicsObserver = null;
}
if (this._renderObserver) {
DefaultScene.MainScene?.onAfterRenderObservable.remove(this._renderObserver);
this._renderObserver = null;
}
if (this._sight) {
this._sight.dispose();
}
if (this._keyboardInput) {
this._keyboardInput.dispose();
}
if (this._controllerInput) {
this._controllerInput.dispose();
}
if (this._audio) {
this._audio.dispose();
}
if (this._weapons) {
this._weapons.dispose();
}
if (this._statusScreen) {
this._statusScreen.dispose();
}
}
}