Fix physics issues: sleep behavior, center of mass, and input scaling

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 <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-11-24 14:03:32 -06:00
parent 71ff46e4cf
commit e31e25f9e5
10 changed files with 156 additions and 45 deletions

9
.claude/mcp.json Normal file
View File

@ -0,0 +1,9 @@
{
"mcpServers": {
"babylon-mcp": {
"command": "npx",
"args": ["mcp-proxy", "http://localhost:4000/mcp"]
}
}
}

View File

@ -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,

View File

@ -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();
}
}

View File

@ -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()}`);

View File

@ -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);

View File

@ -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

View File

@ -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.onAfterRenderObservable.add(() => {
this._frameCount++;
if (this._frameCount >= 10) {
this._frameCount = 0;
DefaultScene.MainScene.onAfterPhysicsObservable.add(() => {
this.updatePhysics();
}
})
DefaultScene.MainScene.onAfterRenderObservable.add(() => {
// 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

View File

@ -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

View File

@ -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);

View File

@ -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);