From e9c6d95907e1a8907c8ac26bd01d5affc8e00a59 Mon Sep 17 00:00:00 2001 From: Chris Pardy Date: Sun, 7 Jun 2026 20:38:32 -0400 Subject: [PATCH] Add A* pathfinding movement to the engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Movement is now target-based, per the agreed model: an action sets a destination, the reducer computes a collision-free path, and time advances the player along it. - nav.ts: NavGrid + buildNav() (rasterizes collision rects into a walkability grid), worldToCell/cellCenter, and aStar() — 8-directional, no corner cutting, with straight runs collapsed; snaps blocked endpoints to the nearest walkable cell, returns null when unreachable. Pure/deterministic, safe to run in the reducer. - map.ts: reads collision out of a world.tilemap.map record (the sibling tilemap.world format) — areaNodes flagged world.tilemap.collision="1", recursing groups and skipping hidden nodes — and navFromMap() to build a grid (default cell = tile/3 subcell). - Engine wiring: GameState.nav, Player.dest/path, config.moveSpeedPxPerSec; a setDestination action that runs A*; movement integrated on every time advance (tick is the heartbeat); paths cleared on teleport/act/kick. Projection exposes dest/path to self only (others are conveyed by streamed position updates). 12 tests added (nav rasterize/convert, A* straight/around-wall/unreachable/no-corner-cut, map collision extraction, and reducer movement). Full suite 42 green; tsc -b clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- engine/actions.ts | 4 + engine/config.ts | 2 + engine/index.ts | 2 + engine/map.ts | 104 ++++++++++++++++ engine/nav.test.ts | 118 ++++++++++++++++++ engine/nav.ts | 251 +++++++++++++++++++++++++++++++++++++++ engine/reducer.test.ts | 61 ++++++++++ engine/reducer.ts | 91 +++++++++++++- engine/selectors.test.ts | 1 + engine/selectors.ts | 6 + engine/testutil.ts | 9 +- engine/types.ts | 20 ++++ 12 files changed, 667 insertions(+), 2 deletions(-) create mode 100644 engine/map.ts create mode 100644 engine/nav.test.ts create mode 100644 engine/nav.ts diff --git a/engine/actions.ts b/engine/actions.ts index b5f93f1..6f85843 100644 --- a/engine/actions.ts +++ b/engine/actions.ts @@ -7,6 +7,7 @@ import type { GameConfig, Facing, ActivityKind, + NavGrid, Outcome, Role, StationSeed, @@ -31,6 +32,8 @@ export type Action = code: string config: GameConfig stations: StationSeed[] + /** the level's navigation grid; omitted when pathfinding isn't needed (e.g. tests) */ + nav?: NavGrid }) | (Base & { type: 'playerJoined' @@ -48,6 +51,7 @@ export type Action = endsAt: number }) | (Base & { type: 'playerMoved'; did: Did; pos: Vec; facing: Facing }) + | (Base & { type: 'setDestination'; did: Did; target: Vec }) | (Base & { type: 'actionStarted' did: Did diff --git a/engine/config.ts b/engine/config.ts index 48cbd77..37cf262 100644 --- a/engine/config.ts +++ b/engine/config.ts @@ -14,6 +14,7 @@ export const SLOT_COOLDOWN_MS = 3_000 export const ROUND_DURATION_MS = 600_000 // 10 min export const DEBRIS_TTL_MS = 15_000 export const VOTE_DURATION_MS = 45_000 +export const MOVE_SPEED_PX_PER_SEC = 140 // matches the Pixi walker's SPEED /** Concurrent users per station — scales with players (placeholder: ~1 slot per 3 players). */ export function stationSlots(playerCount: number): number { @@ -64,5 +65,6 @@ export function defaultConfig(opts: ConfigOptions): GameConfig { slotCooldownMs: SLOT_COOLDOWN_MS, debrisTtlMs: DEBRIS_TTL_MS, voteDurationMs: VOTE_DURATION_MS, + moveSpeedPxPerSec: MOVE_SPEED_PX_PER_SEC, } } diff --git a/engine/index.ts b/engine/index.ts index 110ef27..2370644 100644 --- a/engine/index.ts +++ b/engine/index.ts @@ -5,3 +5,5 @@ export * from './actions.js' export * from './reducer.js' export * from './selectors.js' export * from './config.js' +export * from './nav.js' +export * from './map.js' diff --git a/engine/map.ts b/engine/map.ts new file mode 100644 index 0000000..8ee6fc9 --- /dev/null +++ b/engine/map.ts @@ -0,0 +1,104 @@ +// Reads collision geometry out of a `world.tilemap.map` record (authored in tilemap.world) and +// turns it into a navigation grid for pathfinding. +// +// Collision in that format is an `areaNode` carrying the well-known property +// `world.tilemap.collision = "1"`; its shape is the additive union of pixel `boxes`. There is no +// separate collision node kind. We only need a minimal structural subset of the record here. + +import { buildNav, type Rect } from './nav.js' +import type { NavGrid } from './types.js' + +export const COLLISION_PROPERTY = 'world.tilemap.collision' +export const COLLISION_VALUE = '1' + +/** A rectangle in map pixels (top-left origin, y-down). */ +export interface Box { + x: number + y: number + w: number + h: number +} + +interface Property { + key: string + value: string +} + +/** Structural subset of a scene node — enough to find collision areas and recurse groups. */ +interface MapNode { + $type?: string + hidden?: boolean + properties?: Property[] + /** present on areaNode */ + boxes?: Box[] + /** present on groupNode */ + children?: MapNode[] +} + +/** Structural subset of a `world.tilemap.map` record. */ +export interface MapRecord { + grid: { tileWidth: number; tileHeight: number } + nodes?: MapNode[] +} + +function isCollisionArea(node: MapNode): boolean { + return ( + Array.isArray(node.boxes) && + (node.properties?.some( + (p) => p.key === COLLISION_PROPERTY && p.value === COLLISION_VALUE, + ) ?? + false) + ) +} + +/** All collision boxes in the map (map px), recursing into groups and skipping hidden nodes. */ +export function collisionBoxes(map: MapRecord): Box[] { + const out: Box[] = [] + const walk = (nodes: MapNode[] | undefined): void => { + for (const node of nodes ?? []) { + if (node.hidden) continue + if (isCollisionArea(node)) out.push(...(node.boxes ?? [])) + if (node.children) walk(node.children) + } + } + walk(map.nodes) + return out +} + +/** Union bounds of a set of rects, with optional padding; null when empty. */ +function boundsOf(rects: Rect[], pad: number): Rect | null { + if (rects.length === 0) return null + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + for (const r of rects) { + minX = Math.min(minX, r.x) + minY = Math.min(minY, r.y) + maxX = Math.max(maxX, r.x + r.w) + maxY = Math.max(maxY, r.y + r.h) + } + return { x: minX - pad, y: minY - pad, w: maxX - minX + pad * 2, h: maxY - minY + pad * 2 } +} + +export interface NavFromMapOptions { + /** nav cell size (world px). Defaults to a third of the tile (the editor's subcell grid). */ + cellSize?: number + /** expand collision rects by this many px (≈ player radius). Default 0. */ + inflate?: number + /** explicit grid extent (world px). Defaults to the collision bounds plus padding. */ + bounds?: Rect +} + +/** Build a navigation grid directly from a map record. */ +export function navFromMap(map: MapRecord, opts: NavFromMapOptions = {}): NavGrid { + const cellSize = opts.cellSize ?? Math.max(1, Math.round(map.grid.tileWidth / 3)) + const boxes = collisionBoxes(map) + const bounds = opts.bounds ?? boundsOf(boxes, cellSize * 4) ?? { + x: 0, + y: 0, + w: cellSize, + h: cellSize, + } + return buildNav(boxes, { cellSize, bounds, inflate: opts.inflate ?? 0 }) +} diff --git a/engine/nav.test.ts b/engine/nav.test.ts new file mode 100644 index 0000000..e76c961 --- /dev/null +++ b/engine/nav.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest' +import { aStar, buildNav, cellCenter, isBlocked, worldToCell } from './nav.js' +import { collisionBoxes, navFromMap, type MapRecord } from './map.js' + +describe('buildNav', () => { + it('rasterizes collision rects into blocked cells', () => { + const nav = buildNav([{ x: 20, y: 0, w: 10, h: 10 }], { + cellSize: 10, + bounds: { x: 0, y: 0, w: 50, h: 10 }, + }) + expect(nav.cols).toBe(5) + expect(nav.rows).toBe(1) + expect(isBlocked(nav, 2, 0)).toBe(true) // the cell at x=20..30 + expect(isBlocked(nav, 0, 0)).toBe(false) + expect(isBlocked(nav, 99, 0)).toBe(true) // out of bounds reads as blocked + }) + + it('worldToCell and cellCenter round-trip', () => { + const nav = buildNav([], { cellSize: 10, bounds: { x: 0, y: 0, w: 100, h: 100 } }) + expect(worldToCell(nav, { x: 25, y: 5 })).toEqual({ col: 2, row: 0 }) + expect(cellCenter(nav, 2, 0)).toEqual({ x: 25, y: 5 }) + }) +}) + +describe('aStar', () => { + it('returns a single waypoint for a clear straight line', () => { + const nav = buildNav([], { cellSize: 10, bounds: { x: 0, y: 0, w: 100, h: 10 } }) + const path = aStar(nav, { x: 5, y: 5 }, { x: 95, y: 5 }) + expect(path).toEqual([{ x: 95, y: 5 }]) // collinear run collapsed to the endpoint + }) + + it('routes around a wall with a gap', () => { + // wall spans col 5 for rows 0..3, leaving row 4 open + const nav = buildNav([{ x: 50, y: 0, w: 10, h: 40 }], { + cellSize: 10, + bounds: { x: 0, y: 0, w: 100, h: 50 }, + }) + const path = aStar(nav, { x: 5, y: 5 }, { x: 95, y: 5 }) + expect(path).not.toBeNull() + expect(path!.length).toBeGreaterThan(1) // had to detour + for (const wp of path!) { + const { col, row } = worldToCell(nav, wp) + expect(isBlocked(nav, col, row)).toBe(false) // never routes through a wall + } + // ends at the goal cell + expect(path![path!.length - 1]).toEqual(cellCenter(nav, 9, 0)) + }) + + it('returns null when there is no route', () => { + const nav = buildNav([{ x: 0, y: 0, w: 100, h: 100 }], { + cellSize: 10, + bounds: { x: 0, y: 0, w: 100, h: 100 }, + }) + expect(aStar(nav, { x: 5, y: 5 }, { x: 95, y: 95 })).toBeNull() + }) + + it('does not cut blocked corners', () => { + // block the single cell (1,1); a diagonal from (0,1)->(1,0) must not slip past its corner + const nav = buildNav([{ x: 10, y: 10, w: 10, h: 10 }], { + cellSize: 10, + bounds: { x: 0, y: 0, w: 30, h: 30 }, + }) + const path = aStar(nav, { x: 5, y: 15 }, { x: 15, y: 5 }) + expect(path).not.toBeNull() + for (const wp of path!) { + const { col, row } = worldToCell(nav, wp) + expect(isBlocked(nav, col, row)).toBe(false) + } + }) +}) + +describe('map collision -> nav', () => { + const map: MapRecord = { + grid: { tileWidth: 48, tileHeight: 48 }, + nodes: [ + { + $type: 'world.tilemap.map#areaNode', + properties: [{ key: 'world.tilemap.collision', value: '1' }], + boxes: [{ x: 0, y: 0, w: 48, h: 48 }], + }, + { + // a group with a hidden collision area (should be ignored) and a visible one + $type: 'world.tilemap.map#groupNode', + children: [ + { + $type: 'world.tilemap.map#areaNode', + hidden: true, + properties: [{ key: 'world.tilemap.collision', value: '1' }], + boxes: [{ x: 100, y: 100, w: 10, h: 10 }], + }, + { + $type: 'world.tilemap.map#areaNode', + properties: [{ key: 'world.tilemap.collision', value: '1' }], + boxes: [{ x: 200, y: 0, w: 48, h: 48 }], + }, + { + // a non-collision area (e.g. a spawn zone) — ignored + $type: 'world.tilemap.map#areaNode', + properties: [{ key: 'app.something', value: 'x' }], + boxes: [{ x: 300, y: 0, w: 48, h: 48 }], + }, + ], + }, + ], + } + + it('collects only visible collision boxes, recursing into groups', () => { + const boxes = collisionBoxes(map) + expect(boxes).toHaveLength(2) // hidden + non-collision excluded + expect(boxes).toContainEqual({ x: 200, y: 0, w: 48, h: 48 }) + }) + + it('builds a nav grid sized to the tile subcell by default', () => { + const nav = navFromMap(map) + expect(nav.cellSize).toBe(16) // 48 / 3 + expect(nav.cols).toBeGreaterThan(0) + }) +}) diff --git a/engine/nav.ts b/engine/nav.ts new file mode 100644 index 0000000..fdf382e --- /dev/null +++ b/engine/nav.ts @@ -0,0 +1,251 @@ +// Navigation grid + A* pathfinding. Pure and deterministic: same grid + same endpoints always +// yield the same path, so it is safe to run inside the reducer (PLAN.md §3.7). +// +// The grid rasterizes the level's collision rectangles (from the world.tilemap.map collision +// areas — see map.ts) into a boolean walkability grid. Coordinates are in world pixels. + +import type { NavGrid, Vec } from './types.js' + +/** A rectangle in world pixels (top-left origin, y-down) — same shape as a map `Box`. */ +export interface Rect { + x: number + y: number + w: number + h: number +} + +export interface BuildNavOptions { + cellSize: number + /** world-px extent the grid covers */ + bounds: Rect + /** expand each collision rect by this many px (≈ player radius) to keep paths off walls */ + inflate?: number +} + +/** Rasterize collision rects into a walkability grid. A cell is blocked if any (inflated) rect + * overlaps the cell's pixel square. */ +export function buildNav(rects: Rect[], opts: BuildNavOptions): NavGrid { + const { cellSize, bounds } = opts + const inflate = opts.inflate ?? 0 + const cols = Math.max(1, Math.ceil(bounds.w / cellSize)) + const rows = Math.max(1, Math.ceil(bounds.h / cellSize)) + const blocked = new Array(cols * rows).fill(0) + + const inflated = rects.map((r) => ({ + x: r.x - inflate, + y: r.y - inflate, + w: r.w + inflate * 2, + h: r.h + inflate * 2, + })) + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const cx = bounds.x + col * cellSize + const cy = bounds.y + row * cellSize + for (const r of inflated) { + if ( + cx < r.x + r.w && + cx + cellSize > r.x && + cy < r.y + r.h && + cy + cellSize > r.y + ) { + blocked[row * cols + col] = 1 + break + } + } + } + } + + return { cellSize, cols, rows, originX: bounds.x, originY: bounds.y, blocked } +} + +export function worldToCell(nav: NavGrid, p: Vec): { col: number; row: number } { + return { + col: Math.floor((p.x - nav.originX) / nav.cellSize), + row: Math.floor((p.y - nav.originY) / nav.cellSize), + } +} + +export function cellCenter(nav: NavGrid, col: number, row: number): Vec { + return { + x: nav.originX + (col + 0.5) * nav.cellSize, + y: nav.originY + (row + 0.5) * nav.cellSize, + } +} + +export function inBounds(nav: NavGrid, col: number, row: number): boolean { + return col >= 0 && col < nav.cols && row >= 0 && row < nav.rows +} + +export function isBlocked(nav: NavGrid, col: number, row: number): boolean { + if (!inBounds(nav, col, row)) return true + return nav.blocked[row * nav.cols + col] === 1 +} + +/** Nearest walkable cell to (col,row) by expanding rings; null if the grid is fully blocked. */ +function nearestWalkable( + nav: NavGrid, + col: number, + row: number, +): { col: number; row: number } | null { + if (!isBlocked(nav, col, row)) return { col, row } + const maxR = Math.max(nav.cols, nav.rows) + for (let r = 1; r <= maxR; r++) { + for (let dc = -r; dc <= r; dc++) { + for (let dr = -r; dr <= r; dr++) { + if (Math.max(Math.abs(dc), Math.abs(dr)) !== r) continue // ring perimeter only + const c = col + dc + const rw = row + dr + if (!isBlocked(nav, c, rw)) return { col: c, row: rw } + } + } + } + return null +} + +// --- binary min-heap keyed by f-score --------------------------------------- + +class MinHeap { + private items: { idx: number; f: number }[] = [] + + get size(): number { + return this.items.length + } + + push(idx: number, f: number): void { + const items = this.items + items.push({ idx, f }) + let i = items.length - 1 + while (i > 0) { + const parent = (i - 1) >> 1 + if (items[parent]!.f <= items[i]!.f) break + ;[items[parent], items[i]] = [items[i]!, items[parent]!] + i = parent + } + } + + pop(): number { + const items = this.items + const top = items[0]! + const last = items.pop()! + if (items.length > 0) { + items[0] = last + let i = 0 + for (;;) { + const l = 2 * i + 1 + const r = 2 * i + 2 + let smallest = i + if (l < items.length && items[l]!.f < items[smallest]!.f) smallest = l + if (r < items.length && items[r]!.f < items[smallest]!.f) smallest = r + if (smallest === i) break + ;[items[smallest], items[i]] = [items[i]!, items[smallest]!] + i = smallest + } + } + return top.idx + } +} + +const SQRT2 = Math.SQRT2 + +/** + * A* from `start` to `goal` (world px). Returns a list of world-px waypoints (cell centers, + * straight runs collapsed) the player should walk through to reach `goal`, or null if no route + * exists. The returned path excludes the start position. An empty array means "already there". + * + * 8-directional; diagonal steps are disallowed when they'd cut a blocked corner. + */ +export function aStar(nav: NavGrid, start: Vec, goal: Vec): Vec[] | null { + const s = nearestWalkable(nav, ...cell(worldToCell(nav, start))) + const g = nearestWalkable(nav, ...cell(worldToCell(nav, goal))) + if (!s || !g) return null + + const cols = nav.cols + const startIdx = s.row * cols + s.col + const goalIdx = g.row * cols + g.col + if (startIdx === goalIdx) return [] + + const n = cols * nav.rows + const gScore = new Float64Array(n).fill(Infinity) + const cameFrom = new Int32Array(n).fill(-1) + const closed = new Uint8Array(n) + gScore[startIdx] = 0 + + const h = (idx: number): number => { + const col = idx % cols + const row = (idx - col) / cols + const dc = Math.abs(col - g.col) + const dr = Math.abs(row - g.row) + return (dc + dr) + (SQRT2 - 2) * Math.min(dc, dr) // octile distance + } + + const open = new MinHeap() + open.push(startIdx, h(startIdx)) + + while (open.size > 0) { + const current = open.pop() + if (current === goalIdx) return reconstruct(nav, cameFrom, current) + if (closed[current]) continue + closed[current] = 1 + + const col = current % cols + const row = (current - col) / cols + for (let dc = -1; dc <= 1; dc++) { + for (let dr = -1; dr <= 1; dr++) { + if (dc === 0 && dr === 0) continue + const nc = col + dc + const nr = row + dr + if (isBlocked(nav, nc, nr)) continue + if (dc !== 0 && dr !== 0) { + // no corner cutting: both orthogonal neighbors must be open + if (isBlocked(nav, col + dc, row) || isBlocked(nav, col, row + dr)) continue + } + const nIdx = nr * cols + nc + if (closed[nIdx]) continue + const step = dc !== 0 && dr !== 0 ? SQRT2 : 1 + const tentative = gScore[current]! + step + if (tentative < gScore[nIdx]!) { + gScore[nIdx] = tentative + cameFrom[nIdx] = current + open.push(nIdx, tentative + h(nIdx)) + } + } + } + } + return null +} + +function cell(c: { col: number; row: number }): [number, number] { + return [c.col, c.row] +} + +function reconstruct(nav: NavGrid, cameFrom: Int32Array, goalIdx: number): Vec[] { + const cols = nav.cols + const idxs: number[] = [] + let cur = goalIdx + while (cur !== -1) { + idxs.push(cur) + cur = cameFrom[cur]! + } + idxs.reverse() // start -> goal (cell indices) + + // Collapse straight runs: keep only cells where the step direction changes. + const kept: number[] = [] + for (let i = 0; i < idxs.length; i++) { + if (i === 0 || i === idxs.length - 1) { + kept.push(idxs[i]!) + continue + } + const prev = idxs[i - 1]! + const cur2 = idxs[i]! + const next = idxs[i + 1]! + const d1c = (cur2 % cols) - (prev % cols) + const d1r = ((cur2 - (cur2 % cols)) - (prev - (prev % cols))) / cols + const d2c = (next % cols) - (cur2 % cols) + const d2r = ((next - (next % cols)) - (cur2 - (cur2 % cols))) / cols + if (d1c !== d2c || d1r !== d2r) kept.push(cur2) + } + + // Drop the start cell (player already there); convert the rest to world-px centers. + return kept.slice(1).map((idx) => cellCenter(nav, idx % cols, (idx - (idx % cols)) / cols)) +} diff --git a/engine/reducer.test.ts b/engine/reducer.test.ts index 83d6ea4..e862854 100644 --- a/engine/reducer.test.ts +++ b/engine/reducer.test.ts @@ -86,6 +86,67 @@ describe('movement', () => { }) }) +describe('pathfinding movement', () => { + it('setDestination writes a path toward the target', () => { + let s = setup({ startRound: true }) + s = reduce(s, { type: 'setDestination', serverTime: 1_000, did: A, target: { x: 80, y: 0 } }) + expect(s.players[A]?.path.length).toBeGreaterThan(0) + expect(s.players[A]?.dest).not.toBeNull() + }) + + it('ticks advance the player along the path until they arrive at the destination', () => { + let s = setup({ startRound: true }) + s = reduce(s, { type: 'setDestination', serverTime: 1_000, did: A, target: { x: 80, y: 0 } }) + const dest = s.players[A]!.dest! + s = reduce(s, { type: 'tick', serverTime: 1_000_000 }) // plenty of time + expect(s.players[A]?.pos).toEqual(dest) + expect(s.players[A]?.path).toHaveLength(0) + expect(s.players[A]?.dest).toBeNull() + }) + + it('a short tick moves the player partway without arriving', () => { + let s = setup({ startRound: true }) + const start = s.players[A]!.pos + s = reduce(s, { type: 'setDestination', serverTime: 1_000, did: A, target: { x: 80, y: 0 } }) + s = reduce(s, { type: 'tick', serverTime: 1_100 }) // 100ms => ~14px at 140px/s + const p = s.players[A]! + expect(p.pos).not.toEqual(start) + expect(p.dest).not.toBeNull() + expect(p.path.length).toBeGreaterThan(0) + }) + + it('cannot set a destination while mid-action, and starting an action clears the path', () => { + let s = setup({ startRound: true }) + // mid-action: setDestination is ignored + s = reduce(s, { + type: 'actionStarted', + serverTime: 100, + did: A, + stationId: 'res-wood', + slot: 0, + kind: 'collect', + endsAt: 100 + T1(s), + }) + s = reduce(s, { type: 'setDestination', serverTime: 200, did: A, target: { x: 80, y: 0 } }) + expect(s.players[A]?.path).toHaveLength(0) + + // and: starting an action while pathing clears the path + let s2 = setup({ startRound: true }) + s2 = reduce(s2, { type: 'setDestination', serverTime: 50, did: B, target: { x: 80, y: 0 } }) + expect(s2.players[B]?.path.length).toBeGreaterThan(0) + s2 = reduce(s2, { + type: 'actionStarted', + serverTime: 60, + did: B, + stationId: 'res-wood', + slot: 0, + kind: 'collect', + endsAt: 60 + T1(s2), + }) + expect(s2.players[B]?.path).toHaveLength(0) + }) +}) + describe('station activity + slots', () => { it('collect at a working station grants a real resource and frees the slot with cooldown', () => { let s = setup({ startRound: true }) diff --git a/engine/reducer.ts b/engine/reducer.ts index 5d1036a..117ef3d 100644 --- a/engine/reducer.ts +++ b/engine/reducer.ts @@ -7,22 +7,27 @@ // - `state.seq` counts applied actions (the subscription cursor). // - Never mutates the input state; always returns a new object for anything that changed. +import { aStar } from './nav.js' import type { Action } from './actions.js' import type { Carry, Did, + Facing, GameState, Player, Slot, Station, StationState, + Vec, } from './types.js' export function reduce(state: GameState | undefined, action: Action): GameState { if (action.type === 'gameCreated') return createGame(action) if (!state) throw new Error('first action applied to the engine must be gameCreated') const now = Math.max(state.now, action.serverTime) - const next = apply({ ...state, now }, action, now) + // integrate path-following over the elapsed time, then apply the action's own effect + const moved = advanceMovement(state, now) + const next = apply({ ...moved, now }, action, now) return { ...next, seq: next.seq + 1 } } @@ -60,6 +65,7 @@ function createGame( debris: {}, vote: null, outcome: null, + nav: action.nav ?? null, seq: 1, } } @@ -81,6 +87,8 @@ function apply(state: GameState, action: Action, now: number): GameState { facing: 'south', carrying: null, active: null, + dest: null, + path: [], } return { ...state, players: { ...state.players, [action.did]: player } } } @@ -101,6 +109,8 @@ function apply(state: GameState, action: Action, now: number): GameState { ...p, role: action.roles[did] ?? p.role, pos: action.spawns[did] ?? p.pos, + dest: null, + path: [], } } return { @@ -113,15 +123,33 @@ function apply(state: GameState, action: Action, now: number): GameState { } case 'playerMoved': { + // low-level teleport (spawns/debug): set position directly and cancel any path const p = state.players[action.did] if (!p || p.status !== 'alive' || p.active) return state return setPlayer(state, action.did, (pl) => ({ ...pl, pos: action.pos, facing: action.facing, + dest: null, + path: [], })) } + case 'setDestination': { + const p = state.players[action.did] + if (!p || p.status !== 'alive' || p.active) return state + // no nav grid → fall back to a straight line to the target + const path = state.nav + ? aStar(state.nav, p.pos, action.target) + : [action.target] + if (path === null || path.length === 0) { + // unreachable, or already there: clear any movement + return setPlayer(state, action.did, (pl) => ({ ...pl, dest: null, path: [] })) + } + const last = path[path.length - 1]! + return setPlayer(state, action.did, (pl) => ({ ...pl, dest: last, path })) + } + case 'actionStarted': { const p = state.players[action.did] const station = state.stations[action.stationId] @@ -132,6 +160,8 @@ function apply(state: GameState, action: Action, now: number): GameState { if (!slot || slot.occupantDid !== null) return state const withPlayer = setPlayer(state, action.did, (pl) => ({ ...pl, + dest: null, // stop moving to act + path: [], active: { kind: action.kind, stationId: action.stationId, @@ -234,6 +264,8 @@ function apply(state: GameState, action: Action, now: number): GameState { status: 'kicked', active: null, carrying: null, + dest: null, + path: [], })) } } @@ -357,6 +389,63 @@ function applyResolve( return setPlayer(next, did, (pl) => ({ ...pl, active: null, carrying: carry })) } +// --- movement integration --------------------------------------------------- + +/** Advance every moving player along their path for the elapsed time (state.now -> toNow). */ +function advanceMovement(state: GameState, toNow: number): GameState { + const dt = toNow - state.now + if (dt <= 0) return state + const step = (state.config.moveSpeedPxPerSec * dt) / 1000 + let players = state.players + let changed = false + for (const [did, p] of Object.entries(state.players)) { + if (p.status !== 'alive' || p.path.length === 0) continue + const moved = stepAlongPath(p, step) + if (moved !== p) { + if (!changed) { + players = { ...state.players } + changed = true + } + players[did] = moved + } + } + return changed ? { ...state, players } : state +} + +/** Move a player up to `distance` px along their waypoint path, consuming reached waypoints. */ +function stepAlongPath(p: Player, distance: number): Player { + let pos: Vec = p.pos + let facing: Facing = p.facing + let remaining = distance + let i = 0 + while (i < p.path.length && remaining > 0) { + const wp = p.path[i]! + const dx = wp.x - pos.x + const dy = wp.y - pos.y + const d = Math.hypot(dx, dy) + facing = dirOf(dx, dy, facing) + if (d <= remaining) { + pos = wp + remaining -= d + i++ + } else { + const t = remaining / d + pos = { x: pos.x + dx * t, y: pos.y + dy * t } + remaining = 0 + } + } + const path = i > 0 ? p.path.slice(i) : p.path + const arrived = path.length === 0 + return { ...p, pos, facing, path, dest: arrived ? null : p.dest } +} + +/** Facing from a movement vector — vertical wins ties, matching the Pixi walker. */ +function dirOf(dx: number, dy: number, fallback: Facing): Facing { + if (dx === 0 && dy === 0) return fallback + if (Math.abs(dy) >= Math.abs(dx)) return dy < 0 ? 'north' : 'south' + return dx < 0 ? 'west' : 'east' +} + // --- immutable helpers ------------------------------------------------------ function setPlayer( diff --git a/engine/selectors.test.ts b/engine/selectors.test.ts index f9338a7..71e7606 100644 --- a/engine/selectors.test.ts +++ b/engine/selectors.test.ts @@ -25,6 +25,7 @@ function smallGame(): GameState { slotCooldownMs: 3_000, debrisTtlMs: 15_000, voteDurationMs: 45_000, + moveSpeedPxPerSec: 140, } let s = reduce(undefined, { type: 'gameCreated', diff --git a/engine/selectors.ts b/engine/selectors.ts index de31220..3e24b60 100644 --- a/engine/selectors.ts +++ b/engine/selectors.ts @@ -11,6 +11,7 @@ import type { Outcome, ResourceId, StationState, + Vec, } from './types.js' // --- projection (redaction) ------------------------------------------------- @@ -40,6 +41,9 @@ export interface ProjectedPlayer { facing: GameState['players'][string]['facing'] carrying: ProjectedCarry | null active: ProjectedActive | null + /** movement target/path — only your own (others are conveyed by streamed position updates) */ + dest: Vec | null + path: Vec[] } export interface ProjectedStation { @@ -110,6 +114,8 @@ export function projectFor(state: GameState, viewer: Did | null): ProjectedState kind: seeSecret ? p.active.kind : null, } : null, + dest: seeSecret ? p.dest : null, + path: seeSecret ? p.path : [], } } diff --git a/engine/testutil.ts b/engine/testutil.ts index ed947e5..d71bf83 100644 --- a/engine/testutil.ts +++ b/engine/testutil.ts @@ -3,8 +3,14 @@ import { reduce } from './reducer.js' import { defaultConfig, stationSlots, timerFor } from './config.js' +import { buildNav } from './nav.js' import type { Action } from './actions.js' -import type { ActivityKind, GameState, StationSeed, Vec } from './types.js' +import type { ActivityKind, GameState, NavGrid, StationSeed, Vec } from './types.js' + +/** A wide-open nav grid (no obstacles) covering the standard test map. */ +export function openNav(): NavGrid { + return buildNav([], { cellSize: 16, bounds: { x: -100, y: -100, w: 600, h: 400 } }) +} export const DIDS = ['did:s', 'did:a', 'did:b', 'did:c'] as const @@ -46,6 +52,7 @@ export function setup(opts: SetupOpts = {}): GameState { code: 'ABCD', config, stations: standardStations(playerCount, config.resources), + nav: openNav(), }) dids.forEach((did, i) => { s = reduce(s, { diff --git a/engine/types.ts b/engine/types.ts index d0fdb95..0f3b6c2 100644 --- a/engine/types.ts +++ b/engine/types.ts @@ -37,6 +37,18 @@ export interface Vec { y: number } +/** A walkability grid for pathfinding (built from the level's collision rects — see nav.ts). */ +export interface NavGrid { + cellSize: number + cols: number + rows: number + /** world-px position of the top-left corner of cell (0,0) */ + originX: number + originY: number + /** row-major flags, blocked[row*cols + col] === 1 means impassable */ + blocked: number[] +} + export interface GameConfig { /** Ordered resource ids, e.g. ['wood','stone','wheat','ore']. */ resources: ResourceId[] @@ -53,6 +65,8 @@ export interface GameConfig { slotCooldownMs: number debrisTtlMs: number voteDurationMs: number + /** walk speed along a path, world px per second */ + moveSpeedPxPerSec: number } export interface Slot { @@ -87,6 +101,10 @@ export interface Player { facing: Facing carrying: Carry | null active: ActiveAction | null + /** current pathfinding destination (world px), or null when idle/arrived */ + dest: Vec | null + /** remaining waypoints (world px) from the next one to `dest`; player heads toward path[0] */ + path: Vec[] } export interface Station { @@ -137,6 +155,8 @@ export interface GameState { debris: Record vote: Vote | null outcome: Outcome | null + /** the level's navigation grid for pathfinding; null until provided at gameCreated */ + nav: NavGrid | null /** count of actions applied — serves as the subscription cursor */ seq: number } -- 2.54.0