diff --git a/src/environment/asteroids/rockFactory.ts b/src/environment/asteroids/rockFactory.ts index cec2502..1cdba62 100644 --- a/src/environment/asteroids/rockFactory.ts +++ b/src/environment/asteroids/rockFactory.ts @@ -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 diff --git a/src/ship/projectile.ts b/src/ship/projectile.ts new file mode 100644 index 0000000..ecbd149 --- /dev/null +++ b/src/ship/projectile.ts @@ -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; +} diff --git a/src/ship/ship.ts b/src/ship/ship.ts index 117c3a7..562ffa3 100644 --- a/src/ship/ship.ts +++ b/src/ship/ship.ts @@ -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); } } diff --git a/src/ship/weaponSystem.ts b/src/ship/weaponSystem.ts index cc6855a..d000ee4 100644 --- a/src/ship/weaponSystem.ts +++ b/src/ship/weaponSystem.ts @@ -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(); } diff --git a/src/ui/hud/statusScreen.ts b/src/ui/hud/statusScreen.ts index aab3340..434fc91 100644 --- a/src/ui/hud/statusScreen.ts +++ b/src/ui/hud/statusScreen.ts @@ -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