diff --git a/src/controllerInput.ts b/src/controllerInput.ts new file mode 100644 index 0000000..328bb50 --- /dev/null +++ b/src/controllerInput.ts @@ -0,0 +1,241 @@ +import { + Observable, + Vector2, + WebXRAbstractMotionController, + WebXRControllerComponent, + WebXRInputSource, +} from "@babylonjs/core"; +import debugLog from "./debug"; + +const controllerComponents = [ + "a-button", + "b-button", + "x-button", + "y-button", + "thumbrest", + "xr-standard-squeeze", + "xr-standard-thumbstick", + "xr-standard-trigger", +]; + +type ControllerEvent = { + hand: "right" | "left" | "none"; + type: "thumbstick" | "button"; + controller: WebXRAbstractMotionController; + component: WebXRControllerComponent; + value: number; + axisData: { x: number; y: number }; + pressed: boolean; + touched: boolean; +}; + +interface CameraAdjustment { + direction: "up" | "down"; +} + +/** + * Handles VR controller input for ship control + * Maps controller thumbsticks and buttons to ship controls + */ +export class ControllerInput { + private _leftStick: Vector2 = Vector2.Zero(); + private _rightStick: Vector2 = Vector2.Zero(); + private _shooting: boolean = false; + private _leftInputSource: WebXRInputSource; + private _rightInputSource: WebXRInputSource; + private _controllerObservable: Observable = + new Observable(); + private _onShootObservable: Observable = new Observable(); + private _onCameraAdjustObservable: Observable = + new Observable(); + + constructor() { + this._controllerObservable.add(this.handleControllerEvent.bind(this)); + } + + /** + * Get observable that fires when trigger is pressed + */ + public get onShootObservable(): Observable { + return this._onShootObservable; + } + + /** + * Get observable that fires when camera adjustment buttons are pressed + */ + public get onCameraAdjustObservable(): Observable { + return this._onCameraAdjustObservable; + } + + /** + * Get current input state (stick positions) + */ + public getInputState() { + return { + leftStick: this._leftStick.clone(), + rightStick: this._rightStick.clone(), + }; + } + + /** + * Add a VR controller to the input system + */ + public addController(controller: WebXRInputSource): void { + debugLog( + "ControllerInput.addController called for:", + controller.inputSource.handedness + ); + + if (controller.inputSource.handedness === "left") { + debugLog("Adding left controller"); + this._leftInputSource = controller; + this._leftInputSource.onMotionControllerInitObservable.add( + (motionController) => { + debugLog( + "Left motion controller initialized:", + motionController.handness + ); + this.mapMotionController(motionController); + } + ); + + // Check if motion controller is already initialized + if (controller.motionController) { + debugLog("Left motion controller already initialized, mapping now"); + this.mapMotionController(controller.motionController); + } + } + + if (controller.inputSource.handedness === "right") { + debugLog("Adding right controller"); + this._rightInputSource = controller; + this._rightInputSource.onMotionControllerInitObservable.add( + (motionController) => { + debugLog( + "Right motion controller initialized:", + motionController.handness + ); + this.mapMotionController(motionController); + } + ); + + // Check if motion controller is already initialized + if (controller.motionController) { + debugLog("Right motion controller already initialized, mapping now"); + this.mapMotionController(controller.motionController); + } + } + } + + /** + * Map controller components to observables + */ + private mapMotionController( + controller: WebXRAbstractMotionController + ): void { + debugLog( + "Mapping motion controller:", + controller.handness, + "Profile:", + controller.profileId + ); + + controllerComponents.forEach((component) => { + const comp = controller.components[component]; + + if (!comp) { + debugLog( + ` Component ${component} not found on ${controller.handness} controller` + ); + return; + } + + debugLog( + ` Found component ${component} on ${controller.handness} controller` + ); + const observable = this._controllerObservable; + + if (comp && comp.onAxisValueChangedObservable) { + comp.onAxisValueChangedObservable.add((axisData) => { + observable.notifyObservers({ + controller: controller, + hand: controller.handness, + type: "thumbstick", + component: comp, + value: comp.value, + axisData: { x: axisData.x, y: axisData.y }, + pressed: comp.pressed, + touched: comp.touched, + }); + }); + } + + if (comp && comp.onButtonStateChangedObservable) { + comp.onButtonStateChangedObservable.add((component) => { + observable.notifyObservers({ + controller: controller, + hand: controller.handness, + type: "button", + component: comp, + value: component.value, + axisData: { x: component.axes.x, y: component.axes.y }, + pressed: component.pressed, + touched: component.touched, + }); + }); + } + }); + } + + /** + * Handle controller events (thumbsticks and buttons) + */ + private handleControllerEvent(controllerEvent: ControllerEvent): void { + if (controllerEvent.type === "thumbstick") { + if (controllerEvent.hand === "left") { + this._leftStick.x = controllerEvent.axisData.x; + this._leftStick.y = controllerEvent.axisData.y; + } + + if (controllerEvent.hand === "right") { + this._rightStick.x = controllerEvent.axisData.x; + this._rightStick.y = controllerEvent.axisData.y; + } + } + + if (controllerEvent.type === "button") { + if (controllerEvent.component.type === "trigger") { + if (controllerEvent.value > 0.9 && !this._shooting) { + this._shooting = true; + this._onShootObservable.notifyObservers(); + } + if (controllerEvent.value < 0.1) { + this._shooting = false; + } + } + + if (controllerEvent.component.type === "button") { + if (controllerEvent.component.id === "a-button") { + this._onCameraAdjustObservable.notifyObservers({ + direction: "down", + }); + } + if (controllerEvent.component.id === "b-button") { + this._onCameraAdjustObservable.notifyObservers({ + direction: "up", + }); + } + console.log(controllerEvent); + } + } + } + + /** + * Cleanup observables + */ + public dispose(): void { + this._controllerObservable.clear(); + this._onShootObservable.clear(); + this._onCameraAdjustObservable.clear(); + } +} diff --git a/src/createPlanets.ts b/src/createPlanets.ts index a5c6542..5e948d2 100644 --- a/src/createPlanets.ts +++ b/src/createPlanets.ts @@ -63,6 +63,8 @@ export function createPlanets( const texture = new Texture(getRandomPlanetTexture(), DefaultScene.MainScene); material.diffuseTexture = texture; material.ambientTexture = texture; + material.emissiveTexture = texture; + planets.push(planet); } diff --git a/src/keyboardInput.ts b/src/keyboardInput.ts new file mode 100644 index 0000000..cab67d1 --- /dev/null +++ b/src/keyboardInput.ts @@ -0,0 +1,143 @@ +import { FreeCamera, Observable, Scene, Vector2 } from "@babylonjs/core"; + +/** + * Handles keyboard and mouse input for ship control + * Combines both input methods into a unified interface + */ +export class KeyboardInput { + private _leftStick: Vector2 = Vector2.Zero(); + private _rightStick: Vector2 = Vector2.Zero(); + private _mouseDown: boolean = false; + private _mousePos: Vector2 = new Vector2(0, 0); + private _onShootObservable: Observable = new Observable(); + private _onCameraChangeObservable: Observable = new Observable(); + private _scene: Scene; + + constructor(scene: Scene) { + this._scene = scene; + } + + /** + * Get observable that fires when shoot key/button is pressed + */ + public get onShootObservable(): Observable { + return this._onShootObservable; + } + + /** + * Get observable that fires when camera change key is pressed + */ + public get onCameraChangeObservable(): Observable { + return this._onCameraChangeObservable; + } + + /** + * Get current input state (stick positions) + */ + public getInputState() { + return { + leftStick: this._leftStick.clone(), + rightStick: this._rightStick.clone(), + }; + } + + /** + * Setup keyboard and mouse event listeners + */ + public setup(): void { + this.setupKeyboard(); + this.setupMouse(); + } + + /** + * Setup keyboard event listeners + */ + private setupKeyboard(): void { + document.onkeyup = () => { + this._leftStick.y = 0; + this._leftStick.x = 0; + this._rightStick.y = 0; + this._rightStick.x = 0; + }; + + document.onkeydown = (ev) => { + switch (ev.key) { + case '1': + this._onCameraChangeObservable.notifyObservers(1); + break; + case ' ': + this._onShootObservable.notifyObservers(); + break; + case 'e': + break; + case 'w': + this._leftStick.y = -1; + break; + case 's': + this._leftStick.y = 1; + break; + case 'a': + this._leftStick.x = -1; + break; + case 'd': + this._leftStick.x = 1; + break; + case 'ArrowUp': + this._rightStick.y = -1; + break; + case 'ArrowDown': + this._rightStick.y = 1; + break; + } + }; + } + + /** + * Setup mouse event listeners for drag-based rotation control + */ + private setupMouse(): void { + this._scene.onPointerDown = (evt) => { + this._mousePos.x = evt.x; + this._mousePos.y = evt.y; + this._mouseDown = true; + }; + + this._scene.onPointerUp = () => { + this._mouseDown = false; + }; + + this._scene.onPointerMove = (ev) => { + if (!this._mouseDown) { + return; + } + + const xInc = (ev.x - this._mousePos.x) / 100; + const yInc = (ev.y - this._mousePos.y) / 100; + + if (Math.abs(xInc) <= 1) { + this._rightStick.x = xInc; + } else { + this._rightStick.x = Math.sign(xInc); + } + + if (Math.abs(yInc) <= 1) { + this._rightStick.y = yInc; + } else { + this._rightStick.y = Math.sign(yInc); + } + }; + } + + /** + * Cleanup event listeners + */ + public dispose(): void { + document.onkeydown = null; + document.onkeyup = null; + this._scene.onPointerDown = null; + this._scene.onPointerUp = null; + this._scene.onPointerMove = null; + this._onShootObservable.clear(); + this._onCameraChangeObservable.clear(); + } +} diff --git a/src/levelDeserializer.ts b/src/levelDeserializer.ts index 0ac5084..8cf66da 100644 --- a/src/levelDeserializer.ts +++ b/src/levelDeserializer.ts @@ -1,5 +1,5 @@ import { - AbstractMesh, + AbstractMesh, Color3, MeshBuilder, Observable, PBRMaterial, @@ -91,7 +91,10 @@ export class LevelDeserializer { // Create PBR sun material with fire texture const material = new PBRMaterial("sunMaterial", this.scene); material.emissiveTexture = new FireProceduralTexture("fire", 1024, this.scene); - material.emissiveColor.set(0.5, 0.5, 0.1); + material.albedoColor = Color3.Black(); + material.emissiveColor = Color3.White(); + material.disableLighting = true; + //material.emissiveColor.set(0.5, 0.5, 0.1); material.unlit = true; sun.material = material; @@ -140,7 +143,7 @@ export class LevelDeserializer { material.useLightmapAsShadowmap = true; material.roughness = 0.8; material.metallic = 0; - + material.unlit = true; planet.material = material; planets.push(planet); diff --git a/src/ship.ts b/src/ship.ts index 2e56bb3..1a66b00 100644 --- a/src/ship.ts +++ b/src/ship.ts @@ -2,126 +2,53 @@ import { AbstractMesh, Color3, FreeCamera, - InstancedMesh, Mesh, - MeshBuilder, - Observable, + Mesh, PhysicsAggregate, PhysicsMotionType, PhysicsShapeType, - StandardMaterial, TransformNode, Vector2, Vector3, - WebXRAbstractMotionController, - WebXRControllerComponent, - WebXRInputSource + WebXRInputSource, } from "@babylonjs/core"; -import type {AudioEngineV2, StaticSound} from "@babylonjs/core"; -import {DefaultScene} from "./defaultScene"; +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 debugLog from "./debug"; +import { Scoreboard } from "./scoreboard"; import loadAsset from "./utils/loadAsset"; -import {Debug} from "@babylonjs/core/Legacy/legacy"; -const MAX_LINEAR_VELOCITY = 200; -const MAX_ANGULAR_VELOCITY = 1.4; -const LINEAR_FORCE_MULTIPLIER = 800; -const ANGULAR_FORCE_MULTIPLIER = 15; - -const controllerComponents = [ - 'a-button', - 'b-button', - 'x-button', - 'y-button', - 'thumbrest', - 'xr-standard-squeeze', - 'xr-standard-thumbstick', - 'xr-standard-trigger', -] -type ControllerEvent = { - hand: 'right' | 'left' | 'none', - type: 'thumbstick' | 'button', - controller: WebXRAbstractMotionController, - component: WebXRControllerComponent, - value: number, - axisData: { x: number, y: number }, - pressed: boolean, - touched: boolean - -} - +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"; export class Ship { private _ship: TransformNode; private _scoreboard: Scoreboard; - private _controllerObservable: Observable = new Observable(); - private _ammoMaterial: StandardMaterial; - private _primaryThrustVectorSound: StaticSound; - private _secondaryThrustVectorSound: StaticSound; - private _shot: StaticSound; - private _primaryThrustPlaying: boolean = false; - private _secondaryThrustPlaying: boolean = false; - private _shooting: boolean = false; private _camera: FreeCamera; - private _ammoBaseMesh: AbstractMesh; private _audioEngine: AudioEngineV2; private _sight: Sight; - constructor( audioEngine?: AudioEngineV2) { + // New modular systems + private _keyboardInput: KeyboardInput; + private _controllerInput: ControllerInput; + private _physics: ShipPhysics; + private _audio: ShipAudio; + private _weapons: WeaponSystem; + + // Frame counter for physics updates + private _frameCount: number = 0; + + constructor(audioEngine?: AudioEngineV2) { this._audioEngine = audioEngine; } - private async initializeSounds() { - if (!this._audioEngine) return; - - this._primaryThrustVectorSound = await this._audioEngine.createSoundAsync("thrust", "/thrust5.mp3", { - loop: true, - volume: .2 - }); - this._secondaryThrustVectorSound = await this._audioEngine.createSoundAsync("thrust2", "/thrust5.mp3", { - loop: true, - volume: 0.5 - }); - this._shot = await this._audioEngine.createSoundAsync("shot", "/shot.mp3", { - loop: false, - volume: 0.5 - }); - } public get scoreboard(): Scoreboard { return this._scoreboard; } - private shoot() { - // Only allow shooting if physics is enabled - const config = GameConfig.getInstance(); - if (!config.physicsEnabled) { - return; - } - - this._shot?.play(); - const ammo = new InstancedMesh("ammo", this._ammoBaseMesh as Mesh); - ammo.parent = this._ship; - ammo.position.y = .1; - ammo.position.z = 8.4; - //ammo.rotation.x = Math.PI / 2; - ammo.setParent(null); - const ammoAggregate = new PhysicsAggregate(ammo, PhysicsShapeType.SPHERE, { - mass: 1000, - restitution: 0 - }, DefaultScene.MainScene); - ammoAggregate.body.setAngularDamping(1); - - - ammoAggregate.body.setMotionType(PhysicsMotionType.DYNAMIC); - - ammoAggregate.body.setLinearVelocity(this._ship.forward.scale(200000)) - //.add(this._ship.physicsBody.getLinearVelocity())); - - window.setTimeout(() => { - ammoAggregate.dispose(); - ammo.dispose() - }, 2000); - } public set position(newPosition: Vector3) { const body = this._ship.physicsBody; @@ -137,389 +64,213 @@ export class Ship { body.transformNode.position.copyFrom(newPosition); DefaultScene.MainScene.onAfterRenderObservable.addOnce(() => { body.disablePreStep = true; - }) - + }); } public async initialize() { this._scoreboard = new Scoreboard(); - this._ship = new TransformNode("shipBawe", DefaultScene.MainScene); - //this._ship.rotation.y = Math.PI; - const data = await loadAsset('ship.glb'); - const axes = new Debug.AxesViewer(DefaultScene.MainScene, 1); - //axes.xAxis.parent = data.container.rootNodes[0]; - //axes.yAxis.parent = data.container.rootNodes[0]; - axes.zAxis.parent = data.container.transformNodes[0]; - //data.container.transformNodes[0].parent = this._ship; + 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'); + 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 + mesh: data.container.rootNodes[0].getChildMeshes()[0] as Mesh, }, DefaultScene.MainScene ); agg.body.setMotionType(PhysicsMotionType.DYNAMIC); - agg.body.setLinearDamping(.2); - agg.body.setAngularDamping(.4); + agg.body.setLinearDamping(0.2); + agg.body.setAngularDamping(0.4); agg.body.setAngularVelocity(new Vector3(0, 0, 0)); agg.body.setCollisionCallbackEnabled(true); - } else { console.warn("No geometry mesh found, cannot create physics"); } } - //shipMesh.position.z = -1; + // Initialize audio system if (this._audioEngine) { - await this.initializeSounds(); + this._audio = new ShipAudio(this._audioEngine); + await this._audio.initialize(); } - this._ammoMaterial = new StandardMaterial("ammoMaterial", DefaultScene.MainScene); - this._ammoMaterial.emissiveColor = new Color3(1, 1, 0); - this._ammoBaseMesh = MeshBuilder.CreateIcoSphere("bullet", {radius: .1, subdivisions: 2}, DefaultScene.MainScene); - this._ammoBaseMesh.material = this._ammoMaterial; - this._ammoBaseMesh.setEnabled(false); + // Initialize weapon system + this._weapons = new WeaponSystem(DefaultScene.MainScene); + this._weapons.initialize(); + // Initialize input systems + this._keyboardInput = new KeyboardInput(DefaultScene.MainScene); + this._keyboardInput.setup(); - //const landingLight = new SpotLight("landingLight", new Vector3(0, 0, 0), new Vector3(0, -.5, .5), 1.5, .5, DefaultScene.MainScene); - // landingLight.parent = this._ship; - // landingLight.position.z = 5; + this._controllerInput = new ControllerInput(); - // Physics will be set up after mesh loads in initialize() + // Wire up shooting events + this._keyboardInput.onShootObservable.add(() => { + this.handleShoot(); + }); - this.setupKeyboard(); - this.setupMouse(); - this._controllerObservable.add(this.controllerCallback); - this._camera = new FreeCamera("Flat Camera", - new Vector3(0, .5, 0), - DefaultScene.MainScene); + this._controllerInput.onShootObservable.add(() => { + this.handleShoot(); + }); + + // 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(); + + // 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, 0.5, 0), + DefaultScene.MainScene + ); this._camera.parent = this._ship; - //DefaultScene.MainScene.setActiveCameraByName("Flat Camera"); - // Create sight reticle this._sight = new Sight(DefaultScene.MainScene, this._ship, { - position: new Vector3(0, .1, 125), + position: new Vector3(0, 0.1, 125), circleRadius: 2, crosshairLength: 1.5, lineThickness: 0.1, color: Color3.Green(), renderingGroupId: 3, - centerGap: 0.5 + centerGap: 0.5, }); - console.log(data.meshes.get('Screen')); - const screen = DefaultScene.MainScene.getMaterialById('Screen').getBindedMeshes()[0] as Mesh + + // Setup scoreboard on screen + console.log(data.meshes.get("Screen")); + const screen = DefaultScene.MainScene + .getMaterialById("Screen") + .getBindedMeshes()[0] as AbstractMesh; console.log(screen); const old = screen.parent; screen.setParent(null); screen.setPivotPoint(screen.getBoundingInfo().boundingSphere.center); screen.setParent(old); - screen.rotation.y = Math.PI; + screen.rotation.y = Math.PI; console.log(screen.rotation); console.log(screen.scaling); - this._scoreboard.initialize(screen); - - + this._scoreboard.initialize(screen as any); } + /** + * Update physics based on combined input from all input sources + */ + private updatePhysics(): void { + if (!this._ship?.physicsBody) { + return; + } - private _leftStickVector = Vector2.Zero().clone(); - private _rightStickVector = Vector2.Zero().clone(); - private _mouseDown = false; - private _mousePos = new Vector2(0, 0); + // 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 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; } - private applyForces() { - if (!this?._ship?.physicsBody) { - return; - } - const body = this._ship.physicsBody; - - // Get current velocities for velocity cap checks - const currentLinearVelocity = body.getLinearVelocity(); - const currentAngularVelocity = body.getAngularVelocity(); - const currentSpeed = currentLinearVelocity.length(); - - // Apply linear force from left stick Y (forward/backward) - if (Math.abs(this._leftStickVector.y) > .1) { - // Only apply force if we haven't reached max velocity - if (currentSpeed < MAX_LINEAR_VELOCITY) { - // Get local direction (Z-axis for forward/backward thrust) - const localDirection = new Vector3(0, 0, -this._leftStickVector.y); - // Transform to world space - TransformNode vectors are in local space! - const worldDirection = Vector3.TransformNormal(localDirection, this._ship.getWorldMatrix()); - const force = worldDirection.scale(LINEAR_FORCE_MULTIPLIER); - const thrustPoint = Vector3.TransformCoordinates(this._ship.physicsBody.getMassProperties().centerOfMass.add(new Vector3(0,1,0)), this._ship.getWorldMatrix()); - body.applyForce(force, thrustPoint); - - } - - // Handle primary thrust sound - if (this._primaryThrustVectorSound && !this._primaryThrustPlaying) { - this._primaryThrustVectorSound.play(); - this._primaryThrustPlaying = true; - } - if (this._primaryThrustVectorSound) { - this._primaryThrustVectorSound.volume = Math.abs(this._leftStickVector.y); - } - } else { - // Stop thrust sound when no input - if (this._primaryThrustVectorSound && this._primaryThrustPlaying) { - this._primaryThrustVectorSound.stop(); - this._primaryThrustPlaying = false; - } - } - - // Calculate rotation magnitude for torque and sound - const rotationMagnitude = Math.abs(this._rightStickVector.y) + - Math.abs(this._rightStickVector.x) + - Math.abs(this._leftStickVector.x); - - // Apply angular forces if any stick has significant rotation input - if (rotationMagnitude > .1) { - const currentAngularSpeed = currentAngularVelocity.length(); - - // Only apply torque if we haven't reached max angular velocity - if (currentAngularSpeed < MAX_ANGULAR_VELOCITY) { - const yaw = -this._leftStickVector.x; - const pitch = this._rightStickVector.y; - const roll = this._rightStickVector.x; - - // Create torque in local space, then transform to world space - const localTorque = new Vector3(pitch, yaw, roll).scale(ANGULAR_FORCE_MULTIPLIER); - const worldTorque = Vector3.TransformNormal(localTorque, this._ship.getWorldMatrix()); - - body.applyAngularImpulse(worldTorque); - - // Debug visualization for angular forces - } - - // Handle secondary thrust sound for rotation - if (this._secondaryThrustVectorSound && !this._secondaryThrustPlaying) { - this._secondaryThrustVectorSound.play(); - this._secondaryThrustPlaying = true; - } - if (this._secondaryThrustVectorSound) { - this._secondaryThrustVectorSound.volume = rotationMagnitude * .4; - } - } else { - // Stop rotation thrust sound when no input - if (this._secondaryThrustVectorSound && this._secondaryThrustPlaying) { - this._secondaryThrustVectorSound.stop(); - this._secondaryThrustPlaying = false; - } - } - } - - - private controllerCallback = (controllerEvent: ControllerEvent) => { - // Log first few events to verify they're firing - - if (controllerEvent.type == 'thumbstick') { - if (controllerEvent.hand == 'left') { - this._leftStickVector.x = controllerEvent.axisData.x; - this._leftStickVector.y = controllerEvent.axisData.y; - } - - if (controllerEvent.hand == 'right') { - this._rightStickVector.x = controllerEvent.axisData.x; - this._rightStickVector.y = controllerEvent.axisData.y; - } - this.applyForces(); - } - if (controllerEvent.type == 'button') { - if (controllerEvent.component.type == 'trigger') { - if (controllerEvent.value > .9 && !this._shooting) { - this._shooting = true; - this.shoot(); - } - if (controllerEvent.value < .1) { - this._shooting = false; - } - } - if (controllerEvent.component.type == 'button') { - if (controllerEvent.component.id == 'a-button') { - DefaultScene.XR.baseExperience.camera.position.y = DefaultScene.XR.baseExperience.camera.position.y - .1; - } - if (controllerEvent.component.id == 'b-button') { - DefaultScene.XR.baseExperience.camera.position.y = DefaultScene.XR.baseExperience.camera.position.y + .1; - } - console.log(controllerEvent); - - } - } - } - - private setupMouse() { - this._ship.getScene().onPointerDown = (evt) => { - this._mousePos.x = evt.x; - this._mousePos.y = evt.y; - this._mouseDown = true; - - } - this._ship.getScene().onPointerUp = () => { - this._mouseDown = false; - } - this._ship.getScene().onPointerMove = () => { - - }; - this._ship.getScene().onPointerMove = (ev) => { - if (!this._mouseDown) { - return - } - ; - const xInc = this._rightStickVector.x = (ev.x - this._mousePos.x) / 100; - const yInc = this._rightStickVector.y = (ev.y - this._mousePos.y) / 100; - if (Math.abs(xInc) <= 1) { - this._rightStickVector.x = xInc; - } else { - this._rightStickVector.x = Math.sign(xInc); - } - if (Math.abs(yInc) <= 1) { - this._rightStickVector.y = yInc; - } else { - this._rightStickVector.y = Math.sign(yInc); - } - this.applyForces(); - - }; - } - - private setupKeyboard() { - document.onkeyup = () => { - this._leftStickVector.y = 0; - this._leftStickVector.x = 0; - this._rightStickVector.y = 0; - this._rightStickVector.x = 0; - } - document.onkeydown = (ev) => { - switch (ev.key) { - case '1': - this._camera.position.x = 15; - this._camera.rotation.y = -Math.PI / 2; - break; - case ' ': - this.shoot(); - break; - case 'e': - break; - case 'w': - this._leftStickVector.y = -1; - break; - case 's': - this._leftStickVector.y = 1; - break; - case 'a': - this._leftStickVector.x = -1; - break; - case 'd': - this._leftStickVector.x = 1; - break; - case 'ArrowUp': - this._rightStickVector.y = -1; - break; - case 'ArrowDown': - this._rightStickVector.y = 1; - break; - - } - this.applyForces(); - }; - } - - private _leftInputSource: WebXRInputSource; - private _rightInputSource: WebXRInputSource; - + /** + * Add a VR controller to the input system + */ public addController(controller: WebXRInputSource) { - debugLog('Ship.addController called for:', controller.inputSource.handedness); - - if (controller.inputSource.handedness == "left") { - debugLog('Adding left controller'); - this._leftInputSource = controller; - this._leftInputSource.onMotionControllerInitObservable.add((motionController) => { - debugLog('Left motion controller initialized:', motionController.handness); - this.mapMotionController(motionController); - }); - - // Check if motion controller is already initialized - if (controller.motionController) { - debugLog('Left motion controller already initialized, mapping now'); - this.mapMotionController(controller.motionController); - } + debugLog( + "Ship.addController called for:", + controller.inputSource.handedness + ); + if (this._controllerInput) { + this._controllerInput.addController(controller); } - if (controller.inputSource.handedness == "right") { - debugLog('Adding right controller'); - this._rightInputSource = controller; - this._rightInputSource.onMotionControllerInitObservable.add((motionController) => { - debugLog('Right motion controller initialized:', motionController.handness); - this.mapMotionController(motionController); - }); - - // Check if motion controller is already initialized - if (controller.motionController) { - debugLog('Right motion controller already initialized, mapping now'); - this.mapMotionController(controller.motionController); - } - } - } - - private mapMotionController(controller: WebXRAbstractMotionController) { - debugLog('Mapping motion controller:', controller.handness, 'Profile:', controller.profileId); - - controllerComponents.forEach((component) => { - const comp = controller.components[component]; - - if (!comp) { - debugLog(` Component ${component} not found on ${controller.handness} controller`); - return; - } - - debugLog(` Found component ${component} on ${controller.handness} controller`); - const observable = this._controllerObservable; - - if (comp && comp.onAxisValueChangedObservable) { - comp.onAxisValueChangedObservable.add((axisData) => { - observable.notifyObservers({ - controller: controller, - hand: controller.handness, - type: 'thumbstick', - component: comp, - value: comp.value, - axisData: {x: axisData.x, y: axisData.y}, - pressed: comp.pressed, - touched: comp.touched - }) - }); - } - if (comp && comp.onButtonStateChangedObservable) { - comp.onButtonStateChangedObservable.add((component) => { - observable.notifyObservers({ - controller: controller, - hand: controller.handness, - type: 'button', - component: comp, - value: component.value, - axisData: {x: component.axes.x, y: component.axes.y}, - pressed: component.pressed, - touched: component.touched - }); - }); - } - }); } /** @@ -530,6 +281,20 @@ export class Ship { this._sight.dispose(); } - // Add other cleanup as needed + if (this._keyboardInput) { + this._keyboardInput.dispose(); + } + + if (this._controllerInput) { + this._controllerInput.dispose(); + } + + if (this._audio) { + this._audio.dispose(); + } + + if (this._weapons) { + this._weapons.dispose(); + } } } diff --git a/src/shipAudio.ts b/src/shipAudio.ts new file mode 100644 index 0000000..c1ac127 --- /dev/null +++ b/src/shipAudio.ts @@ -0,0 +1,109 @@ +import type { AudioEngineV2, StaticSound } from "@babylonjs/core"; + +/** + * Manages ship audio (thrust sounds and weapon fire) + */ +export class ShipAudio { + private _audioEngine: AudioEngineV2; + private _primaryThrustSound: StaticSound; + private _secondaryThrustSound: StaticSound; + private _weaponSound: StaticSound; + private _primaryThrustPlaying: boolean = false; + private _secondaryThrustPlaying: boolean = false; + + constructor(audioEngine?: AudioEngineV2) { + this._audioEngine = audioEngine; + } + + /** + * Initialize sound assets + */ + public async initialize(): Promise { + if (!this._audioEngine) return; + + this._primaryThrustSound = await this._audioEngine.createSoundAsync( + "thrust", + "/thrust5.mp3", + { + loop: true, + volume: 0.2, + } + ); + + this._secondaryThrustSound = await this._audioEngine.createSoundAsync( + "thrust2", + "/thrust5.mp3", + { + loop: true, + volume: 0.5, + } + ); + + this._weaponSound = await this._audioEngine.createSoundAsync( + "shot", + "/shot.mp3", + { + loop: false, + volume: 0.5, + } + ); + } + + /** + * Update thrust audio based on current force magnitudes + * @param linearMagnitude - Forward/backward thrust magnitude (0-1) + * @param angularMagnitude - Rotation thrust magnitude (0-3) + */ + public updateThrustAudio( + linearMagnitude: number, + angularMagnitude: number + ): void { + // Handle primary thrust sound (forward/backward movement) + if (linearMagnitude > 0) { + if (this._primaryThrustSound && !this._primaryThrustPlaying) { + this._primaryThrustSound.play(); + this._primaryThrustPlaying = true; + } + if (this._primaryThrustSound) { + this._primaryThrustSound.volume = linearMagnitude; + } + } else { + if (this._primaryThrustSound && this._primaryThrustPlaying) { + this._primaryThrustSound.stop(); + this._primaryThrustPlaying = false; + } + } + + // Handle secondary thrust sound (rotation) + if (angularMagnitude > 0.1) { + if (this._secondaryThrustSound && !this._secondaryThrustPlaying) { + this._secondaryThrustSound.play(); + this._secondaryThrustPlaying = true; + } + if (this._secondaryThrustSound) { + this._secondaryThrustSound.volume = angularMagnitude * 0.4; + } + } else { + if (this._secondaryThrustSound && this._secondaryThrustPlaying) { + this._secondaryThrustSound.stop(); + this._secondaryThrustPlaying = false; + } + } + } + + /** + * Play weapon fire sound + */ + public playWeaponSound(): void { + this._weaponSound?.play(); + } + + /** + * Cleanup audio resources + */ + public dispose(): void { + this._primaryThrustSound?.dispose(); + this._secondaryThrustSound?.dispose(); + this._weaponSound?.dispose(); + } +} diff --git a/src/shipPhysics.ts b/src/shipPhysics.ts new file mode 100644 index 0000000..bebf250 --- /dev/null +++ b/src/shipPhysics.ts @@ -0,0 +1,109 @@ +import { PhysicsBody, TransformNode, Vector2, Vector3 } from "@babylonjs/core"; + +// Physics constants +const MAX_LINEAR_VELOCITY = 200; +const MAX_ANGULAR_VELOCITY = 1.4; +const LINEAR_FORCE_MULTIPLIER = 800; +const ANGULAR_FORCE_MULTIPLIER = 15; + +export interface InputState { + leftStick: Vector2; + rightStick: Vector2; +} + +export interface ForceApplicationResult { + linearMagnitude: number; + angularMagnitude: number; +} + +/** + * Handles physics force calculations and application for the ship + * Pure calculation logic with no external dependencies + */ +export class ShipPhysics { + /** + * Apply forces to the ship based on input state + * @param inputState - Current input state (stick positions) + * @param physicsBody - Physics body to apply forces to + * @param transformNode - Transform node for world space calculations + * @returns Force magnitudes for audio feedback + */ + public applyForces( + inputState: InputState, + physicsBody: PhysicsBody, + transformNode: TransformNode + ): ForceApplicationResult { + if (!physicsBody) { + return { linearMagnitude: 0, angularMagnitude: 0 }; + } + + const { leftStick, rightStick } = inputState; + + // Get current velocities for velocity cap checks + const currentLinearVelocity = physicsBody.getLinearVelocity(); + const currentAngularVelocity = physicsBody.getAngularVelocity(); + const currentSpeed = currentLinearVelocity.length(); + + let linearMagnitude = 0; + let angularMagnitude = 0; + + // Apply linear force from left stick Y (forward/backward) + if (Math.abs(leftStick.y) > 0.1) { + linearMagnitude = Math.abs(leftStick.y); + + // Only apply force if we haven't reached max velocity + if (currentSpeed < MAX_LINEAR_VELOCITY) { + // Get local direction (Z-axis for forward/backward thrust) + const localDirection = new Vector3(0, 0, -leftStick.y); + // Transform to world space + const worldDirection = Vector3.TransformNormal( + localDirection, + transformNode.getWorldMatrix() + ); + const force = worldDirection.scale(LINEAR_FORCE_MULTIPLIER); + + // Calculate thrust point: center of mass + offset (0, 1, 0) in world space + const thrustPoint = Vector3.TransformCoordinates( + physicsBody.getMassProperties().centerOfMass.add(new Vector3(0, 1, 0)), + transformNode.getWorldMatrix() + ); + + physicsBody.applyForce(force, thrustPoint); + } + } + + // Calculate rotation magnitude for torque + angularMagnitude = + Math.abs(rightStick.y) + + Math.abs(rightStick.x) + + Math.abs(leftStick.x); + + // Apply angular forces if any stick has significant rotation input + if (angularMagnitude > 0.1) { + const currentAngularSpeed = currentAngularVelocity.length(); + + // Only apply torque if we haven't reached max angular velocity + if (currentAngularSpeed < MAX_ANGULAR_VELOCITY) { + const yaw = -leftStick.x; + const pitch = rightStick.y; + const roll = rightStick.x; + + // Create torque in local space, then transform to world space + const localTorque = new Vector3(pitch, yaw, roll).scale( + ANGULAR_FORCE_MULTIPLIER + ); + const worldTorque = Vector3.TransformNormal( + localTorque, + transformNode.getWorldMatrix() + ); + + physicsBody.applyAngularImpulse(worldTorque); + } + } + + return { + linearMagnitude, + angularMagnitude, + }; + } +} diff --git a/src/weaponSystem.ts b/src/weaponSystem.ts new file mode 100644 index 0000000..f69ad00 --- /dev/null +++ b/src/weaponSystem.ts @@ -0,0 +1,99 @@ +import { + AbstractMesh, + Color3, + InstancedMesh, + Mesh, + MeshBuilder, + PhysicsAggregate, + PhysicsMotionType, + PhysicsShapeType, + Scene, + StandardMaterial, + TransformNode, + Vector3, +} from "@babylonjs/core"; +import { GameConfig } from "./gameConfig"; + +/** + * Handles weapon firing and projectile lifecycle + */ +export class WeaponSystem { + private _ammoBaseMesh: AbstractMesh; + private _ammoMaterial: StandardMaterial; + private _scene: Scene; + + constructor(scene: Scene) { + this._scene = scene; + } + + /** + * Initialize weapon system (create ammo template) + */ + 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 }, + this._scene + ); + this._ammoBaseMesh.material = this._ammoMaterial; + this._ammoBaseMesh.setEnabled(false); + } + + /** + * 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) + */ + public fire( + shipTransform: TransformNode, + velocityVector: Vector3 + ): void { + // Only allow shooting if physics is enabled + const config = GameConfig.getInstance(); + if (!config.physicsEnabled) { + return; + } + + // Create projectile instance + const ammo = new InstancedMesh("ammo", this._ammoBaseMesh as Mesh); + ammo.parent = shipTransform; + ammo.position.y = 0.1; + ammo.position.z = 8.4; + + // Detach from parent to move independently + ammo.setParent(null); + + // 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); + + // Set projectile velocity (already includes ship velocity) + ammoAggregate.body.setLinearVelocity(velocityVector); + + // Auto-dispose after 2 seconds + window.setTimeout(() => { + ammoAggregate.dispose(); + ammo.dispose(); + }, 2000); + } + + /** + * Cleanup weapon system resources + */ + public dispose(): void { + this._ammoBaseMesh?.dispose(); + this._ammoMaterial?.dispose(); + } +}