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 _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)
* 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.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) {
@ -359,6 +363,11 @@ export class Ship {
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();
@ -441,6 +450,7 @@ export class Ship {
// Initialize status screen with callbacks
this._statusScreen = new StatusScreen(
DefaultScene.MainScene,
this._ship,
this._gameStats,
() => this.handleReplayRequest(),
() => this.handleExitVR(),
@ -557,7 +567,7 @@ export class Ship {
// 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) {
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
@ -660,7 +670,7 @@ export class Ship {
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 resupplyRate = 1 / 600;
const status = this._scoreboard.shipStatus;
@ -697,31 +707,34 @@ export class Ship {
}
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();
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, 8.4);
const localSpawnOffset = new Vector3(0, 0.5, 9.4);
// Transform spawn offset to world space (direction only)
const worldSpawnOffset = Vector3.TransformNormal(
localSpawnOffset,
this._ship.getWorldMatrix()
);
// Transform spawn offset to world space
const worldSpawnOffset = Vector3.TransformCoordinates(localSpawnOffset, worldMatrix);
// Calculate tangential velocity at spawn point: ω × r
const tangentialVelocity = angularVelocity.cross(worldSpawnOffset);
//const tangentialVelocity = angularVelocity.cross(worldSpawnOffset);
// Velocity at spawn point = linear + tangential
const velocityAtSpawn = linearVelocity.add(tangentialVelocity);
// Velocity at spawn point = ship velocity + tangential from rotation
//const velocityAtSpawn = linearVelocity.add(tangentialVelocity);
// Final projectile velocity: forward direction + spawn point velocity
const projectileVelocity = this._ship.forward
.scale(200000)
.add(velocityAtSpawn);
// 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(this._ship, projectileVelocity);
this._weapons.fire(worldSpawnOffset, projectileVelocity);
}
}

View File

@ -1,23 +1,28 @@
import {
AbstractMesh,
Color3,
HavokPlugin,
InstancedMesh,
Mesh,
MeshBuilder,
PhysicsAggregate,
PhysicsMotionType,
PhysicsShapeType,
Observable,
PhysicsBody,
PhysicsShapeSphere,
Quaternion,
Scene,
ShapeCastResult,
StandardMaterial,
TransformNode,
Vector3,
} from "@babylonjs/core";
import { GameConfig } from "../core/gameConfig";
import { ShipStatus } from "./shipStatus";
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 {
private _ammoBaseMesh: AbstractMesh;
@ -26,145 +31,207 @@ export class WeaponSystem {
private _shipStatus: ShipStatus | 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) {
this._scene = scene;
}
/**
* Set the ship status instance for ammo tracking
*/
public setShipStatus(shipStatus: ShipStatus): void {
this._shipStatus = shipStatus;
}
/**
* Set the game stats instance for tracking shots fired
*/
public setGameStats(gameStats: GameStats): void {
this._gameStats = gameStats;
}
/**
* Initialize weapon system (create ammo template)
*/
public setScoreObservable(observable: Observable<{score: number, remaining: number, message: string}>): void {
this._scoreObservable = observable;
}
public setShipBody(body: PhysicsBody): void {
this._shipBody = body;
}
public initialize(): void {
this._ammoMaterial = new StandardMaterial("ammoMaterial", this._scene);
this._ammoMaterial.emissiveColor = new Color3(1, 1, 0);
this._ammoBaseMesh = MeshBuilder.CreateIcoSphere(
"bullet",
{ radius: 0.1, subdivisions: 2 },
{ radius: 0.5, subdivisions: 2 },
this._scene
);
this._ammoBaseMesh.material = this._ammoMaterial;
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
* @param shipTransform - Ship transform node for position/orientation
* @param velocityVector - Complete velocity vector for the projectile (ship forward + ship velocity)
* Fire a projectile from the ship (no physics body - uses shape casting)
*/
public fire(
shipTransform: TransformNode,
velocityVector: Vector3
): void {
// Only allow shooting if physics is enabled
public fire(position: Vector3, velocityVector: Vector3): void {
const config = GameConfig.getInstance();
if (!config.physicsEnabled) {
return;
}
if (!config.physicsEnabled) 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);
ammo.parent = shipTransform;
ammo.position.y = 0.5;
ammo.position.z = 8.4;
ammo.position = position.clone();
// Detach from parent to move independently
ammo.setParent(null);
// Track projectile for update loop
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) {
this._shipStatus.consumeAmmo(0.01);
}
// Track shot fired
if (this._gameStats) {
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 {
// Dispose all active projectiles
for (const proj of this._activeProjectiles) {
proj.mesh.dispose();
}
this._activeProjectiles = [];
this._bulletCastShape?.dispose();
this._ammoBaseMesh?.dispose();
this._ammoMaterial?.dispose();
}

View File

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