feat: pathfinding
This commit is contained in:
@ -1,14 +1,18 @@
|
|||||||
// Link: computer-controlled. Milestones 4-5-8 slice:
|
// 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)
|
// 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)
|
// leave -> no pots left: path to the exit gap and off-screen (ends the round)
|
||||||
// Distractions (grass/chickens) arrive in a later milestone.
|
// 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 { TILE, LINK_SPEED } from "../config.js";
|
||||||
import { visual } from "../sprites.js";
|
import { visual } from "../sprites.js";
|
||||||
import { throwPot } from "../pots.js";
|
import { throwPot } from "../pots.js";
|
||||||
|
import { findPath } from "../pathfind.js";
|
||||||
|
|
||||||
const INTERACT_DIST = TILE * 0.8;
|
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) {
|
export function addLink(k, world, onLeave) {
|
||||||
const p = world.tc(world.level.linkSpawn);
|
const p = world.tc(world.level.linkSpawn);
|
||||||
@ -21,19 +25,38 @@ export function addLink(k, world, onLeave) {
|
|||||||
"link",
|
"link",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Move toward a world position; returns distance remaining.
|
link.path = null;
|
||||||
const stepToward = (target) => {
|
link.pathGoal = null;
|
||||||
const d = target.sub(link.pos);
|
link.repath = 0;
|
||||||
const dist = d.len();
|
|
||||||
if (dist > 1) link.move(d.unit().scale(LINK_SPEED));
|
// Path to `goalTile` and take one step along it. Recomputes when the goal
|
||||||
return dist;
|
// 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 nearestPot = () => {
|
||||||
const pots = k.get("pot");
|
|
||||||
let best = null;
|
let best = null;
|
||||||
let bestD = Infinity;
|
let bestD = Infinity;
|
||||||
for (const pot of pots) {
|
for (const pot of k.get("pot")) {
|
||||||
const d = pot.pos.dist(link.pos);
|
const d = pot.pos.dist(link.pos);
|
||||||
if (d < bestD) {
|
if (d < bestD) {
|
||||||
bestD = d;
|
bestD = d;
|
||||||
@ -43,6 +66,12 @@ export function addLink(k, world, onLeave) {
|
|||||||
return best;
|
return best;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearPath = () => {
|
||||||
|
link.path = null;
|
||||||
|
link.pathGoal = null;
|
||||||
|
link.repath = 0;
|
||||||
|
};
|
||||||
|
|
||||||
// --- SEEK ---
|
// --- SEEK ---
|
||||||
link.onStateUpdate("seek", () => {
|
link.onStateUpdate("seek", () => {
|
||||||
const pot = nearestPot();
|
const pot = nearestPot();
|
||||||
@ -50,14 +79,17 @@ export function addLink(k, world, onLeave) {
|
|||||||
link.enterState("leave");
|
link.enterState("leave");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (stepToward(pot.pos) <= INTERACT_DIST) {
|
if (pot.pos.dist(link.pos) <= INTERACT_DIST) {
|
||||||
link.target = pot;
|
link.target = pot;
|
||||||
link.enterState("throw");
|
link.enterState("throw");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
navTo(world.toTile(pot.pos));
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- THROW ---
|
// --- THROW ---
|
||||||
link.onStateEnter("throw", () => {
|
link.onStateEnter("throw", () => {
|
||||||
|
clearPath();
|
||||||
const pot = link.target;
|
const pot = link.target;
|
||||||
if (!pot || pot.destroyed) {
|
if (!pot || pot.destroyed) {
|
||||||
link.enterState("seek");
|
link.enterState("seek");
|
||||||
@ -65,28 +97,25 @@ export function addLink(k, world, onLeave) {
|
|||||||
}
|
}
|
||||||
const at = pot.pos.clone();
|
const at = pot.pos.clone();
|
||||||
pot.destroy(); // picked up
|
pot.destroy(); // picked up
|
||||||
link.throwing = true;
|
throwPot(k, world, at, () => link.enterState("seek"));
|
||||||
throwPot(k, world, at, () => {
|
|
||||||
link.throwing = false;
|
|
||||||
link.enterState("seek");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
// Stand still while the throw animation plays.
|
// Stand still while the throw animation plays.
|
||||||
|
|
||||||
// --- LEAVE ---
|
// --- LEAVE ---
|
||||||
link.onStateEnter("leave", () => {
|
link.onStateEnter("leave", () => {
|
||||||
|
clearPath();
|
||||||
link.leaving = true;
|
link.leaving = true;
|
||||||
});
|
});
|
||||||
link.onStateUpdate("leave", () => {
|
link.onStateUpdate("leave", () => {
|
||||||
const exitPos = world.tc(world.level.exit);
|
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.pos.dist(exitPos) < TILE * 0.5) {
|
||||||
if (!link.gone) {
|
if (!link.gone) {
|
||||||
link.gone = true;
|
link.gone = true;
|
||||||
onLeave && onLeave();
|
onLeave && onLeave();
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
navTo(world.level.exit);
|
||||||
});
|
});
|
||||||
|
|
||||||
link.onUpdate(() => {
|
link.onUpdate(() => {
|
||||||
|
|||||||
50
src/pathfind.js
Normal file
50
src/pathfind.js
Normal file
@ -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 [];
|
||||||
|
}
|
||||||
@ -8,7 +8,9 @@ import { addLink } from "../actors/link.js";
|
|||||||
|
|
||||||
export function registerGameScene(k) {
|
export function registerGameScene(k) {
|
||||||
k.scene("game", (opts = {}) => {
|
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);
|
const level = generate(k, seed);
|
||||||
let roundOver = false;
|
let roundOver = false;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user