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.
|
population, and an `owner` seam reserved for the factions/pirates to come.
|
||||||
The current system's dossier (name, identity, what's there) shows
|
The current system's dossier (name, identity, what's there) shows
|
||||||
top-left in the game scene.
|
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).
|
in the current system's open space (system boundaries/jumps come next).
|
||||||
Discovered worlds get a screen-edge arrow + name tag; **clicking the
|
Discovered worlds get a screen-edge arrow + name tag; **clicking the
|
||||||
name tag autopilots the ship there** (it arrives on the keep-out rim,
|
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
|
│ ├── builds.json # BUILDING: credits + minerals, ship/planet/station upgrades
|
||||||
│ ├── actionbar.json # the command deck: 6 slots (Research, Build, Ship, ·, ·, Menu)
|
│ ├── actionbar.json # the command deck: 6 slots (Research, Build, Ship, ·, ·, Menu)
|
||||||
│ └── naming.json # syllable pools for names
|
│ └── 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)
|
├── assets/fonts/ # UI typefaces: Ethnocentric (headers), Centauri (body)
|
||||||
├── lib/ # vendored third-party libs (Phaser 4.2.1)
|
├── lib/ # vendored third-party libs (Phaser 4.2.1)
|
||||||
├── js/
|
├── 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,
|
"size": 46,
|
||||||
"color": "#dfe7ff",
|
"color": "#dfe7ff",
|
||||||
"cockpitColor": "#41c7ff",
|
"cockpitColor": "#41c7ff",
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@
|
||||||
* loop from js/entities/Ship.js, then asserts the flight feel:
|
* loop from js/entities/Ship.js, then asserts the flight feel:
|
||||||
* arrives and stops, respects maxSpeed, tracks its heading, stops facing
|
* arrives and stops, respects maxSpeed, tracks its heading, stops facing
|
||||||
* the direction it was traveling, can be re-targeted mid-flight, coasts
|
* 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
|
* The harness integrates acceleration→velocity→position the way the
|
||||||
* Arcade physics world does (after the scene's update).
|
* Arcade physics world does (after the scene's update).
|
||||||
|
|
@ -131,11 +133,12 @@ const integrate = (ship) => {
|
||||||
// --- Test 2: heading tracks the direction of travel -------------------------
|
// --- Test 2: heading tracks the direction of travel -------------------------
|
||||||
{
|
{
|
||||||
const ship = new Ship(scene, 200, 200);
|
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
|
ship.setTarget(600, 200); // fly straight right
|
||||||
for (let t = 0; t < 60; t++) { ship.update(t * dt, dt); integrate(ship); }
|
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;
|
const hd0 = wrap(ship.rotation - (ship.artOffset || 0));
|
||||||
check(`rotates toward direction of travel (rotation=${ship.rotation.toFixed(3)} rad, want ~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);
|
Math.abs(hd) < 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,8 +195,8 @@ const integrate = (ship) => {
|
||||||
if (ship.target === null) break;
|
if (ship.target === null) break;
|
||||||
}
|
}
|
||||||
const want = Math.atan2(target.y - 100, target.x - 100);
|
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)})`,
|
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, want) < 0.25);
|
ship.target === null && angDiff(ship.rotation - (ship.artOffset || 0), want) < 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Diagonal trip from rest: must end facing the diagonal it flew.
|
// Diagonal trip from rest: must end facing the diagonal it flew.
|
||||||
|
|
@ -207,8 +210,8 @@ const integrate = (ship) => {
|
||||||
if (ship.target === null) break;
|
if (ship.target === null) break;
|
||||||
}
|
}
|
||||||
const want = Math.atan2(target.y - 100, target.x - 100);
|
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)})`,
|
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, want) < 0.25);
|
ship.target === null && angDiff(ship.rotation - (ship.artOffset || 0), want) < 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fast ship re-targeted close ahead: still ends facing its travel direction.
|
// 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 want = Math.atan2(target.y - 100, target.x - 100);
|
||||||
const d = Math.hypot(ship.x - target.x, ship.y - target.y);
|
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)})`,
|
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, want) < 0.25);
|
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:
|
it whenever the camera has scrolled. (MenuButton never bit by this:
|
||||||
the menu camera doesn't move.) Set `scrollFactor(0)` on **every** child
|
the menu camera doesn't move.) Set `scrollFactor(0)` on **every** child
|
||||||
of a screen-fixed UI container — done in `ActionBar.buildSlots`.
|
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
|
- To upgrade: replace the vendored file + note the version here (and re-check
|
||||||
the quirks above — they may go away).
|
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.
|
* 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:
|
* All feel/balance numbers come from data/ship.json:
|
||||||
|
* - size (world px hull length) & scale (multiplier)
|
||||||
* - thrust, maxSpeed, drag → how it accelerates and coasts
|
* - thrust, maxSpeed, drag → how it accelerates and coasts
|
||||||
* - rotSpeed → how fast it turns toward its heading (rad/s)
|
* - rotSpeed → how fast it turns toward its heading (rad/s)
|
||||||
* - brakeDistance → where it starts easing off the throttle
|
* - 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 {
|
export class Ship extends Phaser.Physics.Arcade.Sprite {
|
||||||
static TEXTURE_KEY = '__ship';
|
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) {
|
static ensureTexture(scene) {
|
||||||
if (scene.textures.exists(Ship.TEXTURE_KEY)) return;
|
if (scene.textures.exists(Ship.TEXTURE_KEY)) return;
|
||||||
|
|
||||||
|
|
@ -41,12 +62,20 @@ export class Ship extends Phaser.Physics.Arcade.Sprite {
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(scene, x, y) {
|
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);
|
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.add.existing(this);
|
||||||
scene.physics.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) -----------------------------------------
|
// Tuning (data/ship.json) -----------------------------------------
|
||||||
this.thrust = config.get('ship.thrust', 900); // px/s^2
|
this.thrust = config.get('ship.thrust', 900); // px/s^2
|
||||||
this.maxSpeed = config.get('ship.maxSpeed', 480); // px/s
|
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.brakeDistance = config.get('ship.brakeDistance', 260); // px
|
||||||
this.arriveRadius = config.get('ship.arriveRadius', 8); // px
|
this.arriveRadius = config.get('ship.arriveRadius', 8); // px
|
||||||
this.arriveSpeed = config.get('ship.arriveSpeed', 50); // px/s
|
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
|
// Collision radius (half the hull's width, at scale) — planets keep
|
||||||
// the ship this far off their rims (see Planet.constrainShip).
|
// 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;
|
this.target = null;
|
||||||
}
|
}
|
||||||
|
|
@ -93,7 +131,9 @@ export class Ship extends Phaser.Physics.Arcade.Sprite {
|
||||||
body.acceleration.set(0, 0);
|
body.acceleration.set(0, 0);
|
||||||
// Come to rest facing the direction we were traveling.
|
// Come to rest facing the direction we were traveling.
|
||||||
if (speed > 1) {
|
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);
|
body.velocity.set(0, 0);
|
||||||
return;
|
return;
|
||||||
|
|
@ -124,11 +164,13 @@ export class Ship extends Phaser.Physics.Arcade.Sprite {
|
||||||
|
|
||||||
// Heading: while the ship has speed it follows its actual direction
|
// Heading: while the ship has speed it follows its actual direction
|
||||||
// of motion (so it never flies "backwards" as it brakes); when it
|
// of motion (so it never flies "backwards" as it brakes); when it
|
||||||
// (nearly) stands still it points at the target.
|
// (nearly) stands still it points at the target. Sprite rotation =
|
||||||
const wanted = speed > 10
|
// 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(body.velocity.y, body.velocity.x))
|
||||||
: Phaser.Math.Angle.Wrap(Math.atan2(ny, nx));
|
: Phaser.Math.Angle.Wrap(Math.atan2(ny, nx))) + this.artOffset;
|
||||||
const current = Phaser.Math.Angle.Wrap(this.rotation);
|
const current = this.rotation;
|
||||||
const step = Math.min(Math.abs(wanted - current), this.rotSpeed * dt);
|
const step = Math.min(Math.abs(wanted - current), this.rotSpeed * dt);
|
||||||
this.rotation = Phaser.Math.Angle.RotateTo(current, wanted, step);
|
this.rotation = Phaser.Math.Angle.RotateTo(current, wanted, step);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,21 @@ export class GameScene extends Phaser.Scene {
|
||||||
frameHeight: config.get('planets.frameHeight', 1024),
|
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() {
|
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
|
// 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.
|
// 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 = new Ship(this, 0, 0);
|
||||||
this.ship.setDepth(10);
|
this.ship.setDepth(10);
|
||||||
const spawn = this.planet.edgePoint(
|
const spawn = this.planet.edgePoint(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue