From e31e25f9e5bb8e444bfe4f22b1d5b8bd2b357d8f Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Mon, 24 Nov 2025 14:03:32 -0600 Subject: [PATCH] Fix physics issues: sleep behavior, center of mass, and input scaling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit resolves several physics-related issues that were causing unexpected behavior in ship and asteroid movement: **Physics Sleep System** - Fixed abrupt stops by preventing Havok from putting bodies to sleep - Added PhysicsActivationControl.ALWAYS_ACTIVE for ship and asteroids - Made ship sleep behavior configurable via shipPhysics.alwaysActive - Sleep was causing sudden velocity zeroing at low speeds **Center of Mass Issues** - Discovered mesh-based physics calculated offset CoM: (0, -0.38, 0.37) - Override ship center of mass to (0, 0, 0) to prevent thrust torque - Applying force at offset CoM was creating unwanted pitch rotation - Added debug logging to track mass properties **Input Deadzone Improvements** - Implemented smooth deadzone scaling (0.1-0.15 range) - Replaced hard threshold cliff with linear interpolation - Prevents abrupt control cutoff during gentle inputs - Added VR mode check to disable keyboard fallback in VR **Configuration System** - Added DEFAULT_SHIP_PHYSICS constant as single source of truth - Added tunable parameters: linearDamping, angularDamping, alwaysActive - Added fuel consumption rates: linearFuelConsumptionRate, angularFuelConsumptionRate - Tuned for 1 minute linear thrust, 2 minutes angular thrust at 60Hz - All physics parameters now persist to localStorage **Other Fixes** - Changed orbit center to STATIC motion type (was ANIMATED) - Fixed linear force application point (removed offset) - Added ship initial velocity support from level config - Changed physics update from every 10 frames to every physics tick - Increased linear input threshold from 0.1 to 0.15 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .claude/mcp.json | 9 +++ public/levels/rookie-training.json | 6 +- src/core/gameConfig.ts | 42 +++++++----- src/environment/asteroids/rockFactory.ts | 12 +++- src/levels/level1.ts | 22 +++++++ src/main.ts | 7 ++ src/ship/ship.ts | 81 +++++++++++++++++++----- src/ship/shipPhysics.ts | 19 ++++-- src/ui/hud/missionBrief.ts | 2 + src/ui/hud/statusScreen.ts | 1 + 10 files changed, 156 insertions(+), 45 deletions(-) create mode 100644 .claude/mcp.json diff --git a/.claude/mcp.json b/.claude/mcp.json new file mode 100644 index 0000000..20b34db --- /dev/null +++ b/.claude/mcp.json @@ -0,0 +1,9 @@ + { + "mcpServers": { + "babylon-mcp": { + "command": "npx", + "args": ["mcp-proxy", "http://localhost:4000/mcp"] + } + } + } + diff --git a/public/levels/rookie-training.json b/public/levels/rookie-training.json index 0694a9f..77f852c 100644 --- a/public/levels/rookie-training.json +++ b/public/levels/rookie-training.json @@ -1,5 +1,5 @@ { - "version": "1.0", + "version": "1.1", "difficulty": "recruit", "timestamp": "2025-11-11T23:44:24.807Z", "metadata": { @@ -11,8 +11,8 @@ "ship": { "position": [ 0, - 1.5, - 500 + 2, + 0 ], "rotation": [ 0, diff --git a/src/core/gameConfig.ts b/src/core/gameConfig.ts index b6770e7..c4a857e 100644 --- a/src/core/gameConfig.ts +++ b/src/core/gameConfig.ts @@ -1,3 +1,18 @@ +/** + * Default ship physics configuration + */ +const DEFAULT_SHIP_PHYSICS = { + maxLinearVelocity: 200, + maxAngularVelocity: 1.4, + linearForceMultiplier: 100, + angularForceMultiplier: 1.5, + linearFuelConsumptionRate: 0.00002778, // 1 minute at full thrust (60 Hz) + angularFuelConsumptionRate: 0.0001389, // 2 minutes at full thrust (60 Hz) + linearDamping: 0.2, + angularDamping: 0.3, // Moderate damping for 2-3 second coast + alwaysActive: true // Prevent physics sleep (false may cause abrupt stops at zero velocity) +}; + /** * Global game configuration settings * Singleton class for managing game-wide settings @@ -13,12 +28,7 @@ export class GameConfig { public progressionEnabled: boolean = true; // Enable level progression system // Ship physics tuning parameters - public shipPhysics = { - maxLinearVelocity: 200, - maxAngularVelocity: 1.4, - linearForceMultiplier: 800, - angularForceMultiplier: 15 - }; + public shipPhysics = { ...DEFAULT_SHIP_PHYSICS }; /** * Private constructor for singleton pattern @@ -66,10 +76,15 @@ export class GameConfig { // Load ship physics with fallback to defaults if (config.shipPhysics) { this.shipPhysics = { - maxLinearVelocity: config.shipPhysics.maxLinearVelocity ?? 200, - maxAngularVelocity: config.shipPhysics.maxAngularVelocity ?? 1.4, - linearForceMultiplier: config.shipPhysics.linearForceMultiplier ?? 800, - angularForceMultiplier: config.shipPhysics.angularForceMultiplier ?? 15 + maxLinearVelocity: config.shipPhysics.maxLinearVelocity ?? DEFAULT_SHIP_PHYSICS.maxLinearVelocity, + maxAngularVelocity: config.shipPhysics.maxAngularVelocity ?? DEFAULT_SHIP_PHYSICS.maxAngularVelocity, + linearForceMultiplier: config.shipPhysics.linearForceMultiplier ?? DEFAULT_SHIP_PHYSICS.linearForceMultiplier, + angularForceMultiplier: config.shipPhysics.angularForceMultiplier ?? DEFAULT_SHIP_PHYSICS.angularForceMultiplier, + linearFuelConsumptionRate: config.shipPhysics.linearFuelConsumptionRate ?? DEFAULT_SHIP_PHYSICS.linearFuelConsumptionRate, + angularFuelConsumptionRate: config.shipPhysics.angularFuelConsumptionRate ?? DEFAULT_SHIP_PHYSICS.angularFuelConsumptionRate, + linearDamping: config.shipPhysics.linearDamping ?? DEFAULT_SHIP_PHYSICS.linearDamping, + angularDamping: config.shipPhysics.angularDamping ?? DEFAULT_SHIP_PHYSICS.angularDamping, + alwaysActive: config.shipPhysics.alwaysActive ?? DEFAULT_SHIP_PHYSICS.alwaysActive, }; } } else { @@ -87,12 +102,7 @@ export class GameConfig { this.physicsEnabled = true; this.debug = false; this.progressionEnabled = true; - this.shipPhysics = { - maxLinearVelocity: 200, - maxAngularVelocity: 1.4, - linearForceMultiplier: 800, - angularForceMultiplier: 15 - }; + this.shipPhysics = { ...DEFAULT_SHIP_PHYSICS }; this.save(); } } diff --git a/src/environment/asteroids/rockFactory.ts b/src/environment/asteroids/rockFactory.ts index b9b7822..7f5ecd3 100644 --- a/src/environment/asteroids/rockFactory.ts +++ b/src/environment/asteroids/rockFactory.ts @@ -2,9 +2,11 @@ import { AbstractMesh, AudioEngineV2, DistanceConstraint, + HavokPlugin, InstancedMesh, Mesh, Observable, + PhysicsActivationControl, PhysicsAggregate, PhysicsBody, PhysicsMotionType, @@ -45,8 +47,8 @@ export class RockFactory { // Initialize explosion manager const node = new TransformNode('orbitCenter', DefaultScene.MainScene); node.position = Vector3.Zero(); - this._orbitCenter = new PhysicsAggregate(node, PhysicsShapeType.SPHERE, {radius: .1, mass: 1000}, DefaultScene.MainScene ); - this._orbitCenter.body.setMotionType(PhysicsMotionType.ANIMATED); + this._orbitCenter = new PhysicsAggregate(node, PhysicsShapeType.SPHERE, {radius: .1, mass: 0}, DefaultScene.MainScene ); + this._orbitCenter.body.setMotionType(PhysicsMotionType.STATIC); this._explosionManager = new ExplosionManager(DefaultScene.MainScene, { duration: 2000, explosionForce: 150.0, @@ -115,6 +117,12 @@ export class RockFactory { body.setMotionType(PhysicsMotionType.DYNAMIC); body.setCollisionCallbackEnabled(true); + // Prevent asteroids from sleeping to ensure consistent physics simulation + const physicsPlugin = DefaultScene.MainScene.getPhysicsEngine()?.getPhysicsPlugin() as HavokPlugin; + if (physicsPlugin) { + physicsPlugin.setActivationControl(body, PhysicsActivationControl.ALWAYS_ACTIVE); + } + debugLog(`[RockFactory] Setting velocities for ${rock.name}:`); debugLog(`[RockFactory] Linear velocity input: ${linearVelocitry.toString()}`); debugLog(`[RockFactory] Angular velocity input: ${angularVelocity.toString()}`); diff --git a/src/levels/level1.ts b/src/levels/level1.ts index 1354fdf..6277f1c 100644 --- a/src/levels/level1.ts +++ b/src/levels/level1.ts @@ -61,6 +61,12 @@ export class Level1 implements Level { const currPose = xr.baseExperience.camera.globalPosition.y; xr.baseExperience.camera.position = new Vector3(0, 1.5, 0); + // Disable keyboard input in VR mode to prevent interference + if (this._ship.keyboardInput) { + this._ship.keyboardInput.setEnabled(false); + debugLog('[Level1] Keyboard input disabled for VR mode'); + } + // Track WebXR session start try { const analytics = getAnalytics(); @@ -290,6 +296,22 @@ export class Level1 implements Level { await this._ship.initialize(); setLoadingMessage("Loading level from configuration..."); + // Apply ship configuration from level config + const shipConfig = this._deserializer.getShipConfig(); + this._ship.position = new Vector3(...shipConfig.position); + + if (shipConfig.linearVelocity) { + this._ship.setLinearVelocity(new Vector3(...shipConfig.linearVelocity)); + } else { + this._ship.setLinearVelocity(Vector3.Zero()); + } + + if (shipConfig.angularVelocity) { + this._ship.setAngularVelocity(new Vector3(...shipConfig.angularVelocity)); + } else { + this._ship.setAngularVelocity(Vector3.Zero()); + } + // Use deserializer to create all entities from config const entities = await this._deserializer.deserialize(this._ship.scoreboard.onScoreObservable); diff --git a/src/main.ts b/src/main.ts index 4ad7d9a..9d6b2b3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -538,6 +538,13 @@ export class Main { const inputManager = InputControlManager.getInstance(); inputManager.registerPointerFeature(pointerFeature); debugLog("Pointer selection feature registered with InputControlManager"); + + // Configure scene-wide picking predicate to only allow UI meshes + DefaultScene.MainScene.pointerMovePredicate = (mesh) => { + // Only allow picking meshes with metadata.uiPickable = true + return mesh.metadata?.uiPickable === true; + }; + debugLog("Scene picking predicate configured for VR UI only"); } // Hide Discord widget when entering VR, show when exiting diff --git a/src/ship/ship.ts b/src/ship/ship.ts index b8df7ca..a838d3e 100644 --- a/src/ship/ship.ts +++ b/src/ship/ship.ts @@ -2,8 +2,10 @@ import { AbstractMesh, Color3, FreeCamera, + HavokPlugin, Mesh, Observable, + PhysicsActivationControl, PhysicsAggregate, PhysicsMotionType, PhysicsShapeType, @@ -118,6 +120,18 @@ export class Ship { }); } + public setLinearVelocity(velocity: Vector3): void { + if (this._ship?.physicsBody) { + this._ship.physicsBody.setLinearVelocity(velocity); + } + } + + public setAngularVelocity(velocity: Vector3): void { + if (this._ship?.physicsBody) { + this._ship.physicsBody.setAngularVelocity(velocity); + } + } + public async initialize() { this._scoreboard = new Scoreboard(); this._scoreboard.setShip(this); // Pass ship reference for velocity reading @@ -126,7 +140,7 @@ export class Ship { const data = await loadAsset("ship.glb"); this._ship = data.container.transformNodes[0]; // this._ship.id = "Ship"; // Set ID so mission brief can find it - this._ship.position.y = 5; + // Position is now set from level config in Level1.initialize() // Create physics if enabled const config = GameConfig.getInstance(); @@ -144,11 +158,37 @@ export class Ship { ); agg.body.setMotionType(PhysicsMotionType.DYNAMIC); - agg.body.setLinearDamping(0.2); - agg.body.setAngularDamping(0.4); + agg.body.setLinearDamping(config.shipPhysics.linearDamping); + agg.body.setAngularDamping(config.shipPhysics.angularDamping); agg.body.setAngularVelocity(new Vector3(0, 0, 0)); agg.body.setCollisionCallbackEnabled(true); + // Debug: Log center of mass before override + const massProps = agg.body.getMassProperties(); + console.log(`[Ship] Original center of mass (local): ${massProps.centerOfMass.toString()}`); + console.log(`[Ship] Mass: ${massProps.mass}`); + console.log(`[Ship] Inertia: ${massProps.inertia.toString()}`); + + // Override center of mass to origin to prevent thrust from causing torque + // (mesh-based physics was calculating offset center of mass from geometry) + agg.body.setMassProperties({ + mass: 10, + centerOfMass: new Vector3(0, 0, 0), + inertia: massProps.inertia, + inertiaOrientation: massProps.inertiaOrientation + }); + + console.log(`[Ship] Center of mass overridden to: ${agg.body.getMassProperties().centerOfMass.toString()}`); + + // Configure physics sleep behavior from config + // (disabling sleep prevents abrupt stops at zero linear velocity) + if (config.shipPhysics.alwaysActive) { + const physicsPlugin = DefaultScene.MainScene.getPhysicsEngine()?.getPhysicsPlugin() as HavokPlugin; + if (physicsPlugin) { + physicsPlugin.setActivationControl(agg.body, PhysicsActivationControl.ALWAYS_ACTIVE); + } + } + // Register collision handler for energy-based hull damage const observable = agg.body.getCollisionObservable(); observable.add((collisionEvent) => { @@ -276,18 +316,14 @@ export class Ship { this._physics.setGameStats(this._gameStats); // Setup physics update loop (every 10 frames) + DefaultScene.MainScene.onAfterPhysicsObservable.add(() => { + this.updatePhysics(); + }) DefaultScene.MainScene.onAfterRenderObservable.add(() => { - this._frameCount++; - if (this._frameCount >= 10) { - this._frameCount = 0; - this.updatePhysics(); - } - // Update voice audio system (checks for completed sounds and plays next in queue) if (this._voiceAudio) { this._voiceAudio.update(); } - // Check game end conditions every frame (but only acts once) this.checkGameEndConditions(); }); @@ -473,6 +509,9 @@ export class Ship { return; } + // Check if we're in VR mode + const inVRMode = DefaultScene.XR?.baseExperience?.state === 2; // WebXRState.IN_XR = 2 + // Combine input from keyboard and controller const keyboardState = this._keyboardInput?.getInputState() || { leftStick: Vector2.Zero(), @@ -483,16 +522,24 @@ export class Ship { rightStick: Vector2.Zero(), }; - // Merge inputs (controller takes priority if active) + // Merge inputs with smooth deadzone scaling (controller takes priority if active, keyboard disabled in VR) + // Deadzone: 0.1-0.15 range with linear scaling (avoids abrupt cliff effect) + const leftMagnitude = controllerState.leftStick.length(); + const rightMagnitude = controllerState.rightStick.length(); + + // Scale factor: 0% at 0.1, 100% at 0.15, linear interpolation between + const leftScale = Math.max(0, Math.min(1, (leftMagnitude - 0.1) / 0.05)); + const rightScale = Math.max(0, Math.min(1, (rightMagnitude - 0.1) / 0.05)); + const combinedInput = { leftStick: - controllerState.leftStick.length() > 0.1 - ? controllerState.leftStick - : keyboardState.leftStick, + leftMagnitude > 0.1 + ? controllerState.leftStick.scale(leftScale) + : (inVRMode ? Vector2.Zero() : keyboardState.leftStick), rightStick: - controllerState.rightStick.length() > 0.1 - ? controllerState.rightStick - : keyboardState.rightStick, + rightMagnitude > 0.1 + ? controllerState.rightStick.scale(rightScale) + : (inVRMode ? Vector2.Zero() : keyboardState.rightStick), }; // Apply forces and get magnitudes for audio diff --git a/src/ship/shipPhysics.ts b/src/ship/shipPhysics.ts index 532ff38..2ba5cb9 100644 --- a/src/ship/shipPhysics.ts +++ b/src/ship/shipPhysics.ts @@ -63,7 +63,7 @@ export class ShipPhysics { let angularMagnitude = 0; // Apply linear force from left stick Y (forward/backward) - if (Math.abs(leftStick.y) > 0.1) { + if (Math.abs(leftStick.y) > 0.15) { linearMagnitude = Math.abs(leftStick.y); // Check if we have fuel before applying force @@ -79,16 +79,17 @@ export class ShipPhysics { ); const force = worldDirection.scale(this._config.linearForceMultiplier); - // Calculate thrust point: center of mass + offset (0, 1, 0) in world space + // Apply force at center of mass to avoid unintended torque + // (applying at an offset point creates rotation, noticeable at zero linear velocity) const thrustPoint = Vector3.TransformCoordinates( - physicsBody.getMassProperties().centerOfMass.add(new Vector3(0, 1, 0)), + physicsBody.getMassProperties().centerOfMass, transformNode.getWorldMatrix() ); physicsBody.applyForce(force, thrustPoint); - // Consume fuel: normalized magnitude (0-1) * 0.005 per frame - const fuelConsumption = linearMagnitude * 0.005; + // Consume fuel based on config rate (tuned for 1 minute at full thrust) + const fuelConsumption = linearMagnitude * this._config.linearFuelConsumptionRate; this._shipStatus.consumeFuel(fuelConsumption); // Track fuel consumed for statistics @@ -126,11 +127,15 @@ export class ShipPhysics { transformNode.getWorldMatrix() ); + // Note: Havok only exposes angular impulse, not torque + // Babylon.js implements applyForce() as: impulse = force * timeStep + // We do the same for angular: scale torque by physics timestep (1/60) + // Since we call this every 10 frames, we accumulate 10 timesteps worth physicsBody.applyAngularImpulse(worldTorque); - // Consume fuel: normalized magnitude (0-3 max) / 3 * 0.005 per frame + // Consume fuel based on config rate (tuned for 2 minutes at full thrust) const normalizedAngularMagnitude = Math.min(angularMagnitude / 3.0, 1.0); - const fuelConsumption = normalizedAngularMagnitude * 0.005; + const fuelConsumption = normalizedAngularMagnitude * this._config.angularFuelConsumptionRate; this._shipStatus.consumeFuel(fuelConsumption); // Track fuel consumed for statistics diff --git a/src/ui/hud/missionBrief.ts b/src/ui/hud/missionBrief.ts index f98f1db..371627b 100644 --- a/src/ui/hud/missionBrief.ts +++ b/src/ui/hud/missionBrief.ts @@ -46,6 +46,8 @@ export class MissionBrief { mesh.parent = ship; mesh.position = new Vector3(0,1,2.8); + mesh.renderingGroupId = 3; // Same as status screen for consistent rendering + mesh.metadata = { uiPickable: true }; // TAG: VR UI - allow pointer selection console.log('[MissionBrief] Mesh parented to ship at position:', mesh.position); console.log('[MissionBrief] Mesh absolute position:', mesh.getAbsolutePosition()); console.log('[MissionBrief] Mesh scaling:', mesh.scaling); diff --git a/src/ui/hud/statusScreen.ts b/src/ui/hud/statusScreen.ts index d31c635..2b9f276 100644 --- a/src/ui/hud/statusScreen.ts +++ b/src/ui/hud/statusScreen.ts @@ -94,6 +94,7 @@ export class StatusScreen { this._screenMesh.position = new Vector3(0, 0, 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.metadata = { uiPickable: true }; // TAG: VR UI - allow pointer selection // Create material const material = new StandardMaterial("statusScreenMaterial", this._scene);