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