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:
parent
71ff46e4cf
commit
e31e25f9e5
9
.claude/mcp.json
Normal file
9
.claude/mcp.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"babylon-mcp": {
|
||||
"command": "npx",
|
||||
"args": ["mcp-proxy", "http://localhost:4000/mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()}`);
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user