~bigbes/game-prototype-ftl

ref: 036d6feb55f9b471f6a770730bbecff84eaf0040 game-prototype-ftl/docs/tasks/crew-movement.md -rw-r--r-- 16.1 KiB
036d6feb — Eugene Blikh docs(combat): record approved design 30 days 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.

#Plan

Approach: three phases, TDD-first on the two pure units (tiles/pathing, then crew tick), then a single integration phase that wires input + rendering into main.go and render.go. Greenfield feature, no backwards-compat risk.

#Phase 1 — Walkability and pathfinding (TDD)

  • 1.1 tiles_test.go (create) — tests first, must fail before 1.2.

    • TestWalkableTiles_coversAllRoomswalkableTiles(NewPlayerShip()) returns the exact union of the 5 rooms (54 tiles total: 9+12+12+12+9); spot-check tiles inside each room are present and an off-ship tile is absent.
    • TestWalkableTiles_ignoresOutside — tiles at (0,0) and (20,20) are absent.
    • TestBFSPath_sameTilebfsPath(walk, {4,10}, {4,10})nil/empty.
    • TestBFSPath_adjacentTilebfsPath(walk, {4,10}, {5,10})[[2]int{{5,10}}].
    • TestBFSPath_unreachable — from walkable tile to off-ship tile → empty.
    • TestBFSPath_throughShieldsbfsPath(walk, Pilot-center, Engines-center) is non-empty, contains only walkable tiles, ends on the Engines tile, and length equals Manhattan distance (7 for (4,10)→(11,10)).
    • TestBFSPath_deterministic — two consecutive calls produce equal slices.
  • 1.2 tiles.go (create)

    • package main; no Ebitengine imports.
    • func walkableTiles(s Ship) map[[2]int]bool — iterates s.Rooms, inserts every (x, y) with GridX ≤ x < GridX+GridW, GridY ≤ y < GridY+GridH.
    • func bfsPath(walk map[[2]int]bool, from, to [2]int) [][2]int — 4-neighbour BFS with fixed neighbour ordering {{+1,0},{-1,0},{0,+1},{0,-1}} (for determinism), visited-set, parent map; returns waypoints excluding from, including to. Empty on same-tile or unreachable.
    • Invariant: pure, no rendering/input deps; bfsPath deterministic via fixed neighbour order.
  • Commit: crew-movement: walkability + BFS pathfinding with tests

#Phase 2 — Crew type and movement tick (TDD)

  • 2.1 crew_test.go (create)

    • TestNewStartingCrew_count — returns 3 crew.
    • TestNewStartingCrew_positions — Alice at (4,10) (Pilot), Bob at (7,10) (Shields), Carol at (11,10) (Engines); initials 'A', 'B', 'C'; distinct colours.
    • TestUpdateCrew_idle — empty Path, any dt → all fields unchanged.
    • TestUpdateCrew_subTileProgress — path [[2]int{{5,10}}], dt = 0.1 (< TileTime) → TileX/TileY unchanged, MoveT ≈ 0.1/TileTime, path unchanged.
    • TestUpdateCrew_tileSnap — path [[2]int{{5,10}}], dt = TileTimeTileX==5, Path empty, MoveT == 0.
    • TestUpdateCrew_multiStepOvershoot — path [{5,10},{6,10}], dt = 1.5*TileTimeTileX==6, remaining path [{6,10}] wait, check logic. Actually: start (4,10), path [{5,10},{6,10}]; dt=1.5*TileTime means advance 1.5 tiles. After 1 tile: (5,10), path [{6,10}], MoveT=0. Remaining 0.5 tile into next: MoveT≈0.5, TileX==5, path [{6,10}]. Assert that final state.
    • TestUpdateCrew_canonicalIntegral — after any tick, TileX and TileY are integers equal to the last crossed waypoint (never fractional); MoveT is render-time only.
  • 2.2 crew.go (create)

    • package main; imports image/color only (no Ebitengine).
    • const TileTime = 0.35 — seconds per tile.
    • type Crew struct { ID int; Name string; Initial rune; Color color.RGBA; TileX, TileY int; Path [][2]int; MoveT float64 }.
    • func NewStartingCrew() []Crew — hard-coded 3 crew: Alice/Bob/Carol at tiles (4,10), (7,10), (11,10), with distinct muted tints (teal, amber, magenta). Initials 'A', 'B', 'C'.
    • func updateCrew(c *Crew, dt float64) — if len(c.Path) == 0 return. Advance c.MoveT += dt / TileTime. While c.MoveT >= 1 && len(c.Path) > 0: snap (c.TileX, c.TileY) = c.Path[0], c.Path = c.Path[1:], c.MoveT -= 1. If len(c.Path) == 0, force c.MoveT = 0.
    • Invariants: crew.go does not import Ebitengine; TileX/TileY only mutated on waypoint crossing.
  • Commit: crew-movement: crew struct, starting roster, tick

