orbit/js/entities/Ship.js

185 lines
7.2 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;
}
/** Set the destination to fly to (world coordinates). */
setTarget(x, y) {
this.target = { x, y };
}
/** 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));
}
}
}
}