# 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: yes** — `bfsPath`, `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_coversAllRooms` — `walkableTiles(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_sameTile` — `bfsPath(walk, {4,10}, {4,10})` → `nil`/empty. - `TestBFSPath_adjacentTile` — `bfsPath(walk, {4,10}, {5,10})` → `[[2]int{{5,10}}]`. - `TestBFSPath_unreachable` — from walkable tile to off-ship tile → empty. - `TestBFSPath_throughShields` — `bfsPath(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 = TileTime` → `TileX==5`, `Path` empty, `MoveT == 0`. - `TestUpdateCrew_multiStepOvershoot` — path `[{5,10},{6,10}]`, `dt = 1.5*TileTime` → `TileX==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.