#Phase 3 — Input and rendering integration

  • 3.1 render.go (modify — append, no changes to existing code)

    • Add const CrewRadius = 6 (12 px diameter at TilePx=16).
    • Add func drawCrew(screen *ebiten.Image, c Crew, selected bool):
      • Compute interpolated pixel centre: if len(c.Path) > 0, (cx, cy) = lerp((TileX,TileY), Path[0], MoveT) in tile units; else (TileX, TileY). Multiply by TilePx, add TilePx/2 for tile centre.
      • vector.DrawFilledCircle(screen, px, py, CrewRadius, c.Color, true) (antialiased so placeholder doesn't look too harsh; everything else stays non-AA).
      • If selected, draw a 1-px white ring: vector.StrokeCircle(screen, px, py, CrewRadius+1, 1, white, true).
      • Label: ebitenutil.DebugPrintAt(screen, string(c.Initial), int(px)-2, int(py)-5).
    • Invariant: reads Crew, does not mutate; uses only virtual coords through TilePx.
  • 3.2 main.go (modify)

    • Add imports: time.
    • Extend Game: walk map[[2]int]bool, crew []Crew, selectedCrew int, lastTime time.Time.
    • main(): after NewPlayerShip(), compute walk := walkableTiles(ship), crew := NewStartingCrew(); initialise Game{ship, walk, crew, -1, time.Time{}}.
    • Update():
      • First call: lastTime = time.Now(), return nil.
      • Else: dt := time.Since(g.lastTime).Seconds(); g.lastTime = time.Now(). Clamp dt to a sane ceiling (e.g. 0.1) to avoid huge jumps on resume.
      • For each crew: updateCrew(&g.crew[i], dt).
      • Poll left-click: if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) (new import inpututil) — read cx, cy := ebiten.CursorPosition() (already in virtual coords). Derive tile tx, ty := cx/TilePx, cy/TilePx.
      • Selection: iterate crew; if cursor is within CrewRadius of crew centre (tile-space check is sufficient: same tile or adjacent-and-close), set g.selectedCrew = i and return.
      • Otherwise, if g.selectedCrew >= 0 and g.walk[[2]int{tx,ty}]: issue move — from := [2]int{crew.TileX, crew.TileY}; crew.Path = bfsPath(g.walk, from, [2]int{tx,ty}); crew.MoveT = 0.
    • Draw(): after drawShip, iterate crew and call drawCrew(screen, c, i == g.selectedCrew).
    • Invariants: virtual-coord drawing only; single codebase WASM-safe (time.Since and inpututil both work under GOOS=js).
  • Commit: crew-movement: wire input, tick, and render

#Test strategy

Per task TDD: yes — tests from Phase 1 and Phase 2 written first, must fail on the pre-implementation tree, then green after the paired source file. Phase 3 has no automated tests (input and rendering need real mouse/display); acceptance is manual via go run . and make play-web:

  • Click each crew in turn: ring appears, persists until another crew or empty click.
  • Click a destination tile on a selected crew: crew walks there tile-by-tile, ring follows them.
  • Click an unreachable tile (e.g. outside ship): no-op.
  • Clicking a new destination on an already-moving crew: path replaces, no crash.

