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:
parent
2cbd8e8962
commit
23fcbe7176
|
|
@ -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.
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 acceleration→velocity→position 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue