Refactor weapon system to shape casting and parent status screen to ship
All checks were successful
Build / build (push) Successful in 1m47s

- Replace physics-based projectiles with shape cast collision detection
- Add ignoreBody to shape cast to prevent projectiles hitting ship mesh
- Parent StatusScreen to ship TransformNode instead of XR camera
- Add velocity check to victory condition (must be < 5 m/s)
- Adjust projectile spawn offset and velocity

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-11-30 16:03:27 -06:00
parent 5cdbf22e67
commit 5ce26c64ff
5 changed files with 220 additions and 126 deletions

View File

@ -39,6 +39,11 @@ export class RockFactory {
private static _explosionManager: ExplosionManager | null = null; private static _explosionManager: ExplosionManager | null = null;
private static _orbitCenter: PhysicsAggregate | null = null; private static _orbitCenter: PhysicsAggregate | null = null;
/** Public getter for explosion manager (used by WeaponSystem for shape-cast hits) */
public static get explosionManager(): ExplosionManager | null {
return this._explosionManager;
}
/** /**
* Initialize non-audio assets (meshes, explosion manager) * Initialize non-audio assets (meshes, explosion manager)
* Call this before audio engine is unlocked * Call this before audio engine is unlocked

11
src/ship/projectile.ts Normal file
View File

@ -0,0 +1,11 @@
import { InstancedMesh, Vector3 } from "@babylonjs/core";
/**
* Interface for tracking active projectiles in the shape-cast system
*/
export interface Projectile {
mesh: InstancedMesh;
velocity: Vector3;
lastPosition: Vector3;
lifetime: number;
}

View File

@ -274,6 +274,10 @@ export class Ship {
this._weapons.initialize(); this._weapons.initialize();
this._weapons.setShipStatus(this._scoreboard.shipStatus); this._weapons.setShipStatus(this._scoreboard.shipStatus);
this._weapons.setGameStats(this._gameStats); 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) // Initialize input systems (skip in replay mode)
if (!this._isReplayMode) { if (!this._isReplayMode) {
@ -359,6 +363,11 @@ export class Ship {
if (this._voiceAudio) { if (this._voiceAudio) {
this._voiceAudio.update(); 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) // Check game end conditions every 30 frames (~0.5 sec at 60fps)
if (renderFrameCount++ % 30 === 0) { if (renderFrameCount++ % 30 === 0) {
this.checkGameEndConditions(); this.checkGameEndConditions();
@ -441,6 +450,7 @@ export class Ship {
// Initialize status screen with callbacks // Initialize status screen with callbacks
this._statusScreen = new StatusScreen( this._statusScreen = new StatusScreen(
DefaultScene.MainScene, DefaultScene.MainScene,
this._ship,
this._gameStats, this._gameStats,
() => this.handleReplayRequest(), () => this.handleReplayRequest(),
() => this.handleExitVR(), () => this.handleExitVR(),
@ -557,7 +567,7 @@ export class Ship {
// Check condition 3: Victory (all asteroids destroyed, inside landing zone) // 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) // Must have had asteroids to destroy in the first place (prevents false victory on init)
if (asteroidsRemaining <= 0 && this._isInLandingZone && this._scoreboard.hasAsteroidsToDestroy) { if (asteroidsRemaining <= 0 && this._isInLandingZone && this._scoreboard.hasAsteroidsToDestroy && this._ship.physicsBody.getLinearVelocity().length() < 5) {
log.debug('Game end condition met: Victory (all asteroids destroyed)'); log.debug('Game end condition met: Victory (all asteroids destroyed)');
this._statusScreen.show(true, true, 'victory'); // Game ended, VICTORY! this._statusScreen.show(true, true, 'victory'); // Game ended, VICTORY!
// InputControlManager will handle disabling controls when status screen shows // InputControlManager will handle disabling controls when status screen shows
@ -660,7 +670,7 @@ export class Ship {
if (this._isInLandingZone && this._scoreboard?.shipStatus) { if (this._isInLandingZone && this._scoreboard?.shipStatus) {
// Physics update runs every 10 frames at 60fps = 6 times per second // Physics update runs every 10 frames at 60fps = 6 times per second
// 0.1 per second / 6 updates per second = 0.01666... per update // 0.1 per second / 6 updates per second = 0.01666... per update
const resupplyRate = 0.1 / 6; const resupplyRate = 1 / 600;
const status = this._scoreboard.shipStatus; const status = this._scoreboard.shipStatus;
@ -697,31 +707,34 @@ export class Ship {
} }
if (this._weapons && this._ship && this._ship.physicsBody) { if (this._weapons && this._ship && this._ship.physicsBody) {
// Clone world matrix to ensure consistent calculations
const worldMatrix = this._ship.getWorldMatrix().clone();
// Get ship velocities // Get ship velocities
const linearVelocity = this._ship.physicsBody.getLinearVelocity(); const linearVelocity = this._ship.physicsBody.getLinearVelocity().clone();
const angularVelocity = this._ship.physicsBody.getAngularVelocity(); const angularVelocity = this._ship.physicsBody.getAngularVelocity();
// Spawn offset in local space (must match weaponSystem.ts) // Spawn offset in local space (must match weaponSystem.ts)
const localSpawnOffset = new Vector3(0, 0.5, 8.4); const localSpawnOffset = new Vector3(0, 0.5, 9.4);
// Transform spawn offset to world space (direction only) // Transform spawn offset to world space
const worldSpawnOffset = Vector3.TransformNormal( const worldSpawnOffset = Vector3.TransformCoordinates(localSpawnOffset, worldMatrix);
localSpawnOffset,
this._ship.getWorldMatrix()
);
// Calculate tangential velocity at spawn point: ω × r // Calculate tangential velocity at spawn point: ω × r
const tangentialVelocity = angularVelocity.cross(worldSpawnOffset); //const tangentialVelocity = angularVelocity.cross(worldSpawnOffset);
// Velocity at spawn point = linear + tangential // Velocity at spawn point = ship velocity + tangential from rotation
const velocityAtSpawn = linearVelocity.add(tangentialVelocity); //const velocityAtSpawn = linearVelocity.add(tangentialVelocity);
// Final projectile velocity: forward direction + spawn point velocity // Get forward direction using world matrix (same method as thrust)
const projectileVelocity = this._ship.forward const localForward = new Vector3(0, 0, 1);
.scale(200000) const worldForward = Vector3.TransformNormal(localForward, worldMatrix);
.add(velocityAtSpawn); 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(this._ship, projectileVelocity); this._weapons.fire(worldSpawnOffset, projectileVelocity);
} }
} }

View File

@ -1,23 +1,28 @@
import { import {
AbstractMesh, AbstractMesh,
Color3, Color3,
HavokPlugin,
InstancedMesh, InstancedMesh,
Mesh, Mesh,
MeshBuilder, MeshBuilder,
PhysicsAggregate, Observable,
PhysicsMotionType, PhysicsBody,
PhysicsShapeType, PhysicsShapeSphere,
Quaternion,
Scene, Scene,
ShapeCastResult,
StandardMaterial, StandardMaterial,
TransformNode,
Vector3, Vector3,
} from "@babylonjs/core"; } from "@babylonjs/core";
import { GameConfig } from "../core/gameConfig"; import { GameConfig } from "../core/gameConfig";
import { ShipStatus } from "./shipStatus"; import { ShipStatus } from "./shipStatus";
import { GameStats } from "../game/gameStats"; import { GameStats } from "../game/gameStats";
import { Projectile } from "./projectile";
import { RockFactory } from "../environment/asteroids/rockFactory";
import log from "../core/logger";
/** /**
* Handles weapon firing and projectile lifecycle * Handles weapon firing and projectile lifecycle using shape casting
*/ */
export class WeaponSystem { export class WeaponSystem {
private _ammoBaseMesh: AbstractMesh; private _ammoBaseMesh: AbstractMesh;
@ -26,145 +31,207 @@ export class WeaponSystem {
private _shipStatus: ShipStatus | null = null; private _shipStatus: ShipStatus | null = null;
private _gameStats: GameStats | null = null; private _gameStats: GameStats | null = null;
// Shape casting properties
private _activeProjectiles: Projectile[] = [];
private _bulletCastShape: PhysicsShapeSphere;
private _localCastResult: ShapeCastResult;
private _worldCastResult: ShapeCastResult;
private _havokPlugin: HavokPlugin | null = null;
// Observable for score updates when asteroids are destroyed
private _scoreObservable: Observable<{score: number, remaining: number, message: string}> | null = null;
// Ship body to ignore in shape casts
private _shipBody: PhysicsBody | null = null;
constructor(scene: Scene) { constructor(scene: Scene) {
this._scene = scene; this._scene = scene;
} }
/**
* Set the ship status instance for ammo tracking
*/
public setShipStatus(shipStatus: ShipStatus): void { public setShipStatus(shipStatus: ShipStatus): void {
this._shipStatus = shipStatus; this._shipStatus = shipStatus;
} }
/**
* Set the game stats instance for tracking shots fired
*/
public setGameStats(gameStats: GameStats): void { public setGameStats(gameStats: GameStats): void {
this._gameStats = gameStats; this._gameStats = gameStats;
} }
/** public setScoreObservable(observable: Observable<{score: number, remaining: number, message: string}>): void {
* Initialize weapon system (create ammo template) this._scoreObservable = observable;
*/ }
public setShipBody(body: PhysicsBody): void {
this._shipBody = body;
}
public initialize(): void { public initialize(): void {
this._ammoMaterial = new StandardMaterial("ammoMaterial", this._scene); this._ammoMaterial = new StandardMaterial("ammoMaterial", this._scene);
this._ammoMaterial.emissiveColor = new Color3(1, 1, 0); this._ammoMaterial.emissiveColor = new Color3(1, 1, 0);
this._ammoBaseMesh = MeshBuilder.CreateIcoSphere( this._ammoBaseMesh = MeshBuilder.CreateIcoSphere(
"bullet", "bullet",
{ radius: 0.1, subdivisions: 2 }, { radius: 0.5, subdivisions: 2 },
this._scene this._scene
); );
this._ammoBaseMesh.material = this._ammoMaterial; this._ammoBaseMesh.material = this._ammoMaterial;
this._ammoBaseMesh.setEnabled(false); this._ammoBaseMesh.setEnabled(false);
// Create reusable shape for casting (matches bullet radius)
this._bulletCastShape = new PhysicsShapeSphere(
Vector3.Zero(),
0.1,
this._scene
);
// Reusable result objects (avoid allocations)
this._localCastResult = new ShapeCastResult();
this._worldCastResult = new ShapeCastResult();
// Get Havok plugin reference
const engine = this._scene.getPhysicsEngine();
if (engine) {
this._havokPlugin = engine.getPhysicsPlugin() as HavokPlugin;
}
} }
/** /**
* Fire a projectile from the ship * Fire a projectile from the ship (no physics body - uses shape casting)
* @param shipTransform - Ship transform node for position/orientation
* @param velocityVector - Complete velocity vector for the projectile (ship forward + ship velocity)
*/ */
public fire( public fire(position: Vector3, velocityVector: Vector3): void {
shipTransform: TransformNode,
velocityVector: Vector3
): void {
// Only allow shooting if physics is enabled
const config = GameConfig.getInstance(); const config = GameConfig.getInstance();
if (!config.physicsEnabled) { if (!config.physicsEnabled) return;
return;
}
// Check if we have ammo before firing if (this._shipStatus && this._shipStatus.ammo <= 0) return;
if (this._shipStatus && this._shipStatus.ammo <= 0) {
return;
}
// Create projectile instance // Create visual-only projectile (no physics body)
const ammo = new InstancedMesh("ammo", this._ammoBaseMesh as Mesh); const ammo = new InstancedMesh("ammo", this._ammoBaseMesh as Mesh);
ammo.parent = shipTransform; ammo.position = position.clone();
ammo.position.y = 0.5;
ammo.position.z = 8.4;
// Detach from parent to move independently // Track projectile for update loop
ammo.setParent(null); this._activeProjectiles.push({
mesh: ammo,
velocity: velocityVector.clone(),
lastPosition: position.clone(),
lifetime: 0
});
// Create physics for projectile
const ammoAggregate = new PhysicsAggregate(
ammo,
PhysicsShapeType.SPHERE,
{
mass: 1000,
restitution: 0,
},
this._scene
);
ammoAggregate.body.setAngularDamping(1);
ammoAggregate.body.setMotionType(PhysicsMotionType.DYNAMIC);
ammoAggregate.body.setCollisionCallbackEnabled(true);
// Set projectile velocity (already includes ship velocity)
// Clone to capture current direction - prevents curving if source vector updates
ammoAggregate.body.setLinearVelocity(velocityVector.clone());
// Consume ammo
if (this._shipStatus) { if (this._shipStatus) {
this._shipStatus.consumeAmmo(0.01); this._shipStatus.consumeAmmo(0.01);
} }
// Track shot fired
if (this._gameStats) { if (this._gameStats) {
this._gameStats.recordShotFired(); this._gameStats.recordShotFired();
} }
// Track hits via collision detection
let hitRecorded = false; // Prevent multiple hits from same projectile
const gameStats = this._gameStats; // Capture in closure
const collisionObserver = ammoAggregate.body.getCollisionObservable().add((collisionEvent) => {
// Check if projectile hit something (not ship, not another projectile)
// Asteroids/rocks are the targets
if (!hitRecorded && gameStats && collisionEvent.collidedAgainst) {
// Record as hit - assumes collision with asteroid
gameStats.recordShotHit();
hitRecorded = true;
// Remove collision observer after first hit
if (collisionObserver && ammoAggregate.body) {
try {
ammoAggregate.body.getCollisionObservable().remove(collisionObserver);
} catch (_e) {
// Body may have been disposed during collision handling, ignore
}
}
}
});
// Auto-dispose after 2 seconds
window.setTimeout(() => {
// Clean up collision observer if body still exists
if (collisionObserver && ammoAggregate.body) {
try {
ammoAggregate.body.getCollisionObservable().remove(collisionObserver);
} catch (_e) {
// Body may have already been disposed, ignore error
}
}
// Dispose if not already disposed
try {
ammoAggregate.dispose();
ammo.dispose();
} catch (_e) {
// Already disposed, ignore
}
}, 2000);
} }
/** /**
* Cleanup weapon system resources * Update all active projectiles - call each frame
*/ */
public update(deltaTime: number): void {
if (!this._havokPlugin) return;
const toRemove: number[] = [];
for (let i = 0; i < this._activeProjectiles.length; i++) {
const proj = this._activeProjectiles[i];
proj.lifetime += deltaTime;
// Remove if exceeded lifetime (2 seconds)
if (proj.lifetime > 2) {
toRemove.push(i);
continue;
}
// Calculate next position
const currentPos = proj.mesh.position.clone();
const nextPos = currentPos.add(proj.velocity.scale(deltaTime));
// Shape cast from current to next position (ignore ship body)
this._havokPlugin.shapeCast({
shape: this._bulletCastShape,
rotation: Quaternion.Identity(),
startPosition: currentPos,
endPosition: nextPos,
shouldHitTriggers: false,
ignoreBody: this._shipBody ?? undefined
}, this._localCastResult, this._worldCastResult);
if (this._worldCastResult.hasHit) {
// Calculate exact hit point
const hitPoint = Vector3.Lerp(
currentPos,
nextPos,
this._worldCastResult.hitFraction
);
this._onProjectileHit(proj, hitPoint, this._worldCastResult);
toRemove.push(i);
} else {
// No hit - update position
proj.lastPosition.copyFrom(currentPos);
proj.mesh.position.copyFrom(nextPos);
}
}
// Remove hit/expired projectiles (reverse order to preserve indices)
for (let i = toRemove.length - 1; i >= 0; i--) {
const proj = this._activeProjectiles[toRemove[i]];
proj.mesh.dispose();
this._activeProjectiles.splice(toRemove[i], 1);
}
}
private _onProjectileHit(
proj: Projectile,
hitPoint: Vector3,
result: ShapeCastResult
): void {
if (this._gameStats) {
this._gameStats.recordShotHit();
}
if (!result.body) return;
const hitMesh = result.body.transformNode as AbstractMesh;
const isAsteroid = hitMesh?.name?.startsWith("asteroid-");
if (isAsteroid) {
log.debug('[WeaponSystem] Asteroid hit! Triggering destruction...');
// Update score
if (this._scoreObservable) {
this._scoreObservable.notifyObservers({
score: 1,
remaining: -1,
message: "Asteroid Destroyed"
});
}
// Dispose asteroid physics before explosion
if (result.shape) {
result.shape.dispose();
}
result.body.dispose();
// Play explosion effect
if (RockFactory.explosionManager) {
RockFactory.explosionManager.playExplosion(hitMesh);
}
} else {
// Non-asteroid hit - just apply impulse
const impulse = proj.velocity.normalize().scale(50000);
result.body.applyImpulse(impulse, hitPoint);
}
}
public dispose(): void { public dispose(): void {
// Dispose all active projectiles
for (const proj of this._activeProjectiles) {
proj.mesh.dispose();
}
this._activeProjectiles = [];
this._bulletCastShape?.dispose();
this._ammoBaseMesh?.dispose(); this._ammoBaseMesh?.dispose();
this._ammoMaterial?.dispose(); this._ammoMaterial?.dispose();
} }

View File

@ -7,11 +7,11 @@ import {
TextBlock TextBlock
} from "@babylonjs/gui"; } from "@babylonjs/gui";
import { import {
Camera,
Mesh, Mesh,
MeshBuilder, MeshBuilder,
Scene, Scene,
StandardMaterial, StandardMaterial,
TransformNode,
Vector3 Vector3
} from "@babylonjs/core"; } from "@babylonjs/core";
import { GameStats } from "../../game/gameStats"; import { GameStats } from "../../game/gameStats";
@ -34,7 +34,7 @@ export class StatusScreen {
private _screenMesh: Mesh | null = null; private _screenMesh: Mesh | null = null;
private _texture: AdvancedDynamicTexture | null = null; private _texture: AdvancedDynamicTexture | null = null;
private _isVisible: boolean = false; private _isVisible: boolean = false;
private _camera: Camera | null = null; private _shipNode: TransformNode;
private _parTime: number = 120; // Default par time in seconds private _parTime: number = 120; // Default par time in seconds
// Text blocks for statistics // Text blocks for statistics
@ -74,8 +74,9 @@ export class StatusScreen {
// Track if result has been recorded (prevent duplicates) // Track if result has been recorded (prevent duplicates)
private _resultRecorded: boolean = false; private _resultRecorded: boolean = false;
constructor(scene: Scene, gameStats: GameStats, onReplay?: () => void, onExit?: () => void, onResume?: () => void, onNextLevel?: () => void) { constructor(scene: Scene, shipNode: TransformNode, gameStats: GameStats, onReplay?: () => void, onExit?: () => void, onResume?: () => void, onNextLevel?: () => void) {
this._scene = scene; this._scene = scene;
this._shipNode = shipNode;
this._gameStats = gameStats; this._gameStats = gameStats;
this._onReplayCallback = onReplay || null; this._onReplayCallback = onReplay || null;
this._onExitCallback = onExit || null; this._onExitCallback = onExit || null;
@ -87,8 +88,6 @@ export class StatusScreen {
* Initialize the status screen mesh and UI * Initialize the status screen mesh and UI
*/ */
public initialize(): void { public initialize(): void {
this._camera = DefaultScene.XR.baseExperience.camera;
// Create a plane mesh for the status screen // Create a plane mesh for the status screen
this._screenMesh = MeshBuilder.CreatePlane( this._screenMesh = MeshBuilder.CreatePlane(
"statusScreen", "statusScreen",
@ -96,10 +95,9 @@ export class StatusScreen {
this._scene this._scene
); );
// Parent to camera for automatic following // Parent to ship for fixed cockpit position
this._screenMesh.parent = this._camera; this._screenMesh.parent = this._shipNode;
this._screenMesh.position = new Vector3(0, 0, 2); // 2 meters forward in local space this._screenMesh.position = new Vector3(0, 1, 2); // 2 meters forward in local space
//this._screenMesh.rotation.y = Math.PI; // Face backward (toward user)
this._screenMesh.renderingGroupId = 3; // Always render on top this._screenMesh.renderingGroupId = 3; // Always render on top
this._screenMesh.metadata = { uiPickable: true }; // TAG: VR UI - allow pointer selection this._screenMesh.metadata = { uiPickable: true }; // TAG: VR UI - allow pointer selection