From 37c534ef0f387b951eb7235bcaa34f46ef3ad9c8 Mon Sep 17 00:00:00 2001 From: Lexical Bits Date: Mon, 27 Jul 2026 10:25:09 -0400 Subject: [PATCH] feat: pathfinding --- src/actors/link.js | 67 +++++++++++++++++++++++++++++++++------------- src/pathfind.js | 50 ++++++++++++++++++++++++++++++++++ src/scenes/game.js | 4 ++- 3 files changed, 101 insertions(+), 20 deletions(-) create mode 100644 src/pathfind.js diff --git a/src/actors/link.js b/src/actors/link.js index 78be0c4..6e20d97 100644 --- a/src/actors/link.js +++ b/src/actors/link.js @@ -1,14 +1,18 @@ // Link: computer-controlled. Milestones 4-5-8 slice: -// seek -> walk to the nearest pot +// seek -> path to the nearest pot // throw -> pick it up and hurl it (pots.js handles the arc + shatter) -// leave -> no pots left: walk to the exit gap and off-screen (ends the round) -// Distractions (grass/chickens) arrive in a later milestone. +// leave -> no pots left: path to the exit gap and off-screen (ends the round) +// Movement follows a BFS tile path (pathfind.js) so Link routes around terrain +// instead of getting caught on it. Distractions arrive in a later milestone. import { TILE, LINK_SPEED } from "../config.js"; import { visual } from "../sprites.js"; import { throwPot } from "../pots.js"; +import { findPath } from "../pathfind.js"; const INTERACT_DIST = TILE * 0.8; +const WAYPOINT_DIST = TILE * 0.3; // how close before advancing to the next tile +const REPATH_INTERVAL = 0.6; // seconds; recompute to self-heal if bumped export function addLink(k, world, onLeave) { const p = world.tc(world.level.linkSpawn); @@ -21,19 +25,38 @@ export function addLink(k, world, onLeave) { "link", ]); - // Move toward a world position; returns distance remaining. - const stepToward = (target) => { - const d = target.sub(link.pos); - const dist = d.len(); - if (dist > 1) link.move(d.unit().scale(LINK_SPEED)); - return dist; + link.path = null; + link.pathGoal = null; + link.repath = 0; + + // Path to `goalTile` and take one step along it. Recomputes when the goal + // changes, the path is spent, or the refresh timer elapses. + const navTo = (goalTile) => { + link.repath -= k.dt(); + const from = world.toTile(link.pos); + const sameGoal = + link.pathGoal && link.pathGoal.x === goalTile.x && link.pathGoal.y === goalTile.y; + + if (!sameGoal || !link.path || link.path.length === 0 || link.repath <= 0) { + link.path = findPath(world, from, goalTile); + link.pathGoal = { ...goalTile }; + link.repath = REPATH_INTERVAL; + } + + // Advance past any waypoint we've effectively reached. + while (link.path.length && link.pos.dist(world.tc(link.path[0])) <= WAYPOINT_DIST) { + link.path.shift(); + } + + const targetPos = link.path.length ? world.tc(link.path[0]) : world.tc(goalTile); + const d = targetPos.sub(link.pos); + if (d.len() > 1) link.move(d.unit().scale(LINK_SPEED)); }; const nearestPot = () => { - const pots = k.get("pot"); let best = null; let bestD = Infinity; - for (const pot of pots) { + for (const pot of k.get("pot")) { const d = pot.pos.dist(link.pos); if (d < bestD) { bestD = d; @@ -43,6 +66,12 @@ export function addLink(k, world, onLeave) { return best; }; + const clearPath = () => { + link.path = null; + link.pathGoal = null; + link.repath = 0; + }; + // --- SEEK --- link.onStateUpdate("seek", () => { const pot = nearestPot(); @@ -50,14 +79,17 @@ export function addLink(k, world, onLeave) { link.enterState("leave"); return; } - if (stepToward(pot.pos) <= INTERACT_DIST) { + if (pot.pos.dist(link.pos) <= INTERACT_DIST) { link.target = pot; link.enterState("throw"); + return; } + navTo(world.toTile(pot.pos)); }); // --- THROW --- link.onStateEnter("throw", () => { + clearPath(); const pot = link.target; if (!pot || pot.destroyed) { link.enterState("seek"); @@ -65,28 +97,25 @@ export function addLink(k, world, onLeave) { } const at = pot.pos.clone(); pot.destroy(); // picked up - link.throwing = true; - throwPot(k, world, at, () => { - link.throwing = false; - link.enterState("seek"); - }); + throwPot(k, world, at, () => link.enterState("seek")); }); // Stand still while the throw animation plays. // --- LEAVE --- link.onStateEnter("leave", () => { + clearPath(); link.leaving = true; }); link.onStateUpdate("leave", () => { const exitPos = world.tc(world.level.exit); - stepToward(exitPos); - // Once he reaches / passes the exit gap, he's gone. if (link.pos.dist(exitPos) < TILE * 0.5) { if (!link.gone) { link.gone = true; onLeave && onLeave(); } + return; } + navTo(world.level.exit); }); link.onUpdate(() => { diff --git a/src/pathfind.js b/src/pathfind.js new file mode 100644 index 0000000..4566c25 --- /dev/null +++ b/src/pathfind.js @@ -0,0 +1,50 @@ +// Breadth-first shortest path over walkable tiles (4-directional). +// +// Link steers directly toward his target, which means solid terrain between him +// and a pot would trap him. Routing along a tile path of only walkable tiles +// keeps him off obstacles/walls, and because steps are orthogonal he never cuts +// a corner into a solid tile. + +const key = (x, y) => `${x},${y}`; +const NEIGHBORS = [ + [1, 0], + [-1, 0], + [0, 1], + [0, -1], +]; + +// Returns the list of tiles AFTER `from` up to and including `goal`, or [] if +// the goal is unreachable / equals the start (callers fall back to direct steer). +export function findPath(world, from, goal) { + if (from.x === goal.x && from.y === goal.y) return []; + if (!world.walkable(goal.x, goal.y)) return []; + + const prev = new Map(); + const seen = new Set([key(from.x, from.y)]); + const queue = [from]; + let head = 0; + + while (head < queue.length) { + const cur = queue[head++]; + if (cur.x === goal.x && cur.y === goal.y) { + const path = []; + let c = cur; + while (!(c.x === from.x && c.y === from.y)) { + path.push(c); + c = prev.get(key(c.x, c.y)); + } + return path.reverse(); + } + for (const [dx, dy] of NEIGHBORS) { + const nx = cur.x + dx; + const ny = cur.y + dy; + if (!world.walkable(nx, ny)) continue; + const kk = key(nx, ny); + if (seen.has(kk)) continue; + seen.add(kk); + prev.set(kk, { ...cur }); + queue.push({ x: nx, y: ny }); + } + } + return []; +} diff --git a/src/scenes/game.js b/src/scenes/game.js index 2d24f99..ce704ce 100644 --- a/src/scenes/game.js +++ b/src/scenes/game.js @@ -8,7 +8,9 @@ import { addLink } from "../actors/link.js"; export function registerGameScene(k) { k.scene("game", (opts = {}) => { - const seed = opts.seed ?? Math.floor(Math.random() * 1e9); + const urlSeed = Number(new URLSearchParams(location.search).get("seed")); + const seed = + opts.seed ?? (Number.isFinite(urlSeed) && urlSeed > 0 ? urlSeed : Math.floor(Math.random() * 1e9)); const level = generate(k, seed); let roundOver = false;