358 lines
15 KiB
JavaScript
358 lines
15 KiB
JavaScript
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));
|
||
}
|
||
}
|
||
}
|
||
}
|