#Open questions / risks

  • inpututil is a standard Ebitengine subpackage that works under WASM; no conditional imports needed. If it doesn't, Phase 3 falls back to ebiten.IsMouseButtonPressed + manual edge detection.
  • Mouse coords in the browser: ebiten.CursorPosition() is documented to return logical-space coords via Layout; this is how WASM-served Ebitengine games handle input. If verify finds the browser returns raw canvas pixels, the fix is a single (windowW, windowH) := ebiten.WindowSize() scale step.

#Verify

Result: passed

Positive:

  • CK1 — go test -v ./... → 14/14 pass (7 walkability/BFS, 7 crew tick)
  • CK2 — go build ./... → clean native build
  • CK3 — make build-wasm → clean WASM build, web/main.wasm produced
  • CK4 — WASM smoke: 3 crew render at correct tiles (A in Pilot, B in Shields, C in Engines) with distinct tints
  • CK5 — click on Bob → white selection ring appears around B
  • CK6 — click MedBay tile (7,13) with Bob selected → Bob walks Shields → MedBay tile-by-tile, ring follows

Negative:

  • CK7 — TestBFSPath_unreachable covers walkable→off-ship; CK8 covers same-tile (returns nil)
  • CK8 — TestBFSPath_sameTile returns empty
  • CK9 — TestUpdateCrew_idle confirms empty-path tick is a no-op
  • CK10 — WASM smoke: click off-ship tile (20,5) with Bob selected → no movement, selection persists

Invariants / assumptions:

  • CK11 (IV1) — grep "hajimehoshi\|inpututil" ship.go tiles.go crew.go → empty
  • CK12 (IV2) — grep "TilePx\|VirtualW\|VirtualH" ship.go tiles.go crew.go → empty (no pixel constants in pure files)
  • CK13 (IV3) — grep "c\.\(TileX\|TileY\|MoveT\|Path\)\s*=" render.go → empty (render never mutates Crew)
  • CK14 (IV4) — TestBFSPath_deterministic passes
  • CK15 (IV5) — grep "//go:build" --include="*.go" → empty; no platform-suffixed files; native + WASM both build clean

Smoke: WASM at http://localhost:8765/ — selection, BFS dispatch, multi-tile movement, and unreachable-click no-op all observed visually.

#Conclusion

Outcome: crew-movement milestone shipped — tile pathing, crew tick, and click-to-move integration. HEAD 1177709.

Invariants:

  • IV1 — grep "hajimehoshi\|inpututil" ship.go tiles.go crew.go empty.
  • IV2 — grep "TilePx\|VirtualW\|VirtualH" ship.go tiles.go crew.go empty; mouse converted at the boundary in main.Update.
  • IV3 — grep "c\.\(TileX\|TileY\|MoveT\|Path\)\s*=" render.go empty; render reads only.
  • IV4 — TestBFSPath_deterministic passes; fixed neighbour order {+1,0},{-1,0},{0,+1},{0,-1} in tiles.go:bfsPath.
  • IV5 — no //go:build, no platform-suffixed files; native + WASM both build clean; browser smoke confirms inpututil.IsMouseButtonJustPressed and ebiten.CursorPosition work under WASM.

#Unknowns outcome

  • UK1 (ebiten.WindowSize after resize) — moot. ebiten.CursorPosition() already returns logical coords post-Layout, so the implementation never had to consult WindowSize. Browser smoke confirmed correct tile mapping at the default canvas size; resize behaviour was not exercised.

#Review findings

  • Important #1 — reviewer flagged exact-tile selection (TileX == tx && TileY == ty) as imprecise vs the plan's pixel-radius wording. Pushed back: at CrewRadius=6 and TilePx=16 the visible circle fits inside the tile with a 2-px gutter on every side, so clicks on the visible crew always land in the crew's tile. The plan's own parenthetical accepted "tile-space check is sufficient: same tile". No fix.

#Future work

  • If CrewRadius later exceeds TilePx/2, the exact-tile selection check needs to switch to a pixel-distance test. Justification: only matters if Design changes the crew rendering size; currently 6 < 8 by construction.
  • Tile occupancy / "reserve next tile" enforcement, flagged in Design as deferred. Justification: Design § "Tile occupancy" — explicitly out of scope for this milestone.