feat: basic ux

This commit is contained in:
2026-07-27 10:21:36 -04:00
parent 6bc16cb135
commit 20e6f3520a
7 changed files with 357 additions and 31 deletions

View File

@ -4,6 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pot Patrol</title>
<link rel="icon" href="data:," />
<link rel="stylesheet" href="styles.css" />
</head>
<body>

97
src/actors/link.js Normal file
View File

@ -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;
}

37
src/actors/player.js Normal file
View File

@ -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;
}

View File

@ -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.

121
src/pots.js Normal file
View File

@ -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;
}

45
src/scenes/end.js Normal file
View File

@ -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)
);
}

View File

@ -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;
}