orbit/js/entities/Ship.js

223 lines
9.0 KiB
JavaScript
Raw 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.
*
* 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"
*/
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
// 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;
}
/** 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));
}
}
}
}