space-game/src/ship.ts
Michael Mainguy faa5afc604
Some checks failed
Build / build (push) Failing after 24s
Add flat camera mode support and fix WebXR user activation
WebXR-optional gameplay:
- Removed WebXR requirement check, game now works without VR
- Made WebXR initialization optional with graceful fallback
- Flat camera mode automatically activates when XR unavailable
- Keyboard/mouse controls work in flat camera mode
- Camera following works in both XR and flat modes

Fixed WebXR user activation issue:
- Restructured initialization to enter XR immediately after button click
- Moved enterXRAsync() before asset loading to maintain user gesture
- Level1.play() now detects if XR session already active (state === 4)
- Removed setTimeout delays that broke user activation chain
- Falls back to flat mode if XR entry fails at any point

Game initialization improvements:
- Game timer and physics recorder start in both XR and flat modes
- Level1 constructor only sets up XR observables if XR available
- Ship.initialize() activates flat camera when XR not present
- Background stars follow active camera (XR or flat)
- Ready observable calls play() immediately to maintain activation

User experience:
- Game starts immediately in available mode (VR or flat)
- Seamless fallback if VR headset disconnects or unavailable
- Desktop users can now play with keyboard/mouse
- No error messages blocking non-VR users

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 06:30:59 -06:00

427 lines
14 KiB
TypeScript

import {
AbstractMesh,
Color3,
FreeCamera,
Mesh,
PhysicsAggregate,
PhysicsMotionType,
PhysicsShapeType,
TransformNode,
Vector2,
Vector3,
WebXRInputSource,
} from "@babylonjs/core";
import type { AudioEngineV2 } from "@babylonjs/core";
import { DefaultScene } from "./defaultScene";
import { GameConfig } from "./gameConfig";
import { Sight } from "./sight";
import debugLog from "./debug";
import { Scoreboard } from "./scoreboard";
import loadAsset from "./utils/loadAsset";
import { Debug } from "@babylonjs/core/Legacy/legacy";
import { KeyboardInput } from "./keyboardInput";
import { ControllerInput } from "./controllerInput";
import { ShipPhysics } from "./shipPhysics";
import { ShipAudio } from "./shipAudio";
import { WeaponSystem } from "./weaponSystem";
import { StatusScreen } from "./statusScreen";
import { GameStats } from "./gameStats";
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 _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;
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 keyboardInput(): KeyboardInput {
return this._keyboardInput;
}
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 async initialize() {
this._scoreboard = new Scoreboard();
this._gameStats = new GameStats();
this._ship = new TransformNode("shipBase", DefaultScene.MainScene);
const data = await loadAsset("ship.glb");
this._ship = data.container.transformNodes[0];
this._ship.position.y = 5;
// Create physics if enabled
const config = GameConfig.getInstance();
if (config.physicsEnabled) {
console.log("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(0.2);
agg.body.setAngularDamping(0.4);
agg.body.setAngularVelocity(new Vector3(0, 0, 0));
agg.body.setCollisionCallbackEnabled(true);
// Register collision handler for hull damage
const observable = agg.body.getCollisionObservable();
observable.add(() => {
// Damage hull on any collision
if (this._scoreboard?.shipStatus) {
this._scoreboard.shipStatus.damageHull(0.01);
}
});
} else {
console.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 weapon system
this._weapons = new WeaponSystem(DefaultScene.MainScene);
this._weapons.initialize();
this._weapons.setShipStatus(this._scoreboard.shipStatus);
this._weapons.setGameStats(this._gameStats);
// Initialize input systems (skip in replay mode)
if (!this._isReplayMode) {
this._keyboardInput = new KeyboardInput(DefaultScene.MainScene);
this._keyboardInput.setup();
this._controllerInput = new 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) {
this._statusScreen.toggle();
}
});
// 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)
DefaultScene.MainScene.onAfterRenderObservable.add(() => {
this._frameCount++;
if (this._frameCount >= 10) {
this._frameCount = 0;
this.updatePhysics();
}
});
// Setup camera
this._camera = new FreeCamera(
"Flat Camera",
new Vector3(0, 1.5, 0),
DefaultScene.MainScene
);
this._camera.parent = this._ship;
// 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);
debugLog('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(() => {
// Each score event represents an asteroid destroyed
this._gameStats.recordAsteroidDestroyed();
});
// 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)
this._gameStats.recordHullDamage(Math.abs(event.delta));
}
});
// Initialize status screen
this._statusScreen = new StatusScreen(DefaultScene.MainScene, this._gameStats);
this._statusScreen.initialize(this._camera);
}
/**
* Update physics based on combined input from all input sources
*/
private updatePhysics(): void {
if (!this._ship?.physicsBody) {
return;
}
// 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 (controller takes priority if active)
const combinedInput = {
leftStick:
controllerState.leftStick.length() > 0.1
? controllerState.leftStick
: keyboardState.leftStick,
rightStick:
controllerState.rightStick.length() > 0.1
? controllerState.rightStick
: 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 is still in the landing zone by checking distance
// Since it's a trigger, we need to track position
const shipPos = this._ship.physicsBody.transformNode.position;
const landingPos = this._landingAggregate.transformNode.position;
const distance = Vector3.Distance(shipPos, landingPos);
// Assume landing zone radius is approximately 20 units (adjust as needed)
const wasInZone = this._isInLandingZone;
this._isInLandingZone = distance < 20;
if (this._isInLandingZone && !wasInZone) {
debugLog("Ship entered landing zone - resupply active");
} else if (!this._isInLandingZone && wasInZone) {
debugLog("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 = 0.1 / 6;
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 (this._audio) {
this._audio.playWeaponSound();
}
if (this._weapons && this._ship && this._ship.physicsBody) {
// Calculate projectile velocity: ship forward + ship velocity
const shipVelocity = this._ship.physicsBody.getLinearVelocity();
const projectileVelocity = this._ship.forward
.scale(200000)
.add(shipVelocity);
this._weapons.fire(this._ship, 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 to detect when ship enters/exits landing zone
landingAggregate.body.getCollisionObservable().add((collisionEvent) => {
// Check if the collision is with our ship
if (collisionEvent.collider === this._ship.physicsBody) {
this._isInLandingZone = true;
debugLog("Ship entered landing zone - resupply active");
}
});
}
/**
* Add a VR controller to the input system
*/
public addController(controller: WebXRInputSource) {
debugLog(
"Ship.addController called for:",
controller.inputSource.handedness
);
if (this._controllerInput) {
this._controllerInput.addController(controller);
}
}
/**
* Dispose of ship resources
*/
public dispose(): void {
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();
}
}
}