import Phaser from '../vendor/phaser.js'; import { config } from '../config/Config.js'; import { toColor } from '../utils/Color.js'; /** * The player's ship: an arcade-physics sprite that flies to wherever you click. * * Art: the spritesheet in data/ship.json → `texture` (frameWidth×frameHeight * frames; `frame` picks which one — frame 0 is the starter ship). The art in * a frame faces `artFacing` ("north" | "east" | "south" | "west") and the * ship rotates about the frame center, so heading math is unchanged — only * a constant render offset (this.artOffset) is applied. If the sheet isn't * configured or didn't load, the built-in procedural dart is drawn instead * (it faces east, offset 0). * * All feel/balance numbers come from data/ship.json: * - size (world px hull length) & scale (multiplier) * - thrust, maxSpeed, drag → how it accelerates and coasts * - rotSpeed → how fast it turns toward its heading (rad/s) * - brakeDistance → where it starts easing off the throttle * - arriveRadius / arriveSpeed → when it considers itself "arrived" * * Base stats (data/ship.json → stats, exposed as this.stats) are the * ship's starting condition before any upgrades: * - hullIntegrity → the hull's max health (damage it can take) * - shields → damage absorbed in front of the hull * - cargoHold → goods capacity * - mineralStorage → minerals capacity * - miningSpeed → multiplier on mining rate (1 = baseline) * * The hold (this.minerals / storageRoom / addMinerals): the minerals * hauled aboard — the mining beam loads it, capped at stats.mineralStorage. */ export class Ship extends Phaser.Physics.Arcade.Sprite { static TEXTURE_KEY = '__ship'; /** World angle (rad) for each direction the sheet art may face. */ static FACING_ANGLE = { north: -Math.PI / 2, east: 0, south: Math.PI / 2, west: Math.PI, }; /** * Ensure a texture exists under TEXTURE_KEY. If the spritesheet was * loaded (GameScene.preload), it's already there and we do nothing. * Otherwise draw the built-in procedural dart — facing EAST (heading 0). */ static ensureTexture(scene) { if (scene.textures.exists(Ship.TEXTURE_KEY)) return; const size = config.get('ship.size', 46); const W = size; const H = Math.round(size * 0.68); const hull = toColor(config.get('ship.color', '#dfe7ff')); const cockpit = toColor(config.get('ship.cockpitColor', '#41c7ff')); // A simple dart, pointing right (angle 0). const g = scene.make.graphics({ add: false }); g.fillStyle(hull, 1); g.beginPath(); g.moveTo(W - 4, H / 2); // nose g.lineTo(4, 3); // top rear g.lineTo(12, H / 2); // tail notch g.lineTo(4, H - 3); // bottom rear g.closePath(); g.fillPath(); g.fillStyle(cockpit, 1); g.fillCircle(Math.round(W - W * 0.3), H / 2, Math.max(3, Math.round(W * 0.085))); g.generateTexture(Ship.TEXTURE_KEY, W, H); g.destroy(); } constructor(scene, x, y) { // Is the spritesheet present (GameScene.preload queued it when // ship.texture is set)? The dart fallback faces EAST; the sheet art // faces ship.artFacing — the offset below reconciles the two. const hasSheet = scene.textures.exists(Ship.TEXTURE_KEY); Ship.ensureTexture(scene); super(scene, x, y, Ship.TEXTURE_KEY, config.get('ship.frame', 0)); scene.add.existing(this); scene.physics.add.existing(this); // Render offset: sprite rotation = heading + artOffset (0 for the dart). const facing = Ship.FACING_ANGLE[config.get('ship.artFacing', 'east')] ?? 0; this.artOffset = hasSheet ? -facing : 0; // Tuning (data/ship.json) ----------------------------------------- this.thrust = config.get('ship.thrust', 900); // px/s^2 this.maxSpeed = config.get('ship.maxSpeed', 480); // px/s this.drag = config.get('ship.drag', 2.4); // 1/s, exponential decay this.rotSpeed = config.get('ship.rotSpeed', 10); // rad/s this.brakeDistance = config.get('ship.brakeDistance', 260); // px this.arriveRadius = config.get('ship.arriveRadius', 8); // px this.arriveSpeed = config.get('ship.arriveSpeed', 50); // px/s // Base stats (data/ship.json → stats) — the ship's starting // condition, before any upgrades. Combat, trading, and mining // systems will read these as capacities. this.stats = { hullIntegrity: config.get('ship.stats.hullIntegrity', 100), shields: config.get('ship.stats.shields', 0), cargoHold: config.get('ship.stats.cargoHold', 100), mineralStorage: config.get('ship.stats.mineralStorage', 250), miningSpeed: config.get('ship.stats.miningSpeed', 1), }; // The hold: minerals aboard right now (0 … stats.mineralStorage). // The mining sequence (js/mining/Mining.js) loads it as the beam // strips the rock; the cap is the base stat above. this.minerals = 0; // World size = size × scale. Sheet frames are frameWidth px wide; the // dart is drawn at `size` px — the sprite scale maps either one onto // the same world size, so both look identical. const worldSize = config.get('ship.size', 46) * config.get('ship.scale', 1); const frameWidth = hasSheet ? Math.max(1, config.get('ship.frameWidth', 256)) : Math.max(1, config.get('ship.size', 46)); this.setScale(worldSize / frameWidth); // Collision radius (half the hull's width, at scale) — planets keep // the ship this far off their rims (see Planet.constrainShip). this.radius = worldSize / 2; this.target = null; // SHIP STATE — what the ship is doing right now. 'normal' is the // default: free to fly wherever the player sends it. 'mining' = the // arm's sequence owns the ship (js/mining/Mining.js drives it; the // beam + ore stream only live while the state holds). More states // will land here (docking, boarding, …). Any change OUT of a state — // another state, or the player MOVING the ship (setTarget, autopilot) // — is signalled via onStateChange, and the scene's handler tears // down whatever that state was doing (ends the mining sequence). this.state = 'normal'; // 'normal' | 'mining' (later: more) this.onStateChange = null; // (next, prev, reason) => void — the scene installs } /** * Change the ship's state ('normal' | 'mining' | …). A no-op when the * state is unchanged. On a real change, onStateChange fires — the scene * ends the mining sequence when the ship leaves 'mining', whichever * state (or movement) took it out. * * @returns {boolean} true when the state actually changed */ setState(next, reason) { if (next === this.state) return false; const prev = this.state; this.state = next; if (typeof this.onStateChange === 'function') { try { this.onStateChange(next, prev, reason); } catch (err) { console.error('[ship] onStateChange handler failed', err); } } return true; } /** Room left in the hold (0 when full). */ storageRoom() { return Math.max(0, this.stats.mineralStorage - this.minerals); } /** * Load up to `n` minerals into the hold (capped at * stats.mineralStorage). Returns the amount actually added — callers * see the excess as wasted when the hold was full. */ addMinerals(n) { const take = Math.min(Math.max(0, Math.floor(n)), this.storageRoom()); if (take > 0) this.minerals += take; return take; } /** * Set the hold directly (the SAVE/LOAD path — js/save/SaveData.js → * captureState writes it, GameScene.applyRestore restores it). Clamped * to [0, stats.mineralStorage] — a record can never overfill the hold. */ setMinerals(n) { this.minerals = Math.max(0, Math.min(this.stats.mineralStorage, Math.round(Number(n) || 0))); } /** Set the destination to fly to (world coordinates). */ setTarget(x, y) { this.target = { x, y }; // Moving the ship ends whatever non-normal state it was in (mining, // and later states): the player's movement always wins, so any state // hands the ship back to 'normal' — the scene's onStateChange handler // finishes the teardown (retracts the beam, releases the arm). this.setState('normal', 'move'); } /** Stop steering and brake immediately. */ stop() { this.target = null; this.body.acceleration.set(0, 0); this.body.velocity.set(0, 0); } update(_time, delta) { const body = this.body; if (!body) return; const dt = Math.min(delta, 64) / 1000; if (this.target) { const dx = this.target.x - this.x; const dy = this.target.y - this.y; const dist = Math.hypot(dx, dy); const speed = body.velocity.length(); // Arrived: close enough and slow enough → stop cleanly. if (dist <= this.arriveRadius && speed <= this.arriveSpeed) { this.target = null; // Clear acceleration too, so the physics step can't re-kick us. body.acceleration.set(0, 0); // Come to rest facing the direction we were traveling. if (speed > 1) { this.rotation = Phaser.Math.Angle.Wrap( Math.atan2(body.velocity.y, body.velocity.x) + this.artOffset, ); } body.velocity.set(0, 0); return; } const nx = dx / dist; const ny = dy / dist; // Braking: with the throttle cut, drag carries the ship a further // speed / drag before it stops. While that still lands short of the // target, keep easing the throttle with distance; once we are too // fast to stop in time, cut the throttle and let drag bleed the // speed off, so we reach the target with little or no speed to spare. const canStopInTime = speed <= this.drag * dist; const throttle = canStopInTime ? Phaser.Math.Clamp(dist / this.brakeDistance, 0, 1) : 0; body.acceleration.set(nx * this.thrust * throttle, ny * this.thrust * throttle); // Gentle drag so we never coast forever. body.velocity.scale(Math.max(0, 1 - this.drag * dt)); // Hard speed cap. const v = body.velocity.length(); if (v > this.maxSpeed) { body.velocity.scale(this.maxSpeed / v); } // Heading: while the ship has speed it follows its actual direction // of motion (so it never flies "backwards" as it brakes); when it // (nearly) stands still it points at the target. Sprite rotation = // heading + artOffset (the sheet art may not face east). const wanted = (speed > 10 ? Phaser.Math.Angle.Wrap(Math.atan2(body.velocity.y, body.velocity.x)) : Phaser.Math.Angle.Wrap(Math.atan2(ny, nx))) + this.artOffset; const current = this.rotation; const step = Math.min(Math.abs(wanted - current), this.rotSpeed * dt); this.rotation = Phaser.Math.Angle.RotateTo(current, wanted, step); } else { body.acceleration.set(0, 0); // Inertial drift, decaying smoothly. if (body.velocity.length() > 0.5) { body.velocity.scale(Math.max(0, 1 - this.drag * dt)); } } } }