diff --git a/README.md b/README.md index 9359cf1..927e9ad 100644 --- a/README.md +++ b/README.md @@ -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/ diff --git a/assets/images/originals/base-ship-landed-terran.png b/assets/images/originals/base-ship-landed-terran.png new file mode 100644 index 0000000..c02c46d Binary files /dev/null and b/assets/images/originals/base-ship-landed-terran.png differ diff --git a/assets/images/ships-player.png b/assets/images/ships-player.png new file mode 100644 index 0000000..6f5cad0 Binary files /dev/null and b/assets/images/ships-player.png differ diff --git a/assets/images/ships-player.psd b/assets/images/ships-player.psd new file mode 100644 index 0000000..194a68a Binary files /dev/null and b/assets/images/ships-player.psd differ diff --git a/assets/videos/terran-land-01.mp4 b/assets/videos/terran-land-01.mp4 new file mode 100644 index 0000000..bbe1b5c Binary files /dev/null and b/assets/videos/terran-land-01.mp4 differ diff --git a/data/ship.json b/data/ship.json index 3171bad..f7aedde 100644 --- a/data/ship.json +++ b/data/ship.json @@ -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", diff --git a/dev/ship-behavior.test.mjs b/dev/ship-behavior.test.mjs index c6d5b7e..50f7bcf 100644 --- a/dev/ship-behavior.test.mjs +++ b/dev/ship-behavior.test.mjs @@ -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); } } diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 24663dd..7261253 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -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). diff --git a/js/entities/Ship.js b/js/entities/Ship.js index 0127cb6..aeced36 100644 --- a/js/entities/Ship.js +++ b/js/entities/Ship.js @@ -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,24 +62,41 @@ 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 - this.drag = config.get('ship.drag', 2.4); // 1/s, exponential decay - this.rotSpeed = config.get('ship.rotSpeed', 10); // rad/s + 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 - this.setScale(config.get('ship.scale', 1)); + 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 = (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 - ? 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); + // (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 { diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 8ffb6f4..890f4f5 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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(