orbit/js/entities/Ship.js

358 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
* — and, with the Shift+click THROTTLE LOCK (thrustToward, the 'thrust'
* state), keeps flying a committed heading until the player steers it
* somewhere else or it runs into an obstacle (the scene stops it there).
*
* 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;
// THROTTLE LOCK (the Shift+click hold — js/scenes/GameScene.js): the
// committed unit heading the ship keeps flying while it is in its
// 'thrust' state — past the original click point and on. Cleared by
// setTarget() / stop() the instant the player steers the ship back.
this.thrustDir = null;
// SHIP STATE — what the ship is doing right now. 'normal' is the
// default: free to fly wherever the player sends it. 'thrust' = the
// Shift+click throttle lock (full-throttle along the committed
// heading, see thrustToward + update below). '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' | 'thrust' | 'mining' (later: more)
this.onStateChange = null; // (next, prev, reason) => void — the scene installs
}
/**
* Change the ship's state ('normal' | 'thrust' | '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 };
this.thrustDir = null; // steering somewhere else ends the Shift+click hold
// 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');
}
/**
* Commit to the heading toward (x, y) and HOLD IT (the Shift+click
* throttle lock): the ship flies that way at full throttle — PAST the
* point and on — for as long as it stays in its 'thrust' state. A plain
* setTarget() flies somewhere else and stops there (ending the hold);
* stop() brakes it dead. Running into a solid or the tether rim ends it
* too — the scene sees the contact and calls stop() (GameScene
* .onPostUpdate). The direction is ship → (x, y); a degenerate point
* (right on the hull) keeps the heading it already has.
*/
thrustToward(x, y) {
const dx = x - this.x;
const dy = y - this.y;
const d = Math.hypot(dx, dy);
let nx;
let ny;
if (d > 0.5) {
nx = dx / d;
ny = dy / d;
} else {
// The ship IS the point: keep the heading it already has.
const h = this.rotation - this.artOffset;
nx = Math.cos(h);
ny = Math.sin(h);
}
this.target = null;
this.thrustDir = { x: nx, y: ny };
this.setState('thrust', 'move');
}
/**
* Stop steering and brake immediately (target AND the throttle-lock
* heading are cleared, velocity zeroed). The ship's STATE is left as-is
* on purpose — the mining hold (GameScene) needs a parked ship that
* stays in its 'mining' state; a scene that ends a hold sets the state
* itself (see the contact stop in GameScene.onPostUpdate).
*/
stop() {
this.target = null;
this.thrustDir = 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;
// THROTTLE LOCK (the Shift+click hold): full throttle along the
// committed heading — no arrival check, no stopping — until the
// player steers the ship somewhere else (setTarget → 'normal' + a
// target, which takes over below), brakes it (stop() clears thrustDir,
// dropping the ship to the coast branch), or it runs into a solid /
// the tether rim (the scene sees the contact and calls stop() —
// GameScene.onPostUpdate).
if (this.state === 'thrust' && this.thrustDir) {
body.acceleration.set(
this.thrustDir.x * this.thrust,
this.thrustDir.y * this.thrust,
);
// The same drag + hard speed cap as the click-to-fly steering.
body.velocity.scale(Math.max(0, 1 - this.drag * dt));
const v = body.velocity.length();
if (v > this.maxSpeed) body.velocity.scale(this.maxSpeed / v);
// Heading follows the actual direction of motion (so it never yaws
// "backwards" while turning); standing still, it points down the
// committed heading. Sprite rotation = heading + artOffset (the
// sheet art may not face east).
const speed = body.velocity.length();
const wanted =
(speed > 10
? Phaser.Math.Angle.Wrap(Math.atan2(body.velocity.y, body.velocity.x))
: Phaser.Math.Angle.Wrap(
Math.atan2(this.thrustDir.y, this.thrustDir.x),
)) + 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);
return;
}
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));
}
}
}
}