~bigbes/game-prototype-ftl

ref: aa11b70ce5637b345f60e1a6407c21683d811a02 game-prototype-ftl/docs/tasks/crew-movement.md -rw-r--r-- 5.3 KiB
aa11b70c — Eugene Blikh docs(crew-movement): design a month ago

#crew-movement

Second milestone of the ftl-shape vertical slice. Adds interactive crew: click a crew member, click a destination tile, watch them walk. Built on top of the skeleton milestone's tile grid and ship layout.

#Design

Purpose. Introduce interactive crew: 3 crew members sit on walkable tiles; player clicks a crew to select, clicks a destination tile to issue a move order; crew walks tile-by-tile along a shortest path. Foundation for combat-milestone features that require crew to be "at" a specific system.

In scope. Walkable-tile model derived from existing ship rooms; 3 named placeholder crew with distinct tints; click-to-select / click-to-move input; BFS pathfinding on the walkable tile graph; tile-by-tile movement animation; selection feedback (1-px ring around selected crew).

Out of scope. Doors / walls, crew skills, crew health, hover-tile preview, multi-select, queued orders, damage, enemy ship, combat, pause.

Chosen approach.

  • Walkability. New file tiles.go: func walkableTiles(s Ship) map[[2]int]bool — union of every room's interior tiles, keyed by [x, y]. Computed once at ship construction, cached on Game. Naturally connected because rooms share tile edges in the skeleton layout.
  • Crew model. New file crew.go:
    • type Crew struct { ID int; Name string; Initial rune; Color color.RGBA; TileX, TileY int; Path [][2]int; MoveT float64 } — tile position is canonical; Path is the remaining waypoints (empty = idle); MoveT is progress in [0,1) toward the next waypoint.
    • func NewStartingCrew() []Crew — Alice in Pilot, Bob in Shields, Carol in Engines.
  • Pathfinding. func bfsPath(walk map[[2]int]bool, from, to [2]int) [][2]int — standard 4-neighbour BFS over the walkable set; returns waypoints excluding from and including to. Empty path means unreachable or same-tile.
  • Input. Game.Update reads the mouse: left-click → convert window pixels to virtual pixels (using ebiten.WindowSize + the known Layout scale) → tile coords. If the click lands on a crew member, select that crew (store its index in selectedCrew). Otherwise, if a crew is selected and the click tile is walkable, replace that crew's Path with a fresh BFS result and reset MoveT.
  • Movement tick. func updateCrew(c *Crew, dt float64) — if Path non-empty, advance MoveT += dt / tileTime (default tileTime = 0.35s). When MoveT >= 1, snap TileX, TileY to the next waypoint, pop it, reset MoveT. Rendered position is linearly interpolated between the current tile and the next waypoint.
  • Rendering. drawCrew(screen *ebiten.Image, c Crew, selected bool) added to render.go. Crew is a 12 px colored circle centred on the interpolated position, plus the initial letter via ebitenutil.DebugPrintAt. Selected crew gets a 1-px white outer ring.
  • Game struct growth. Game gains walk map[[2]int]bool, crew []Crew, selectedCrew int (−1 if none), lastTime time.Time. Update computes dt from wall-clock delta.
  • Tile occupancy. No enforcement this milestone — two crew may overlap visually. A short comment flags the spot where a "reserve next tile" check belongs later.

Tradeoffs that settled the decisions.

  • Tile-position-as-canonical (vs pixel-position + tile-on-arrival): cleaner gameplay queries ("is crew in Weapons?"), simpler save-state later; cost is one interpolation step in the renderer.
  • map[[2]int]bool (vs [][]bool grid): tiny ship, map lookup is fast enough, keeps the code resolution-independent. Memory overhead is irrelevant at ~30 tiles.
  • Per-crew path ownership (vs central path manager): each crew is independent at this scope; no coordination needed.

Unknowns. Mouse-pixel → virtual-pixel conversion relies on ebiten.WindowSize reporting the current window size (including after resize). Ebitengine's documented behaviour confirms this.

TDD: yesbfsPath, walkableTiles, and the updateCrew tick are pure functions with clear inputs/outputs and meaningful branching (same-tile, unreachable, multi-room path, boundary-crossing path, tile snap on MoveT >= 1). Input handling and rendering stay manual — they need the real mouse and display.

#Invariants

  • ship.go, tiles.go, and crew.go must not import Ebitengine. Pathfinding and walkability are pure.
  • All drawing uses virtual-resolution coordinates (640×360). Mouse input is converted once at the boundary; crew positions are stored in tile units only.
  • Crew canonical position is (TileX, TileY) — a tile coordinate. Render-time interpolation is never written back to TileX / TileY.
  • bfsPath is deterministic: same walkable set + same from / to → same path, via a stable neighbour ordering.
  • Single codebase builds unchanged for GOOS=js GOARCH=wasm and native targets. Mouse input works in the browser via the same API.

#Principles

  • YAGNI: no doors, walls, queued orders, hover preview, or cancel animation. If the combat milestone needs it, it ships there.
  • Tests cover pure functions (path, walkability, tick); rendering and input stay manual.
  • Fail fast: unreachable paths return empty, not "closest-so-far". Clicking outside the ship is a no-op, not a teleport.
  • Separate data from rendering and input — crew.go has no ebiten. imports; render.go never mutates crew state.
  • Deterministic core: same input sequence and timestep → identical game state.