spaceship-vectors-controller.js

total 0
used 0
limit 0
/* categories: relative files: ../point_src/core/head.js ../point_src/pointpen.js ../point_src/pointdraw.js ../point_src/extras.js ../point_src/math.js ../point_src/point-content.js ../point_src/stage.js point dragging stroke ../point_src/distances.js pointlist ../point_src/events.js ../point_src/automouse.js ../point_src/relative.js ../point_src/keyboard.js ../point_src/constrain-distance.js ../point_src/screenwrap.js Coupled Vector Engines - Rigid Body Physics Simulation This demonstrates a proper 2D rigid body physics system where multiple engines are coupled together to form a single ship. Key physics concepts: 1. CENTER OF MASS (COM): The true pivot point calculated from all masses 2. MOMENT OF INERTIA: Resistance to rotation based on mass distribution 3. TORQUE: Rotational force = distance × force (cross product) 4. GRAVITY-GRADIENT TORQUE: Instability from mass distribution in gravity field Physics Improvements (v2): - Fixed: Ship now rotates around actual COM, not reference point - Fixed: Gravity strength consistent between linear and rotational - Fixed: Multiple COM calculations optimized to single calculation per frame - Added: getTotalMass() helper to avoid repeated calculations - Cleaned: Removed unused helper functions and variables Physics Improvements (v3): - Refactored: Extracted calculateMomentOfInertia() as standalone function - Documented: Complete API reference in docs/moment-of-inertia-api.md - Educational: Added beginner's guide in docs/moment-of-inertia-explained.md - Simplified: Unified body list API - all bodies treated equally, distinguished by isLocal flag - Extracted: calculateTotalMass(), calculateCenterOfMass(), applyEngineForces(), applyGravityGradientTorque(), applyGamepadControls(), and updateRigidBodyPhysics() as reusable standalone functions */ // ============================================================================ // MOMENT OF INERTIA CALCULATION // ============================================================================ /** * Calculate the moment of inertia for a rigid body system around its center of mass. * * WHAT IS MOMENT OF INERTIA? * Think of it as "rotational mass" - it describes how hard it is to spin an object. * Just like mass resists being pushed (linear motion), moment of inertia resists * being spun (rotational motion). * * KEY INSIGHT: * - A heavy object far from the spin point is VERY hard to spin (high I) * - A light object close to the spin point is EASY to spin (low I) * * FORMULA: I = Σ(mass × distance²) * For each piece of mass, multiply its mass by the SQUARE of its distance from * the center of rotation. Then add them all up. * * WHY DISTANCE SQUARED? * Moving mass twice as far away makes it FOUR times harder to spin (2² = 4). * This is why figure skaters spin faster when they pull their arms in! * * PRACTICAL EXAMPLES: * 1. Ice skater with arms out: HIGH moment of inertia → spins SLOWLY * Ice skater with arms in: LOW moment of inertia → spins FAST * * 2. Bicycle wheel: mass at the rim → HIGH I → stable, hard to tilt * Solid disc of same mass: mass at center → LOW I → easy to tilt * * 3. A hammer: easy to spin around handle (mass is far), hard to spin around * the middle (mass evenly distributed) * * IN THIS SIMULATION: * - Ship with cargo far from center → HIGH I → sluggish rotation * - Ship with cargo at center → LOW I → responsive rotation * - Engines firing off-center create torque based on this I value * * SIMPLIFIED API: * All bodies (ship, engines, cargo, fuel tanks) are treated uniformly as mass points. * The difference between "engines" and "cargo" is only that engines apply force - * both contribute equally to moment of inertia based on their mass and position. * * @param {Object} centerOfMass - The pivot point {x, y} * @param {Object} mainBody - The main rigid body with {x, y, mass, radians} * @param {Array} bodies - Array of ALL mass-bearing objects: * - World-space bodies: {x, y, mass} (engines, asteroids, etc.) * - Local-space bodies: {x, y, mass, isLocal: true} (cargo, fuel) * Local-space bodies will be rotated by mainBody.radians * @returns {number} The moment of inertia (I) in mass × distance² units * * @example * const com = {x: 100, y: 100} * const ship = {x: 100, y: 100, mass: 10, radians: 0} * const bodies = [ * {x: 90, y: 100, mass: 1}, // Engine (world space) * {x: 110, y: 100, mass: 1}, // Engine (world space) * {x: 50, y: 0, mass: 20, isLocal: true} // Heavy cargo (local space) * ] * * const I = calculateMomentOfInertia(com, ship, bodies) * // Result: High I because cargo is far from center * // This ship will be slow to rotate */ function calculateMomentOfInertia(centerOfMass, mainBody, bodies = []) { let I = 0 const com = centerOfMass // 1. Add contribution from the main body const dx = mainBody.x - com.x const dy = mainBody.y - com.y const distanceSquared = dx * dx + dy * dy I += mainBody.mass * distanceSquared // 2. Add contributions from all bodies (engines, cargo, fuel tanks, etc.) const cos = Math.cos(mainBody.radians || 0) const sin = Math.sin(mainBody.radians || 0) for (let body of bodies) { let worldX, worldY // Check if this is a local-space body (needs rotation) if (body.isLocal) { // Rotate from local to world coordinates const rotatedX = body.x * cos - body.y * sin const rotatedY = body.x * sin + body.y * cos worldX = mainBody.x + rotatedX worldY = mainBody.y + rotatedY } else { // Already in world space worldX = body.x worldY = body.y } // Calculate distance from center of mass const dx = worldX - com.x const dy = worldY - com.y const distanceSquared = dx * dx + dy * dy I += body.mass * distanceSquared } return I } /** * Calculate the total mass of a rigid body system. * * This is a simple summation of all mass in the system, used for: * - Linear motion calculations (F = ma) * - Center of mass calculations * - Understanding the system's overall mass distribution * * @param {Object} mainBody - The main rigid body with {mass} * @param {Array} bodies - Array of ALL mass-bearing objects with {mass} property * Both world-space and local-space bodies can be included * @returns {number} The total mass of the system * * @example * const ship = {mass: 10} * const bodies = [ * {mass: 1}, // Engine * {mass: 1}, // Engine * {mass: 20} // Cargo * ] * * const totalMass = calculateTotalMass(ship, bodies) * // Result: 32 (10 + 1 + 1 + 20) */ function calculateTotalMass(mainBody, bodies = []) { let totalMass = mainBody.mass for (let body of bodies) { totalMass += body.mass } return totalMass } /** * Calculate the center of mass (COM) for a rigid body system. * * WHAT IS CENTER OF MASS? * The center of mass is the "balance point" of an object - the point where all * the mass seems to be concentrated. It's where the object rotates around naturally. * * REAL-WORLD EXAMPLE: * Hold a hammer by the handle - it wants to rotate around a point near the head, * not the middle of the handle. That's the center of mass! * * PHYSICS: * COM = Σ(mass_i × position_i) / Σ(mass_i) * Each piece of mass "votes" for where the COM should be, weighted by its mass. * Heavy masses far away pull the COM toward them more than light masses. * * WHY IT MATTERS: * - Objects rotate around their COM naturally * - Forces through the COM produce linear motion only (no rotation) * - Forces off-center from COM produce both linear motion AND rotation * * IN THIS SIMULATION: * - Ship with heavy cargo on top → COM shifts upward → unstable * - Balanced cargo → COM stays centered → stable flight * * @param {Object} mainBody - The main rigid body with {x, y, mass, radians} * @param {Array} bodies - Array of ALL mass-bearing objects: * - World-space bodies: {x, y, mass} (engines, already positioned) * - Local-space bodies: {x, y, mass, isLocal: true} (cargo, needs rotation) * Local-space bodies will be rotated by mainBody.radians * @returns {Object} The center of mass position {x, y} * * @example * const ship = {x: 100, y: 100, mass: 10, radians: 0} * const bodies = [ * {x: 90, y: 100, mass: 1}, // Engine left (world space) * {x: 110, y: 100, mass: 1}, // Engine right (world space) * {x: 0, y: -50, mass: 20, isLocal: true} // Heavy cargo above ship (local space) * ] * * const com = calculateCenterOfMass(ship, bodies) * // Result: COM shifts upward because of heavy cargo * // com.y will be less than 100 (upward in screen coords) * * @example * // Balanced system - COM at ship center * const ship = {x: 100, y: 100, mass: 10, radians: 0} * const bodies = [ * {x: 90, y: 100, mass: 1}, // Engine left * {x: 110, y: 100, mass: 1} // Engine right * ] * const com = calculateCenterOfMass(ship, bodies) * // Result: com ≈ {x: 100, y: 100} (near ship center) */ function calculateCenterOfMass(mainBody, bodies = []) { let totalMass = mainBody.mass let x = mainBody.x * mainBody.mass let y = mainBody.y * mainBody.mass // Pre-calculate rotation for local-space bodies const cos = Math.cos(mainBody.radians || 0) const sin = Math.sin(mainBody.radians || 0) // Add contributions from all bodies for (let body of bodies) { let worldX, worldY // Check if this is a local-space body (needs rotation) if (body.isLocal) { // Rotate from local to world coordinates const rotatedX = body.x * cos - body.y * sin const rotatedY = body.x * sin + body.y * cos worldX = mainBody.x + rotatedX worldY = mainBody.y + rotatedY } else { // Already in world space worldX = body.x worldY = body.y } totalMass += body.mass x += worldX * body.mass y += worldY * body.mass } return { x: x / totalMass, y: y / totalMass } } /** * Apply forces from engines to a rigid body's linear and angular velocity. * * This function calculates both: * 1. LINEAR FORCES: Push the body in the direction engines are pointing * 2. TORQUE: Rotate the body when engines fire off-center from COM * * PHYSICS BREAKDOWN: * - Each engine produces a force vector in its pointing direction * - Linear forces sum up: F_total = Σ(F_x, F_y) * - Acceleration: a = F / m (Newton's 2nd law) * - Torque from each engine: τ = r × F (cross product) * - Angular acceleration: α = τ / I (rotational analog of F = ma) * * WHY TORQUE MATTERS: * An engine firing directly through COM produces no rotation (distance = 0). * An engine firing perpendicular to COM produces maximum rotation. * An engine aligned with COM produces no rotation (force parallel to lever arm). * * @param {Object} mainBody - The main rigid body with {vx, vy, rotationSpeed} * @param {Array} engines - Array of engines with {x, y, radians, force} properties * @param {Object} centerOfMass - The pivot point {x, y} * @param {number} totalMass - Total mass of the system * @param {number} momentOfInertia - Rotational inertia (I) * @returns {Object} The force and torque applied: {fx, fy, torque} * * @example * const ship = {vx: 0, vy: 0, rotationSpeed: 0} * const engines = [ * {x: 100, y: 90, radians: -Math.PI/2, force: 1}, // Top engine pointing up * {x: 100, y: 110, radians: -Math.PI/2, force: 1} // Bottom engine pointing up * ] * const com = {x: 100, y: 100} * * const result = applyEngineForces(ship, engines, com, 12, 250) * // Both engines push up → linear force * // Engines on opposite sides → no net torque (balanced) * * @example * // Unbalanced case - creates rotation * const engines = [ * {x: 100, y: 90, radians: -Math.PI/2, force: 2}, // Top engine (strong) * {x: 100, y: 110, radians: -Math.PI/2, force: 1} // Bottom engine (weak) * ] * // Top engine creates clockwise torque, bottom creates counter-clockwise * // Net result: ship rotates while moving up */ function applyEngineForces(mainBody, engines, centerOfMass, totalMass, momentOfInertia) { let fxTotal = 0 let fyTotal = 0 let torqueTotal = 0 for (let engine of engines) { const force = engine.force // Calculate force vector in the direction the engine is pointing const fx = Math.cos(engine.radians) * force const fy = Math.sin(engine.radians) * force fxTotal += fx fyTotal += fy // Calculate torque (rotational force) around center of mass const dx = engine.x - centerOfMass.x const dy = engine.y - centerOfMass.y // Cross product in 2D: torque = r × F // dx * fy gives the torque from horizontal distance and vertical force // dy * fx gives the torque from vertical distance and horizontal force torqueTotal += dx * fy - dy * fx } // Apply forces to ship's linear velocity if (totalMass > 0) { mainBody.vx += fxTotal / totalMass mainBody.vy += fyTotal / totalMass } // Apply torque to ship's angular velocity if (momentOfInertia > 0) { mainBody.rotationSpeed += torqueTotal / momentOfInertia } return { fx: fxTotal, fy: fyTotal, torque: torqueTotal } } /** * Apply gravity-gradient torque to a rigid body system. * * WHAT IS GRAVITY-GRADIENT TORQUE? * When different parts of an object experience slightly different gravitational forces * (due to being at different positions), this creates a torque that tries to align * the object with the gravity field. * * REAL-WORLD EXAMPLE: * A top-heavy rocket (like a water tower on a thin base) will tip over because the * heavy mass at the top experiences gravity at a different horizontal position than * the center of mass, creating a rotational force. * * WHY IT MATTERS: * - Top-heavy objects are UNSTABLE - they naturally want to flip over * - Bottom-heavy objects are STABLE - gravity pulls them back to upright * - This is why real rockets have heavy engines at the bottom! * * PHYSICS: * For each mass point, we calculate: * - Its horizontal distance from COM (dx) * - The gravitational force on it (mass × gravity) * - The torque: τ = dx × (mass × gravity) * * All these torques sum up to create rotational instability or stability. * * IN THIS SIMULATION: * - Heavy cargo at top → large positive dx → destabilizing torque * - Heavy cargo at bottom → large negative dx → stabilizing torque * - Balanced cargo → near-zero torques → neutral stability * * @param {Object} mainBody - The main rigid body with {rotationSpeed, x, y, mass, radians} * @param {Array} bodies - Array of ALL mass-bearing objects: * - World-space bodies: {x, y, mass} (engines, already positioned) * - Local-space bodies: {x, y, mass, isLocal: true} (cargo, needs rotation) * Local-space bodies will be rotated by mainBody.radians * @param {Object} centerOfMass - The center of mass position {x, y} * @param {number} momentOfInertia - Rotational inertia (I) * @param {number} gravityStrength - Strength of gravity (default: 0.01) * @returns {number} The gravity-induced torque applied * * @example * // Top-heavy ship - UNSTABLE * const ship = {x: 100, y: 100, mass: 10, radians: 0.1, rotationSpeed: 0} // Tilted slightly * const bodies = [ * {x: 90, y: 100, mass: 1}, // Engine left * {x: 110, y: 100, mass: 1}, // Engine right * {x: 0, y: -50, mass: 30, isLocal: true} // Heavy cargo HIGH above center * ] * const com = {x: 100, y: 85} // COM shifted upward * * const torque = applyGravityGradientTorque(ship, bodies, com, 500, 0.01) * // Result: Ship will continue tilting and flip over (destabilizing torque) * * @example * // Bottom-heavy ship - STABLE * const ship = {x: 100, y: 100, mass: 10, radians: 0.1, rotationSpeed: 0} // Tilted slightly * const bodies = [ * {x: 90, y: 100, mass: 1}, // Engine left * {x: 110, y: 100, mass: 1}, // Engine right * {x: 0, y: 50, mass: 30, isLocal: true} // Heavy cargo LOW below center * ] * const com = {x: 100, y: 115} // COM shifted downward * * const torque = applyGravityGradientTorque(ship, bodies, com, 500, 0.01) * // Result: Ship will rotate back to upright (stabilizing torque) */ function applyGravityGradientTorque(mainBody, bodies, centerOfMass, momentOfInertia, gravityStrength = 0.01) { const cos = Math.cos(mainBody.radians || 0) const sin = Math.sin(mainBody.radians || 0) let gravityTorque = 0 // Apply gravity torque from all bodies for (let body of bodies) { let worldX // Check if this is a local-space body (needs rotation) if (body.isLocal) { // Rotate from local to world coordinates const rotatedX = body.x * cos - body.y * sin worldX = mainBody.x + rotatedX } else { // Already in world space worldX = body.x } // Distance from COM (horizontal distance creates torque) const dx = worldX - centerOfMass.x // Torque = r × F (cross product: horizontal distance × vertical force) // Gravity pulls down, so force is in +y direction gravityTorque += dx * body.mass * gravityStrength } // Add contribution from main body const dx = mainBody.x - centerOfMass.x gravityTorque += dx * mainBody.mass * gravityStrength // Apply the gravity-induced torque if (momentOfInertia > 0) { mainBody.rotationSpeed += gravityTorque / momentOfInertia } return gravityTorque } // ============================================================================ // GAMEPAD INTEGRATION // ============================================================================ /** * Apply gamepad controller inputs to engine controls. * * WHAT THIS DOES: * Maps gamepad analog sticks and triggers to engine rotation and thrust: * - Left stick Y-axis: Controls top engine (a) rotation speed * - Right stick Y-axis: Controls bottom engine (b) rotation speed * - Left trigger: Controls top engine (a) thrust * - Right trigger: Controls bottom engine (b) thrust * - Button A: Side thruster (c) burst * - Back button: Reset ship to center * * ROTATION BEHAVIOR: * When a stick is held, the engine rotation accumulates continuously, allowing * the engine to spin 360+ degrees. When released, rotation stops immediately. * This gives precise control over engine orientation. * * DEADZONE: * Small stick movements are ignored (handled by GamepadController) to prevent * controller drift from causing unwanted inputs. * * @param {Object} gamepadState - The gamepad state dictionary with analog values * @param {Array} engines - Array of engine objects [{force, ...}, ...] * @param {Array} engineOffsets - Array of engine offset configs [{radians, rotationSpeed, ...}, ...] * @param {number} triggerForce - Force multiplier for trigger inputs (default: 0.06) * @param {Function} resetCallback - Optional callback function to reset the ship * @returns {Object} Updated input state for debugging: {rotation: [a, b], thrust: [a, b, c]} * * @example * const gp = gamepadController.state * const engines = [engineA, engineB, engineC] * const offsets = [ * {radians: 0.1, rotationSpeed: 0}, * {radians: -0.1, rotationSpeed: 0}, * {radians: 0, rotationSpeed: 0} * ] * * const inputs = applyGamepadControls(gp, engines, offsets, 0.06, () => resetShip()) * // Stick up → engine rotates, trigger → thrust applied * // inputs = {rotation: [0.05, -0.03], thrust: [0.12, 0.08, 0]} */ function applyGamepadControls(gamepadState, engines, engineOffsets, triggerForce = 0.06, resetCallback = null) { const gp = gamepadState // Back button resets the ship to center with zero velocity if (gp.buttonBack && resetCallback) { resetCallback() return {rotation: [0, 0], thrust: [0, 0, 0]} } // Left stick Y-axis controls engine 'a' rotation speed (top engine) // Negative Y is up on stick, so we negate for intuitive control // When stick is held, rotation accumulates; when released, rotation stops const leftStickInput = -gp.leftStickY if (Math.abs(leftStickInput) > 0) { // Apply rotation speed based on stick deflection engineOffsets[0].rotationSpeed = leftStickInput * 0.05 // Rotation speed multiplier } else { // Stop rotation when stick is released engineOffsets[0].rotationSpeed = 0 } // Accumulate rotation engineOffsets[0].radians += engineOffsets[0].rotationSpeed // Right stick Y-axis controls engine 'b' rotation speed (bottom engine) const rightStickInput = -gp.rightStickY if (Math.abs(rightStickInput) > 0) { engineOffsets[1].rotationSpeed = rightStickInput * 0.05 } else { engineOffsets[1].rotationSpeed = 0 } // Accumulate rotation engineOffsets[1].radians += engineOffsets[1].rotationSpeed // Left trigger controls engine 'a' power (left engine) let thrustA = 0, thrustB = 0, thrustC = 0 if (gp.leftTrigger > 0) { thrustA = gp.leftTrigger * triggerForce engines[1].force += thrustA } // Right trigger controls engine 'b' power (bottom engine) if (gp.rightTrigger > 0) { thrustB = gp.rightTrigger * triggerForce engines[2].force += thrustB } // Optional: Button A for engine 'c' (top thruster) if (gp.buttonA) { thrustC = triggerForce engines[0].force += thrustC } return { rotation: [engineOffsets[0].rotationSpeed, engineOffsets[1].rotationSpeed], thrust: [thrustA, thrustB, thrustC] } } /** * Update rigid body physics for a ship with coupled engines. * * WHAT THIS DOES: * This is the main physics simulation loop that: * 1. Updates ship rotation around its center of mass * 2. Applies engine forces (linear thrust and torque) * 3. Applies gravity-gradient torque (top-heavy instability) * 4. Moves the ship through space * 5. Decays engine forces over time * * THE CENTER OF MASS CHALLENGE: * As the ship rotates, the center of mass (COM) position changes relative to * the ship's reference point. To keep rotation smooth and physically correct, * we must: * - Calculate COM before rotation * - Apply rotation to ship * - Calculate COM after rotation * - Adjust ship position so COM stays in the same world position * * This prevents the ship from "wobbling" as it rotates. * * OPTIMIZATION: * COM and moment of inertia are calculated only 3 times per frame: * - Once before rotation (to track COM offset) * - Once after rotation (to correct ship position) * - Once for physics calculations (forces and torques) * * STANDALONE DESIGN: * This function uses the standalone physics functions directly (calculateCenterOfMass, * calculateMomentOfInertia, applyEngineForces, applyGravityGradientTorque) making it * completely independent and reusable without requiring class method wrappers. * * @param {Object} ship - Main rigid body {x, y, vx, vy, radians, rotationSpeed, mass} * @param {Array} engines - Array of engines with {x, y, radians, force, mass} * @param {Array} massPoints - Additional mass points [{x, y, mass}] in local space * @param {Object} options - Configuration options * @param {Function} options.updateEnginePositions - Callback to update engine visual positions * @param {Function} options.addMotion - Callback to apply velocity (default: simple addition) * @param {number} options.gravityStrength - Gravity acceleration (default: 0.01) * @param {number} options.forceDecay - Engine force decay factor (default: 0.9) * @param {number} options.speed - Movement speed multiplier (default: 1) * @returns {Object} Physics state for debugging: {com: {x, y}, I, forces, torques} * * @example * // Minimal usage - only updateEnginePositions callback required * const result = updateRigidBodyPhysics(ship, engines, massPoints, { * updateEnginePositions: updateEngineCallback * }) */ function updateRigidBodyPhysics(ship, engines, massPoints, options = {}) { const { updateEnginePositions, addMotion = (ship, speed = 1) => { ship.x += ship.vx * speed ship.y += ship.vy * speed }, gravityStrength = 0.01, forceDecay = 0.9, speed = 1 } = options // Helper: Combine all bodies for physics calculations const getAllBodies = () => [ ...engines, // World-space (already positioned) ...massPoints.map(mp => ({...mp, isLocal: true})) // Local-space (need rotation) ] // STEP 1: Calculate COM before rotation (to track world position) const comBefore = calculateCenterOfMass(ship, getAllBodies()) // STEP 2: Apply rotation to ship ship.radians += ship.rotationSpeed ship.rotation = ship.radians * 180 / Math.PI // STEP 3: Update engine positions based on new ship orientation updateEnginePositions() // STEP 4: Calculate COM after rotation (with new engine positions) const comAfter = calculateCenterOfMass(ship, getAllBodies()) // STEP 5: Adjust ship position so COM stays in same world position // This keeps the ship rotating around its true center of mass // We need to move the ship by the amount the COM moved const comDeltaX = comAfter.x - comBefore.x const comDeltaY = comAfter.y - comBefore.y ship.x -= comDeltaX ship.y -= comDeltaY // STEP 6: Re-update engine positions with corrected ship position updateEnginePositions() // STEP 7: Calculate final COM and moment of inertia for physics const allBodies = getAllBodies() const com = calculateCenterOfMass(ship, allBodies) const I = calculateMomentOfInertia(com, ship, allBodies) // STEP 8: Apply engine forces (linear thrust + torque) const totalMass = calculateTotalMass(ship, allBodies) const forceResult = applyEngineForces(ship, engines, com, totalMass, I) // STEP 9: Apply gravity-gradient torque (top-heavy = unstable) const gravityTorque = applyGravityGradientTorque(ship, allBodies, com, I, gravityStrength) // STEP 10: Apply linear gravity to ship ship.vy += gravityStrength // STEP 11: Move ship based on velocity addMotion(ship, speed) // STEP 12: Decay engine forces over time engines.forEach(e => e.force *= forceDecay) return { com: com, momentOfInertia: I, forces: forceResult, gravityTorque: gravityTorque } } // ============================================================================ // SHIP CLASS // ============================================================================ /** * Ship class representing a rigid body with coupled engines. * * This extends Point to create a physics-enabled ship that: * - Has mass and rotational inertia * - Rotates around its center of mass * - Responds to engine forces and gravity * - Manages attached engines */ class Ship extends Point { constructor(config = {}) { super(config) // Physics properties this.vx = config.vx || 0 this.vy = config.vy || 0 this.radians = config.radians || -Math.PI/2 // Default: pointing up this.rotationSpeed = config.rotationSpeed || 0 this.mass = config.mass || 10 this.radius = config.radius || 5 // Set rotation in degrees for Point compatibility this.rotation = this.radians * 180 / Math.PI // Engine management this.engines = [] this.engineOffsets = [] } /** * Add an engine to the ship using relative (local-space) position. * * The engine Point's x, y, and rotation are treated as RELATIVE to the ship: * - x, y: Offset from ship's center in local coordinates * - rotation: Rotation relative to ship's forward direction (in degrees) * * The engine will be positioned in world space and moved with the ship. * * @param {Point} enginePoint - The Point object with relative position (x, y, rotation) * @param {number} [mass=1] - Engine mass (default: 1) * @returns {Ship} Returns this for method chaining * * @example * const ship = new Ship({ x: 200, y: 225, radians: -Math.PI/2 }) * const engineA = new Point({ x: 0, y: -25, rotation: 0, radius: 10 }) * * ship.addEngine(engineA, 1) // Engine at top, pointing same direction as ship */ addEngine(enginePoint, mass = 1) { // Ensure engine has required physics properties enginePoint.mass = mass enginePoint.force = enginePoint.force || 0 // Convert rotation from degrees to radians if needed const engineRadians = enginePoint.radians || (enginePoint.rotation * Math.PI / 180) // Store the RELATIVE position (already in local space) const engineOffset = { x: enginePoint.x, y: enginePoint.y, radians: engineRadians, rotationSpeed: 0 } // Transform engine to world space initially const cos = Math.cos(this.radians) const sin = Math.sin(this.radians) const rotatedX = enginePoint.x * cos - enginePoint.y * sin const rotatedY = enginePoint.x * sin + enginePoint.y * cos enginePoint.x = this.x + rotatedX enginePoint.y = this.y + rotatedY enginePoint.radians = this.radians + engineRadians enginePoint.rotation = enginePoint.radians * 180 / Math.PI this.engines.push(enginePoint) this.engineOffsets.push(engineOffset) return this // Allow chaining } /** * Update engine positions based on ship's current position and rotation. * * This transforms each engine from local space (relative to ship) to world space * by applying rotation and translation. Should be called whenever the ship moves * or rotates. */ updateEnginePositions() { const cos = Math.cos(this.radians) const sin = Math.sin(this.radians) this.engines.forEach((engine, i) => { const offset = this.engineOffsets[i] // Rotate the offset by the ship's current rotation const rotatedX = offset.x * cos - offset.y * sin const rotatedY = offset.x * sin + offset.y * cos // Position engine relative to ship engine.x = this.x + rotatedX engine.y = this.y + rotatedY // Sync engine rotation with ship + local offset engine.radians = this.radians + offset.radians engine.rotation = (engine.radians * 180 / Math.PI) }) } /** * Reset ship to a position with zero velocity and rotation. * * This resets all physics state including: * - Position (x, y) * - Velocity (vx, vy) * - Rotation (radians, rotationSpeed) * - Engine forces * - Engine rotation offsets to initial values * * @param {number} x - X position to reset to * @param {number} y - Y position to reset to * @param {number} [radians=-Math.PI/2] - Rotation in radians (default: pointing up) * @returns {Ship} Returns this for method chaining */ reset(x, y, radians = -Math.PI / 2) { // Reset position and velocity this.x = x this.y = y this.vx = 0 this.vy = 0 // Reset rotation this.radians = radians this.rotation = radians * 180 / Math.PI this.rotationSpeed = 0 // Reset engine forces this.engines.forEach(e => e.force = 0) // Reset engine offsets to their stored initial values // (Keep the original offset positions, just reset rotation deltas) this.engineOffsets.forEach(offset => { offset.rotationSpeed = 0 // Note: offset.radians is preserved as the initial engine angle }) // Update engine positions to match new ship state this.updateEnginePositions() return this } /** * Draw the ship and its engines to the canvas. * * This renders: * - Ship center point (green indicator) * - All engine points with indicators * - Lines connecting engines to ship center * - Optional: Center of mass indicator * - Optional: Mass points (cargo, fuel tanks, etc.) * - Optional: Lines connecting engines to form rigid body outline * * @param {CanvasRenderingContext2D} ctx - The canvas 2D context * @param {Object} [options] - Drawing options * @param {string} [options.shipColor='#00ff00'] - Color for ship center * @param {string} [options.engineColor] - Default color for engines (uses engine defaults if not set) * @param {string} [options.lineColor='purple'] - Color for lines connecting engines to ship * @param {boolean} [options.drawOutline=false] - Whether to draw rigid body outline * @param {string} [options.outlineColor='#ffffff44'] - Color for rigid body outline * @param {Object} [options.com] - Center of mass position {x, y} (if provided, will be drawn) * @param {string} [options.comColor='#ff0000'] - Color for center of mass indicator * @param {number} [options.comRadius=8] - Radius of center of mass circle * @param {Array} [options.massPoints] - Array of mass points to visualize [{x, y, mass}, ...] * @param {string} [options.massPointColor='#ffff00'] - Color for mass point indicators */ drawShip(ctx, options = {}) { const { shipColor = '#00ff00', engineColor, lineColor = 'purple', drawOutline = false, outlineColor = '#ffffff44', com, comColor = '#ff0000', comRadius = 8, massPoints, massPointColor = '#ffff00' } = options // Draw center of mass if provided if (com) { ctx.fillStyle = comColor ctx.beginPath() ctx.arc(com.x, com.y, comRadius, 0, Math.PI * 2) ctx.fill() } // Draw mass points if provided (visualize payload/fuel tanks) if (massPoints && massPoints.length > 0) { const cos = Math.cos(this.radians) const sin = Math.sin(this.radians) ctx.fillStyle = massPointColor for (let massPoint of massPoints) { const rotatedX = massPoint.x * cos - massPoint.y * sin const rotatedY = massPoint.x * sin + massPoint.y * cos const worldX = this.x + rotatedX const worldY = this.y + rotatedY // Size based on mass const radius = Math.sqrt(massPoint.mass) * 2 ctx.beginPath() ctx.arc(worldX, worldY, radius, 0, Math.PI * 2) ctx.fill() } } // Draw the ship center (green - this is the reference point, not COM) this.pen.indicator(ctx, shipColor) // Draw the engines this.engines.forEach((engine, i) => { // Use custom color or let engine use its default if (engineColor) { engine.pen.indicator(ctx, engineColor) } else { // Use different colors for different engines (for visual distinction) const colors = [undefined, undefined, '#ff00ff'] // Purple for third engine engine.pen.indicator(ctx, colors[i]) } // Draw line from engine to ship center engine.pen.line(ctx, this, lineColor) }) // Optional: Draw lines connecting engines to show rigid body if (drawOutline && this.engines.length > 2) { ctx.strokeStyle = outlineColor ctx.lineWidth = 2 ctx.beginPath() ctx.moveTo(this.engines[0].x, this.engines[0].y) for (let i = 1; i < this.engines.length; i++) { ctx.lineTo(this.engines[i].x, this.engines[i].y) } ctx.lineTo(this.engines[0].x, this.engines[0].y) ctx.stroke() } } } // Gamepad integration /** * 1. Capture first gamepad * 2. Engine `a` and `b` rotation to the thumb sticks * 3. Engine `a` and `b` power to the triggers */ class GamepadController { constructor() { this.connected = false this.gamepad = null this.deadzone = 0.15 // Ignore small stick movements // Gamepad state dictionary this.state = { leftStickX: 0, // Left stick horizontal (-1 to 1) leftStickY: 0, // Left stick vertical (-1 to 1) rightStickX: 0, // Right stick horizontal (-1 to 1) rightStickY: 0, // Right stick vertical (-1 to 1) leftTrigger: 0, // Left trigger (0 to 1) rightTrigger: 0, // Right trigger (0 to 1) buttonA: false, buttonB: false, buttonBack: false, // Back/Select button (button 8) buttonBackPressed: false // Track button press for edge detection } this.setupGamepadListeners() } setupGamepadListeners() { window.addEventListener('gamepadconnected', (e) => { console.log('Gamepad connected:', e.gamepad.id) this.gamepad = e.gamepad this.connected = true }) window.addEventListener('gamepaddisconnected', (e) => { console.log('Gamepad disconnected') this.connected = false this.gamepad = null this.resetState() }) } applyDeadzone(value) { /* Apply deadzone to analog inputs to prevent drift */ return Math.abs(value) < this.deadzone ? 0 : value } update() { /* Poll gamepad state and update the state dictionary */ if (!this.connected) return // Get fresh gamepad state (required for polling API) const gamepads = navigator.getGamepads() this.gamepad = gamepads[0] || gamepads[1] || gamepads[2] || gamepads[3] if (!this.gamepad) { this.connected = false return } // Update analog sticks (standard mapping) this.state.leftStickX = this.applyDeadzone(this.gamepad.axes[0]) this.state.leftStickY = this.applyDeadzone(this.gamepad.axes[1]) this.state.rightStickX = this.applyDeadzone(this.gamepad.axes[2]) this.state.rightStickY = this.applyDeadzone(this.gamepad.axes[3]) // Update triggers (buttons 6 and 7 on most controllers) // Some controllers report triggers as axes, others as buttons if (this.gamepad.buttons[7]) { this.state.leftTrigger = this.gamepad.buttons[7].value } if (this.gamepad.buttons[6]) { this.state.rightTrigger = this.gamepad.buttons[6].value } // Update face buttons this.state.buttonA = this.gamepad.buttons[0]?.pressed || false this.state.buttonB = this.gamepad.buttons[1]?.pressed || false // Update back button (button 8 on most controllers - "Select" or "Back") const backPressed = this.gamepad.buttons[8]?.pressed || false // Edge detection: only trigger once per button press if (backPressed && !this.state.buttonBackPressed) { this.state.buttonBack = true } else { this.state.buttonBack = false } this.state.buttonBackPressed = backPressed } resetState() { /* Reset all state values to neutral */ this.state.leftStickX = 0 this.state.leftStickY = 0 this.state.rightStickX = 0 this.state.rightStickY = 0 this.state.leftTrigger = 0 this.state.rightTrigger = 0 this.state.buttonA = false this.state.buttonB = false this.state.buttonBack = false this.state.buttonBackPressed = false } } class MainStage extends Stage { canvas = 'playspace' mounted() { console.log('mounted') // this.screenwrap = new ScreenWrap this.mouse.position.vy = this.mouse.position.vx = 0 // Create the ship as a Ship instance this.ship = new Ship({ x: 200, y: 225, // midpoint between a and b vx: 0, vy: 0, radians: -Math.PI/2, // -90 degrees (pointing up) rotationSpeed: 0, mass: 10, radius: 5 }) // Create engine points with RELATIVE positions (local space) // These will be transformed to world space by Ship.addEngine() this.a = new Point({ x: 50, y: 0, vx: 0, vy: 0, rotation: 0, radius: 10 }) // Top engine (25 units above ship center) this.b = new Point({ x: 0, y: -20, vx: 0, vy: 0, rotation: 0, radius: 10 }) // Left engine (25 units left of ship center) this.c = new Point({ x: 0, y: 20, vx: 0, vy: 0, rotation: 0, radius: 10 }) // Right engine (25 units right of ship center) // Add engines to ship - addEngine transforms from relative to world space this.ship .addEngine(this.a, 1) // Top engine .addEngine(this.b, 1) // Left engine .addEngine(this.c, 1) // Right engine ; // Convenience references (for backward compatibility with existing code) this.engines = this.ship.engines this.engineOffsets = this.ship.engineOffsets // Add additional mass points to shift center of mass // These are "virtual" mass points that don't render but affect physics // For a top-heavy VTOL: put heavy mass at the top this.massPoints = [ { x: 60, y: 0, mass: 20 }, // Heavy payload at the top (15 mass units) { x: 30, y: 0, mass: 8 }, // Additional mass slightly lower // , { x: 0, y: 40, mass: 20 } // Light fuel tank at bottom (uncomment to test) ] this.asteroids = new PointList( [250, 200] , [200, 250] , [200, 350] ).cast() this.asteroids.update({vx: 0, vy: 0, mass: 1}) this.keyboard.onKeydown(KC.UP, this.onUpKeydown.bind(this)) this.keyboard.onKeyup(KC.UP, this.onUpKeyup.bind(this)) this.keyboard.onKeydown(KC.LEFT, this.onLeftKeydown.bind(this)) this.keyboard.onKeydown(KC.RIGHT, this.onRightKeydown.bind(this)) this.keyboard.onKeydown(KC.DOWN, this.onDownKeydown.bind(this)) this.keyboard.onKeyup(KC.DOWN, this.onDownKeyup.bind(this)) this.power = 0 this.powerDown = false this.triggerForce = 0.26 this.dragging.add(...this.asteroids) // Initialize gamepad controller this.gamepad = new GamepadController() } applyGamepadControls() { /* Apply gamepad inputs to engine controls - now a thin wrapper */ if (!this.gamepad.connected) return return applyGamepadControls( this.gamepad.state, this.engines, this.ship.engineOffsets, this.triggerForce, () => this.resetShip() ) } resetShip() { /* Reset ship to center position - delegates to Ship.reset() */ this.ship.reset(this.center.x, this.center.y, -Math.PI / 2) console.log('Ship reset to center') } addMotion(point, speed=1) { /* Because we're in a zero-gravity space, the velocity is simply _added_ to the current XY, pushing the point in the direction of forced. */ point.x += point.vx point.y += point.vy return } performPower(){ if(this.powerDown === true) { /* Applied here, bcause a spaceship only applied force when the thottle is on.*/ this.impart(.06) return } this.power = 0 if(this.reverseDown === true) { this.impart(-.01) } } onUpKeydown(ev) { /* On keydown we add some to the throttle. As keydown first repeatedly, this will raise the power until keyup */ this.powerDown = true } onUpKeyup(ev) { /* Reset the throttle */ this.powerDown = false } impart(speed=1, direction=new Point(1,0)){ /* Impart _speed_ for momentum relative to the direction the the point. For example - pointing _right_ and applying the _{1,0}_ direction (denoting forward) will push the point further right, applying _{0, 1}_ pushes the point _left_ relative to its direction. Or to rephase, imagine a engine on the back of the point - pushing _forward_. */ // Apply force to each engine individually this.engines.forEach(engine => { engine.force += speed }) } onDownKeydown(ev) { this.reverseDown = true } onDownKeyup(ev) { this.reverseDown = false } onLeftKeydown(ev) { /* Rotate the ship as if spinning on the spot. This rotation Speed is applied constantly in `this.updateShip` */ if(ev.shiftKey || ev.ctrlKey) { /* Perform a _crab_ left - apply differential thrust */ this.a.force += 0.02 this.b.force -= 0.02 return } // Apply force to engine 'a' to create rotation // Positive force pushes in the direction the engine is pointing this.a.force += this.triggerForce } onRightKeydown(ev) { /* Rotate the ship as if spinning on the spot. This rotation Speed is applied constantly in `this.updateShip` */ if(ev.shiftKey || ev.ctrlKey) { /* Perform a _crab_ right - apply differential thrust */ this.a.force -= 0.02 this.b.force += 0.02 return } // Apply force to engine 'b' to create rotation // Positive force pushes in the direction the engine is pointing this.b.force += this.triggerForce } updateShip(){ // Update gamepad state and apply controls this.gamepad.update() this.applyGamepadControls() // Run the main physics simulation const result = updateRigidBodyPhysics(this.ship, this.ship.engines, this.massPoints, { updateEnginePositions: () => this.ship.updateEnginePositions(), addMotion: (ship, speed) => this.addMotion(ship, speed), gravityStrength: 0.04, forceDecay: 0.9, speed: this.speed }) // Screen wrap this.screenWrap.perform(this.ship) // Apply throttle/reverse this.performPower() return result } draw(ctx) { this.clear(ctx) let shipData = this.updateShip() this.asteroids.pen.indicators(ctx) // Draw the ship with COM and mass points this.ship.drawShip(ctx, { shipColor: '#00ff00', lineColor: 'purple', drawOutline: false, // Set to true to see rigid body outline com: shipData.com, // Pass center of mass for visualization massPoints: this.massPoints // Pass mass points for visualization }) // Draw gamepad status indicator if (this.gamepad.connected) { ctx.fillStyle = '#00ff00' ctx.font = '14px monospace' ctx.fillText('🎮 Gamepad Connected', 10, 20) // Show trigger values const gp = this.gamepad.state ctx.fillStyle = '#ffffff' ctx.fillText(`L: ${gp.leftTrigger.toFixed(2)} R: ${gp.rightTrigger.toFixed(2)}`, 10, 40) ctx.fillText(`LS: ${gp.leftStickY.toFixed(2)} RS: ${gp.rightStickY.toFixed(2)}`, 10, 60) } // Draw lines connecting engines to show rigid body // ctx.strokeStyle = '#ffffff44' // ctx.lineWidth = 2 // ctx.beginPath() // ctx.moveTo(this.a.x, this.a.y) // ctx.lineTo(this.b.x, this.b.y) // ctx.lineTo(this.c.x, this.c.y) // ctx.lineTo(this.a.x, this.a.y) // ctx.stroke() } } stage = MainStage.go()
Run
Meta Data
imports ()
files ('../point_src/core/head.js', '../point_src/pointpen.js', '../point_src/pointdraw.js', '../point_src/extras.js', '../point_src/math.js', '../point_src/point-content.js', '../point_src/stage.js', 'point', 'dragging', 'stroke', '../point_src/distances.js', 'pointlist', '../point_src/events.js', '../point_src/automouse.js', '../point_src/relative.js', '../point_src/keyboard.js', '../point_src/constrain-distance.js', '../point_src/screenwrap.js')
unused_keys ('title',)
unknown_keys ('categories',)
categories ['relative']
filepath_exists True
path spaceship-vectors-controller.js
filepath spaceship-vectors-controller.js
clean_files ('../point_src/core/head.js', '../point_src/pointpen.js', '../point_src/pointdraw.js', '../point_src/extras.js', '../point_src/math.js', '../point_src/compass.js', '../point_src/center.js', '../point_src/point-content.js', '../point_src/stage-resize.js', '../point_src/functions/resolve.js', '../point_src/stage.js', '../point_src/relative-xy.js', '../point_src/pointcast.js', '../point_src/point.js', '../point_src/functions/clamp.js', '../point_src/distances.js', '../point_src/protractor.js', '../point_src/text/beta.js', '../point_src/dragging.js', '../point_src/setunset.js', '../point_src/stroke.js', '../point_src/pointlistdraw.js', '../point_src/pointlistgradient.js', '../point_src/pointlistshape.js', '../point_src/pointlistgenerator.js', '../point_src/unpack.js', '../point_src/pointlist.js', '../point_src/pointlistpen.js', '../point_src/events.js', '../point_src/automouse.js', '../point_src/relative.js', '../point_src/keyboard.js', '../point_src/constrain-distance.js', '../point_src/screenwrap.js')
markdown {'html': "<p>Coupled Vector Engines - Rigid Body Physics Simulation</p>\n<p>This demonstrates a proper 2D rigid body physics system where multiple engines\nare coupled together to form a single ship. Key physics concepts:</p>\n<ol>\n<li>CENTER OF MASS (COM): The true pivot point calculated from all masses</li>\n<li>MOMENT OF INERTIA: Resistance to rotation based on mass distribution</li>\n<li>TORQUE: Rotational force = distance × force (cross product)</li>\n<li>GRAVITY-GRADIENT TORQUE: Instability from mass distribution in gravity field</li>\n</ol>\n<p>Physics Improvements (v2):\n- Fixed: Ship now rotates around actual COM, not reference point\n- Fixed: Gravity strength consistent between linear and rotational\n- Fixed: Multiple COM calculations optimized to single calculation per frame\n- Added: getTotalMass() helper to avoid repeated calculations\n- Cleaned: Removed unused helper functions and variables</p>\n<p>Physics Improvements (v3):\n- Refactored: Extracted calculateMomentOfInertia() as standalone function\n- Documented: Complete API reference in docs/moment-of-inertia-api.md\n- Educational: Added beginner's guide in docs/moment-of-inertia-explained.md\n- Simplified: Unified body list API - all bodies treated equally, distinguished by isLocal flag\n- Extracted: calculateTotalMass(), calculateCenterOfMass(), applyEngineForces(), applyGravityGradientTorque(), applyGamepadControls(), and updateRigidBodyPhysics() as reusable standalone functions</p>", 'content': "categories: relative\nfiles:\n ../point_src/core/head.js\n ../point_src/pointpen.js\n ../point_src/pointdraw.js\n ../point_src/extras.js\n ../point_src/math.js\n ../point_src/point-content.js\n ../point_src/stage.js\n point\n dragging\n stroke\n ../point_src/distances.js\n pointlist\n ../point_src/events.js\n ../point_src/automouse.js\n ../point_src/relative.js\n ../point_src/keyboard.js\n ../point_src/constrain-distance.js\n ../point_src/screenwrap.js\n\nCoupled Vector Engines - Rigid Body Physics Simulation\n\nThis demonstrates a proper 2D rigid body physics system where multiple engines\nare coupled together to form a single ship. Key physics concepts:\n\n1. CENTER OF MASS (COM): The true pivot point calculated from all masses\n2. MOMENT OF INERTIA: Resistance to rotation based on mass distribution\n3. TORQUE: Rotational force = distance × force (cross product)\n4. GRAVITY-GRADIENT TORQUE: Instability from mass distribution in gravity field\n\nPhysics Improvements (v2):\n- Fixed: Ship now rotates around actual COM, not reference point\n- Fixed: Gravity strength consistent between linear and rotational\n- Fixed: Multiple COM calculations optimized to single calculation per frame\n- Added: getTotalMass() helper to avoid repeated calculations\n- Cleaned: Removed unused helper functions and variables\n\nPhysics Improvements (v3):\n- Refactored: Extracted calculateMomentOfInertia() as standalone function\n- Documented: Complete API reference in docs/moment-of-inertia-api.md\n- Educational: Added beginner's guide in docs/moment-of-inertia-explained.md\n- Simplified: Unified body list API - all bodies treated equally, distinguished by isLocal flag\n- Extracted: calculateTotalMass(), calculateCenterOfMass(), applyEngineForces(), applyGravityGradientTorque(), applyGamepadControls(), and updateRigidBodyPhysics() as reusable standalone functions"}