Replace procedural ship sprite with spritesheet art and facing offset

- Load `assets/images/ships-player.png` as a Phaser spritesheet via
  `data/ship.json` (`texture`, `frameWidth`, `frameHeight`, `frame`)
- Add `artFacing` config; Ship applies a constant `artOffset` so heading
  math is unchanged while the frame art (facing north) renders correctly
- Scale sprite as `(size * scale) / frameWidth` to preserve the 46 px world
  hull size and collision radius regardless of frame dimensions
- Fall back to the built-in procedural dart when the sheet is missing or
  fails to load, with a console warning in GameScene.create()
- Update ship-behavior tests to account for `artOffset` in heading checks
- Add source art (`ships-player.psd`, landed-ship reference) and a demo
  video; document the new pattern in README and PROJECT_NOTES
This commit is contained in:
Brian Fertig 2026-09-03 21:40:33 -06:00
parent 2cbd8e8962
commit 23fcbe7176
10 changed files with 109 additions and 27 deletions

View File

@ -40,7 +40,8 @@ python3 -m http.server 8080
population, and an `owner` seam reserved for the factions/pirates to come.
The current system's dossier (name, identity, what's there) shows
top-left in the game scene.
- Game screen with a basic top-down ship: **click anywhere to fly there**
- Game screen with a top-down ship (real spritesheet art — frame 0 of
`assets/images/ships-player.png`, see `data/ship.json`): **click anywhere to fly there**
in the current system's open space (system boundaries/jumps come next).
Discovered worlds get a screen-edge arrow + name tag; **clicking the
name tag autopilots the ship there** (it arrives on the keep-out rim,
@ -75,7 +76,7 @@ orbit/
│ ├── builds.json # BUILDING: credits + minerals, ship/planet/station upgrades
│ ├── actionbar.json # the command deck: 6 slots (Research, Build, Ship, ·, ·, Menu)
│ └── naming.json # syllable pools for names
├── assets/images/ # art: planets.png (1024×1024 spritesheet frames)
├── assets/images/ # art: planets.png (1024×1024 frames), ships-player.png (256×256 frames)
├── assets/fonts/ # UI typefaces: Ethnocentric (headers), Centauri (body)
├── lib/ # vendored third-party libs (Phaser 4.2.1)
├── js/

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Binary file not shown.

View File

@ -1,4 +1,10 @@
{
"_art": "texture/frameWidth/frameHeight/frame = the spritesheet (frame 0 = the starter ship, 256×256 frames; the ship is drawn centered in its frame and rotates about that center). artFacing = the direction the art points in its frame — the code applies a constant render offset so heading math stays identical. If the sheet is missing, the built-in procedural dart is drawn instead (it faces east). size = hull length in world px (also its collision 'diameter'); scale multiplies it (1 = as designed).",
"texture": "assets/images/ships-player.png",
"frameWidth": 256,
"frameHeight": 256,
"frame": 0,
"artFacing": "north",
"size": 46,
"color": "#dfe7ff",
"cockpitColor": "#41c7ff",

View File

@ -7,7 +7,9 @@
* loop from js/entities/Ship.js, then asserts the flight feel:
* arrives and stops, respects maxSpeed, tracks its heading, stops facing
* the direction it was traveling, can be re-targeted mid-flight, coasts
* to rest, hard-brakes on stop().
* to rest, hard-brakes on stop(). Heading assertions account for the
* ship's art render offset (ship.artOffset data/ship.json artFacing),
* which is a presentation concern, not flight behavior.
*
* The harness integrates accelerationvelocityposition the way the
* Arcade physics world does (after the scene's update).
@ -131,11 +133,12 @@ const integrate = (ship) => {
// --- Test 2: heading tracks the direction of travel -------------------------
{
const ship = new Ship(scene, 200, 200);
ship.rotation = 2.5; // start facing the wrong way
ship.rotation = 2.5 + (ship.artOffset || 0); // start facing the wrong way (heading space)
ship.setTarget(600, 200); // fly straight right
for (let t = 0; t < 60; t++) { ship.update(t * dt, dt); integrate(ship); }
let hd = wrap(ship.rotation); if (hd > Math.PI) hd -= TAU;
check(`rotates toward direction of travel (rotation=${ship.rotation.toFixed(3)} rad, want ~0)`,
const hd0 = wrap(ship.rotation - (ship.artOffset || 0));
let hd = hd0 > Math.PI ? hd0 - TAU : hd0;
check(`rotates toward direction of travel (heading=${hd.toFixed(3)} rad, want ~0)`,
Math.abs(hd) < 0.3);
}
@ -192,8 +195,8 @@ const integrate = (ship) => {
if (ship.target === null) break;
}
const want = Math.atan2(target.y - 100, target.x - 100);
check(`straight trip: stops facing direction of travel (rot=${ship.rotation.toFixed(3)} rad, want ${want.toFixed(3)})`,
ship.target === null && angDiff(ship.rotation, want) < 0.25);
check(`straight trip: stops facing direction of travel (heading=${wrap(ship.rotation - (ship.artOffset || 0)).toFixed(3)} rad, want ${want.toFixed(3)})`,
ship.target === null && angDiff(ship.rotation - (ship.artOffset || 0), want) < 0.25);
}
// Diagonal trip from rest: must end facing the diagonal it flew.
@ -207,8 +210,8 @@ const integrate = (ship) => {
if (ship.target === null) break;
}
const want = Math.atan2(target.y - 100, target.x - 100);
check(`diagonal trip: stops facing direction of travel (rot=${ship.rotation.toFixed(3)} rad, want ${want.toFixed(3)})`,
ship.target === null && angDiff(ship.rotation, want) < 0.25);
check(`diagonal trip: stops facing direction of travel (heading=${wrap(ship.rotation - (ship.artOffset || 0)).toFixed(3)} rad, want ${want.toFixed(3)})`,
ship.target === null && angDiff(ship.rotation - (ship.artOffset || 0), want) < 0.25);
}
// Fast ship re-targeted close ahead: still ends facing its travel direction.
@ -225,8 +228,8 @@ const integrate = (ship) => {
}
const want = Math.atan2(target.y - 100, target.x - 100);
const d = Math.hypot(ship.x - target.x, ship.y - target.y);
check(`fast re-target: arrives (dist=${d.toFixed(2)}) facing direction of travel (rot=${ship.rotation.toFixed(3)} rad, want ${want.toFixed(3)})`,
ship.target === null && d <= 8.5 && angDiff(ship.rotation, want) < 0.25);
check(`fast re-target: arrives (dist=${d.toFixed(2)}) facing direction of travel (heading=${wrap(ship.rotation - (ship.artOffset || 0)).toFixed(3)} rad, want ${want.toFixed(3)})`,
ship.target === null && d <= 8.5 && angDiff(ship.rotation - (ship.artOffset || 0), want) < 0.25);
}
}

View File

@ -213,6 +213,16 @@ world** — solid, rendered, flyable-to. Rules and seams:
it whenever the camera has scrolled. (MenuButton never bit by this:
the menu camera doesn't move.) Set `scrollFactor(0)` on **every** child
of a screen-fixed UI container — done in `ActionBar.buildSlots`.
- **Ship art** — the starter ship is now a spritesheet frame:
`data/ship.json → texture` (assets/images/ships-player.png, 256×256
frames, `frame` 0), mirroring the planets pattern. The frame art faces
`artFacing` ("north" — the nose is the frame's top edge; the glass
cockpit section is aft, per the art's author); `Ship` applies a
constant render offset (`artOffset`) so heading math is identical and
the ship rotates about the frame center. Sprite
scale = (size×scale)/frameWidth keeps the 46 px world size (and 23 px
collision radius) regardless of frame size. Missing/failed sheet →
built-in procedural dart (facing east), with a console note.
- To upgrade: replace the vendored file + note the version here (and re-check
the quirks above — they may go away).

View File

@ -5,7 +5,16 @@ 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
@ -14,7 +23,19 @@ import { toColor } from '../utils/Color.js';
export class Ship extends Phaser.Physics.Arcade.Sprite {
static TEXTURE_KEY = '__ship';
/** Draws the ship texture once per game (procedural, no assets). */
/** 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;
@ -41,12 +62,20 @@ export class Ship extends Phaser.Physics.Arcade.Sprite {
}
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);
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
@ -55,10 +84,19 @@ export class Ship extends Phaser.Physics.Arcade.Sprite {
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
this.setScale(config.get('ship.scale', 1));
// 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 = (config.get('ship.size', 46) * config.get('ship.scale', 1)) / 2;
this.radius = worldSize / 2;
this.target = null;
}
@ -93,7 +131,9 @@ export class Ship extends Phaser.Physics.Arcade.Sprite {
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.rotation = Phaser.Math.Angle.Wrap(
Math.atan2(body.velocity.y, body.velocity.x) + this.artOffset,
);
}
body.velocity.set(0, 0);
return;
@ -124,11 +164,13 @@ export class Ship extends Phaser.Physics.Arcade.Sprite {
// 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.
const wanted = speed > 10
// (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));
const current = Phaser.Math.Angle.Wrap(this.rotation);
: 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 {

View File

@ -44,6 +44,21 @@ export class GameScene extends Phaser.Scene {
frameHeight: config.get('planets.frameHeight', 1024),
},
);
// The player's ship spritesheet (data/ship.json → texture). If it
// isn't configured or the file is missing, Ship falls back to its
// built-in procedural dart (a console note says so in create()).
const shipTexture = config.get('ship.texture', '');
if (shipTexture) {
this.load.spritesheet(
Ship.TEXTURE_KEY,
shipTexture,
{
frameWidth: config.get('ship.frameWidth', 256),
frameHeight: config.get('ship.frameHeight', 256),
},
);
}
}
create() {
@ -93,6 +108,11 @@ export class GameScene extends Phaser.Scene {
// The ship — a short hop (~150 px, edge-to-edge) from the home world's
// rim, in a seed-derived direction: same galaxy ⇒ same start.
if (config.get('ship.texture', '') && !this.textures.exists(Ship.TEXTURE_KEY)) {
console.warn(
`[orbit] ship spritesheet "${config.get('ship.texture')}" did not load — using the built-in dart.`,
);
}
this.ship = new Ship(this, 0, 0);
this.ship.setDepth(10);
const spawn = this.planet.edgePoint(