diff --git a/index.html b/index.html
index 25b8aed..43d295e 100644
--- a/index.html
+++ b/index.html
@@ -4,6 +4,7 @@
Pot Patrol
+
diff --git a/src/actors/link.js b/src/actors/link.js
new file mode 100644
index 0000000..78be0c4
--- /dev/null
+++ b/src/actors/link.js
@@ -0,0 +1,97 @@
+// Link: computer-controlled. Milestones 4-5-8 slice:
+// seek -> walk 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.
+
+import { TILE, LINK_SPEED } from "../config.js";
+import { visual } from "../sprites.js";
+import { throwPot } from "../pots.js";
+
+const INTERACT_DIST = TILE * 0.8;
+
+export function addLink(k, world, onLeave) {
+ const p = world.tc(world.level.linkSpawn);
+ const link = k.add([
+ ...visual(k, "link"),
+ k.pos(p),
+ k.area(),
+ k.body(),
+ k.state("seek", ["seek", "throw", "leave"]),
+ "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;
+ };
+
+ const nearestPot = () => {
+ const pots = k.get("pot");
+ let best = null;
+ let bestD = Infinity;
+ for (const pot of pots) {
+ const d = pot.pos.dist(link.pos);
+ if (d < bestD) {
+ bestD = d;
+ best = pot;
+ }
+ }
+ return best;
+ };
+
+ // --- SEEK ---
+ link.onStateUpdate("seek", () => {
+ const pot = nearestPot();
+ if (!pot) {
+ link.enterState("leave");
+ return;
+ }
+ if (stepToward(pot.pos) <= INTERACT_DIST) {
+ link.target = pot;
+ link.enterState("throw");
+ }
+ });
+
+ // --- THROW ---
+ link.onStateEnter("throw", () => {
+ const pot = link.target;
+ if (!pot || pot.destroyed) {
+ link.enterState("seek");
+ return;
+ }
+ const at = pot.pos.clone();
+ pot.destroy(); // picked up
+ link.throwing = true;
+ throwPot(k, world, at, () => {
+ link.throwing = false;
+ link.enterState("seek");
+ });
+ });
+ // Stand still while the throw animation plays.
+
+ // --- LEAVE ---
+ link.onStateEnter("leave", () => {
+ 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();
+ }
+ }
+ });
+
+ link.onUpdate(() => {
+ link.z = link.pos.y;
+ });
+
+ return link;
+}
diff --git a/src/actors/player.js b/src/actors/player.js
new file mode 100644
index 0000000..c190f03
--- /dev/null
+++ b/src/actors/player.js
@@ -0,0 +1,37 @@
+// The player: input-driven, collides with solid obstacles/walls, cleans shards.
+
+import { PLAYER_SPEED } from "../config.js";
+import { visual } from "../sprites.js";
+
+export function addPlayer(k, world) {
+ const p = world.tc(world.level.playerSpawn);
+ const player = k.add([
+ ...visual(k, "player"),
+ k.pos(p),
+ k.area(),
+ k.body(), // dynamic body: pushed out of static obstacles/walls
+ "player",
+ ]);
+
+ player.onUpdate(() => {
+ const dir = k.vec2(0, 0);
+ if (k.isKeyDown("left") || k.isKeyDown("a")) dir.x -= 1;
+ if (k.isKeyDown("right") || k.isKeyDown("d")) dir.x += 1;
+ if (k.isKeyDown("up") || k.isKeyDown("w")) dir.y -= 1;
+ if (k.isKeyDown("down") || k.isKeyDown("s")) dir.y += 1;
+ if (dir.x !== 0 || dir.y !== 0) {
+ player.move(dir.unit().scale(PLAYER_SPEED)); // unit() => diagonals aren't faster
+ }
+ player.z = player.pos.y; // depth sort
+ });
+
+ // Sweep up shards on contact.
+ player.onCollide("shard", (s) => {
+ if (s.cleaned) return;
+ s.cleaned = true;
+ s.destroy();
+ world.onShardsChanged();
+ });
+
+ return player;
+}
diff --git a/src/main.js b/src/main.js
index 37fa65b..3ad5aa5 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,6 +1,7 @@
import kaplay from "https://unpkg.com/kaplay@3001.0.19/dist/kaplay.mjs";
import { CANVAS_W, CANVAS_H } from "./config.js";
import { registerGameScene } from "./scenes/game.js";
+import { registerEndScenes } from "./scenes/end.js";
const k = kaplay({
width: CANVAS_W,
@@ -15,6 +16,7 @@ const k = kaplay({
k.setGravity(0);
registerGameScene(k);
+registerEndScenes(k);
k.go("game");
// Dev-only handle for headless checks / console poking: open with ?debug.
diff --git a/src/pots.js b/src/pots.js
new file mode 100644
index 0000000..38261ad
--- /dev/null
+++ b/src/pots.js
@@ -0,0 +1,121 @@
+// Pot throwing physics: arc a pot 2-3 tiles, shatter on landing, scatter shards.
+
+import { TILE, COLS, ROWS, THROW_TILES, SHARDS_PER_POT, Z } from "./config.js";
+import { visual } from "./sprites.js";
+
+const DIRS8 = [
+ [1, 0], [-1, 0], [0, 1], [0, -1],
+ [1, 1], [1, -1], [-1, 1], [-1, -1],
+];
+
+const randint = (k, [min, max]) => min + Math.floor(k.rand() * (max - min + 1));
+const inBounds = (x, y) => x >= 0 && y >= 0 && x < COLS && y < ROWS;
+
+// Throw from `fromPos`, run the arc, shatter, spawn shards. Calls onDone() when
+// the pot has landed and shattered.
+export function throwPot(k, world, fromPos, onDone) {
+ const from = world.toTile(fromPos);
+ const [dx, dy] = k.choose(DIRS8);
+ const dist = randint(k, THROW_TILES);
+
+ // Walk outward to the requested distance, stopping at the last passable tile.
+ let land = { ...from };
+ for (let step = 1; step <= dist; step++) {
+ const nx = from.x + dx * step;
+ const ny = from.y + dy * step;
+ if (!inBounds(nx, ny) || !world.walkable(nx, ny)) break;
+ land = { x: nx, y: ny };
+ }
+
+ const start = fromPos.clone();
+ const end = world.tc(land);
+ const dur = 0.45;
+ const peak = 46; // arc height in px
+
+ // Shadow on the ground + the flying pot above it.
+ const shadow = k.add([
+ k.circle(9),
+ k.pos(end),
+ k.anchor("center"),
+ k.scale(1, 0.5), // flatten into an oval
+ k.color(0, 0, 0),
+ k.opacity(0.25),
+ k.z(Z.GROUND + 1),
+ ]);
+ const flying = k.add([...visual(k, "pot"), k.pos(start), k.z(Z.FLYING)]);
+
+ k.tween(0, 1, dur, (t) => {
+ const x = k.lerp(start.x, end.x, t);
+ const y = k.lerp(start.y, end.y, t);
+ const arc = peak * 4 * t * (1 - t); // parabola, 0 at ends
+ flying.pos = k.vec2(x, y - arc);
+ shadow.opacity = 0.15 + 0.15 * (1 - Math.abs(0.5 - t) * 2);
+ }, k.easings.linear).then(() => {
+ flying.destroy();
+ shadow.destroy();
+ shatter(k, world, land, end);
+ onDone && onDone();
+ });
+}
+
+function shatter(k, world, landTile, landPos) {
+ k.shake(4);
+ burst(k, landPos);
+
+ const count = randint(k, SHARDS_PER_POT);
+ // Candidate tiles: landing tile first, then a shuffled set of neighbors.
+ const neighbors = [
+ [0, 0], [1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1],
+ ];
+ const candidates = [];
+ for (const [ox, oy] of neighbors) {
+ const x = landTile.x + ox;
+ const y = landTile.y + oy;
+ if (inBounds(x, y) && world.walkable(x, y)) candidates.push({ x, y });
+ }
+ // Keep landing tile at the front; shuffle the rest (seeded).
+ for (let i = candidates.length - 1; i > 1; i--) {
+ const j = 1 + Math.floor(k.rand() * i);
+ [candidates[i], candidates[j]] = [candidates[j], candidates[i]];
+ }
+
+ let placed = 0;
+ for (const t of candidates) {
+ if (placed >= count) break;
+ spawnShard(k, world, t);
+ placed++;
+ }
+ world.onShardsChanged();
+}
+
+// A quick asset-free shatter flash.
+function burst(k, pos) {
+ const ring = k.add([
+ k.circle(6),
+ k.pos(pos),
+ k.anchor("center"),
+ k.color(255, 240, 210),
+ k.opacity(0.9),
+ k.z(Z.FLYING),
+ ]);
+ k.tween(6, 22, 0.25, (r) => (ring.radius = r), k.easings.easeOutQuad);
+ k.tween(0.9, 0, 0.25, (o) => (ring.opacity = o), k.easings.linear).then(() =>
+ ring.destroy()
+ );
+}
+
+function spawnShard(k, world, tile) {
+ const p = world.tc(tile);
+ // Small random jitter so multiple shards don't perfectly overlap tile centers.
+ const jx = (k.rand() - 0.5) * TILE * 0.4;
+ const jy = (k.rand() - 0.5) * TILE * 0.4;
+ const s = k.add([
+ ...visual(k, "shard"),
+ k.pos(p.x + jx, p.y + jy),
+ k.area(),
+ k.z(p.y),
+ k.rotate(k.rand() * 360),
+ "shard",
+ ]);
+ return s;
+}
diff --git a/src/scenes/end.js b/src/scenes/end.js
new file mode 100644
index 0000000..6ac5290
--- /dev/null
+++ b/src/scenes/end.js
@@ -0,0 +1,45 @@
+// Win / lose screens. Show the result and offer retry (same seed) or a new scene.
+
+import { CANVAS_W, CANVAS_H } from "../config.js";
+
+export function registerEndScenes(k) {
+ const screen = (title, subtitle, tint) => (opts = {}) => {
+ k.add([k.rect(CANVAS_W, CANVAS_H), k.pos(0, 0), k.color(...tint)]);
+ k.add([
+ k.text(title, { size: 48 }),
+ k.pos(CANVAS_W / 2, CANVAS_H / 2 - 60),
+ k.anchor("center"),
+ k.color(255, 255, 255),
+ ]);
+ k.add([
+ k.text(subtitle, { size: 20, width: CANVAS_W - 120, align: "center" }),
+ k.pos(CANVAS_W / 2, CANVAS_H / 2 + 10),
+ k.anchor("center"),
+ k.color(235, 235, 235),
+ ]);
+ k.add([
+ k.text("R: retry same scene N: new scene", { size: 18 }),
+ k.pos(CANVAS_W / 2, CANVAS_H - 50),
+ k.anchor("center"),
+ k.color(220, 220, 220),
+ k.opacity(0.85),
+ ]);
+
+ k.onKeyPress("r", () => k.go("game", { seed: opts.seed }));
+ k.onKeyPress("n", () => k.go("game"));
+ };
+
+ k.scene(
+ "win",
+ screen("You cleaned up!", "Every shard swept before Link slipped away.", [30, 90, 60])
+ );
+ k.scene(
+ "lose",
+ (opts = {}) =>
+ screen(
+ "Too slow!",
+ `Link left with ${opts.left ?? 0} shard${opts.left === 1 ? "" : "s"} still on the ground.`,
+ [110, 50, 50]
+ )(opts)
+ );
+}
diff --git a/src/scenes/game.js b/src/scenes/game.js
index 551d3af..2d24f99 100644
--- a/src/scenes/game.js
+++ b/src/scenes/game.js
@@ -1,18 +1,30 @@
-// The main scene. Milestones 1-2: build a seeded procedural level and render it.
-// Actors are placed but static for now (movement/AI arrive in later milestones).
+// The main scene: build the procedural level, spawn actors, run the core loop.
import { TILE, COLS, ROWS, Z } from "../config.js";
import { generate } from "../gen.js";
import { visual } from "../sprites.js";
+import { addPlayer } from "../actors/player.js";
+import { addLink } from "../actors/link.js";
export function registerGameScene(k) {
k.scene("game", (opts = {}) => {
const seed = opts.seed ?? Math.floor(Math.random() * 1e9);
const level = generate(k, seed);
+ let roundOver = false;
// Pixel center of a tile.
const tc = (t) => k.vec2((t.x + 0.5) * TILE, (t.y + 0.5) * TILE);
+ // Shared context handed to actors / pot physics.
+ const world = {
+ level,
+ tc,
+ toTile: (p) => ({ x: Math.floor(p.x / TILE), y: Math.floor(p.y / TILE) }),
+ walkable: (x, y) =>
+ x >= 0 && y >= 0 && x < COLS && y < ROWS && level.walkable[y][x],
+ onShardsChanged: () => updateHud(),
+ };
+
// --- Ground (checkerboard of two greens) ---
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
@@ -26,7 +38,7 @@ export function registerGameScene(k) {
}
}
- // Border walls (every non-walkable border tile except the exit gap).
+ // --- Border walls (solid), exit gap left open ---
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
const isBorder = x === 0 || y === 0 || x === COLS - 1 || y === ROWS - 1;
@@ -37,7 +49,10 @@ export function registerGameScene(k) {
k.pos(x * TILE, y * TILE),
k.color(72, 66, 60),
k.outline(1, k.rgb(52, 48, 42)),
+ k.area(),
+ k.body({ isStatic: true }),
k.z(Z.GROUND + 2),
+ "wall",
]);
}
}
@@ -52,51 +67,59 @@ export function registerGameScene(k) {
k.z(Z.GROUND + 1),
]);
- // Helper: add a world entity anchored at its tile center, y-sorted.
- const spawn = (kind, tile, ...tags) => {
+ // Add a y-sorted world entity anchored at its tile center.
+ const spawn = (kind, tile, extra = [], ...tags) => {
const p = tc(tile);
- return k.add([...visual(k, kind), k.pos(p), k.z(p.y), ...tags]);
+ return k.add([...visual(k, kind), k.pos(p), k.z(p.y), ...extra, ...tags]);
};
// --- Static world ---
- level.obstacles.forEach((t) => spawn("obstacle", t, "obstacle"));
- level.grass.forEach((t) => spawn("grass", t, "grass"));
- level.pots.forEach((t) => spawn("pot", t, "pot"));
- level.chickens.forEach((t) => spawn("chicken", t, "chicken"));
+ level.obstacles.forEach((t) =>
+ spawn("obstacle", t, [k.area(), k.body({ isStatic: true })], "obstacle")
+ );
+ level.grass.forEach((t) => spawn("grass", t, [k.area()], "grass"));
+ level.pots.forEach((t) => spawn("pot", t, [k.area()], "pot"));
+ level.chickens.forEach((t) => spawn("chicken", t, [k.area()], "chicken"));
- // --- Actors (static this milestone) ---
- spawn("link", level.linkSpawn, "link");
- spawn("player", level.playerSpawn, "player");
+ // --- Actors ---
+ addPlayer(k, world);
+ addLink(k, world, () => endRound());
- // --- Dev HUD ---
- k.add([
- k.text(
- `Pot Patrol seed:${seed}\n` +
- `pots:${level.pots.length} grass:${level.grass.length} ` +
- `chickens:${level.chickens.length} obstacles:${level.obstacles.length} ` +
- `exit:${level.exitEdge}`,
- { size: 16 }
- ),
+ // --- HUD ---
+ const hud = k.add([
+ k.text("", { size: 16 }),
k.pos(10, 8),
k.color(255, 255, 255),
k.fixed(),
k.z(Z.HUD),
]);
+ function updateHud() {
+ const shards = k.get("shard").length;
+ const pots = k.get("pot").length;
+ hud.text = `Shards: ${shards} Pots left: ${pots} seed:${seed}`;
+ // Early win: Link has no pots left and the board is clear.
+ if (!roundOver && pots === 0 && shards === 0) endRound();
+ }
+ updateHud();
+
k.add([
- k.text("R: new scene", { size: 14 }),
- k.pos(10, CANVAS_HUD_BOTTOM()),
- k.color(230, 230, 230),
+ k.text("WASD / arrows to move · sweep shards · R: new scene", { size: 13 }),
+ k.pos(10, ROWS * TILE - 22),
+ k.color(235, 235, 235),
k.opacity(0.8),
k.fixed(),
k.z(Z.HUD),
]);
- // Regenerate with a fresh seed.
+ // End-of-round evaluation.
+ function endRound() {
+ if (roundOver) return;
+ roundOver = true;
+ const left = k.get("shard").length;
+ if (left === 0) k.go("win", { seed });
+ else k.go("lose", { seed, left });
+ }
+
k.onKeyPress("r", () => k.go("game"));
});
}
-
-// Small helper kept out of the layout math above.
-function CANVAS_HUD_BOTTOM() {
- return ROWS * TILE - 24;
-}