From 6bc16cb135f254362e04e485044eb8c65e347887 Mon Sep 17 00:00:00 2001 From: Lexical Bits Date: Mon, 27 Jul 2026 10:16:03 -0400 Subject: [PATCH] feat: skeleton --- .gitignore | 3 + index.html | 12 ++++ src/config.js | 31 +++++++++ src/gen.js | 157 +++++++++++++++++++++++++++++++++++++++++++++ src/main.js | 21 ++++++ src/scenes/game.js | 102 +++++++++++++++++++++++++++++ src/sprites.js | 26 ++++++++ styles.css | 13 ++++ 8 files changed, 365 insertions(+) create mode 100644 .gitignore create mode 100644 index.html create mode 100644 src/config.js create mode 100644 src/gen.js create mode 100644 src/main.js create mode 100644 src/scenes/game.js create mode 100644 src/sprites.js create mode 100644 styles.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aeda74c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.safeclade +.safeclaude +.claude diff --git a/index.html b/index.html new file mode 100644 index 0000000..25b8aed --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + Pot Patrol + + + + + + diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..e42b1ed --- /dev/null +++ b/src/config.js @@ -0,0 +1,31 @@ +// Tunable constants. Ranges are [min, max] inclusive and rolled per-scene. + +export const TILE = 40; +export const COLS = 24; +export const ROWS = 14; +export const CANVAS_W = TILE * COLS; // 960 +export const CANVAS_H = TILE * ROWS; // 560 + +// Movement (px/sec). Link is slightly faster than the player. +export const PLAYER_SPEED = 130; +export const LINK_SPEED = 150; + +// Per-scene entity counts. +export const OBSTACLE_COUNT = [6, 12]; +export const POT_COUNT = [5, 8]; +export const GRASS_COUNT = [8, 15]; +export const CHICKEN_COUNT = [2, 4]; + +// Gameplay tuning (used from later milestones). +export const THROW_TILES = [2, 3]; +export const SHARDS_PER_POT = [1, 3]; +export const DISTRACT_RADIUS = 4; // tiles +export const FLEE_TIME = 1.5; // seconds + +// Draw layers. World entities use z = pos.y so lower sprites overlap higher +// ones; ground sits below everything, flying pots and HUD above. +export const Z = { + GROUND: -10, + FLYING: 5000, + HUD: 10000, +}; diff --git a/src/gen.js b/src/gen.js new file mode 100644 index 0000000..5ba2f4b --- /dev/null +++ b/src/gen.js @@ -0,0 +1,157 @@ +// Seeded procedural scene generation. +// +// Produces a solvable single-screen level: a walled arena with exactly one exit +// gap, scattered obstacles, and randomly placed pots / grass / chickens — all +// guaranteed reachable from Link's spawn. Returns plain data; scenes/game.js +// turns it into Kaplay objects. + +import { + COLS, + ROWS, + OBSTACLE_COUNT, + POT_COUNT, + GRASS_COUNT, + CHICKEN_COUNT, +} from "./config.js"; + +const key = (x, y) => `${x},${y}`; +const NEIGHBORS = [ + [1, 0], + [-1, 0], + [0, 1], + [0, -1], +]; + +// Inclusive integer in [min, max] using Kaplay's seeded RNG. +function randint(k, [min, max]) { + return min + Math.floor(k.rand() * (max - min + 1)); +} + +// Flood fill of walkable tiles from a start tile (4-directional). +function reachableFrom(walkable, sx, sy) { + const seen = new Set([key(sx, sy)]); + const queue = [[sx, sy]]; + while (queue.length) { + const [x, y] = queue.shift(); + for (const [dx, dy] of NEIGHBORS) { + const nx = x + dx; + const ny = y + dy; + if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue; + if (!walkable[ny][nx] || seen.has(key(nx, ny))) continue; + seen.add(key(nx, ny)); + queue.push([nx, ny]); + } + } + return seen; +} + +// One generation attempt. Returns a level or null if it came out unsolvable. +function attempt(k) { + const walkable = Array.from({ length: ROWS }, () => Array(COLS).fill(true)); + const occupied = new Set(); // tiles that already hold a wall or entity + + // Border walls. + for (let x = 0; x < COLS; x++) { + for (let y = 0; y < ROWS; y++) { + if (x === 0 || y === 0 || x === COLS - 1 || y === ROWS - 1) { + walkable[y][x] = false; + occupied.add(key(x, y)); + } + } + } + + // Carve one exit gap on a random edge. + const exitEdge = k.choose(["top", "bottom", "left", "right"]); + let exit; + if (exitEdge === "top") exit = { x: randint(k, [2, COLS - 3]), y: 0 }; + else if (exitEdge === "bottom") + exit = { x: randint(k, [2, COLS - 3]), y: ROWS - 1 }; + else if (exitEdge === "left") exit = { x: 0, y: randint(k, [2, ROWS - 3]) }; + else exit = { x: COLS - 1, y: randint(k, [2, ROWS - 3]) }; + walkable[exit.y][exit.x] = true; + occupied.delete(key(exit.x, exit.y)); + + // Spawns: Link near the side opposite the exit, player near center. + let linkSpawn; + if (exitEdge === "top") linkSpawn = { x: randint(k, [2, COLS - 3]), y: ROWS - 2 }; + else if (exitEdge === "bottom") linkSpawn = { x: randint(k, [2, COLS - 3]), y: 1 }; + else if (exitEdge === "left") linkSpawn = { x: COLS - 2, y: randint(k, [2, ROWS - 3]) }; + else linkSpawn = { x: 1, y: randint(k, [2, ROWS - 3]) }; + const playerSpawn = { x: Math.floor(COLS / 2), y: Math.floor(ROWS / 2) }; + + // Reserve spawns + their neighbors so nothing spawns on top of an actor. + const reserve = (x, y) => { + occupied.add(key(x, y)); + for (const [dx, dy] of NEIGHBORS) occupied.add(key(x + dx, y + dy)); + }; + reserve(linkSpawn.x, linkSpawn.y); + reserve(playerSpawn.x, playerSpawn.y); + + // Grabs a random free, walkable interior tile (null if it can't find one). + const freeTile = () => { + for (let tries = 0; tries < 400; tries++) { + const x = randint(k, [1, COLS - 2]); + const y = randint(k, [1, ROWS - 2]); + if (walkable[y][x] && !occupied.has(key(x, y))) return { x, y }; + } + return null; + }; + + // Obstacles (block movement). + const obstacles = []; + const obstacleCount = randint(k, OBSTACLE_COUNT); + for (let i = 0; i < obstacleCount; i++) { + const t = freeTile(); + if (!t) break; + walkable[t.y][t.x] = false; + occupied.add(key(t.x, t.y)); + obstacles.push(t); + } + + // Reachability: player and exit must be reachable from Link's spawn. + const reachable = reachableFrom(walkable, linkSpawn.x, linkSpawn.y); + if (!reachable.has(key(playerSpawn.x, playerSpawn.y))) return null; + if (!reachable.has(key(exit.x, exit.y))) return null; + + // Place interactables only on reachable free tiles. + const placeMany = (range) => { + const out = []; + const count = randint(k, range); + for (let i = 0; i < count; i++) { + const t = freeTile(); + if (!t || !reachable.has(key(t.x, t.y))) continue; + occupied.add(key(t.x, t.y)); + out.push(t); + } + return out; + }; + + const pots = placeMany(POT_COUNT); + if (pots.length === 0) return null; // no pots -> no game + const grass = placeMany(GRASS_COUNT); + const chickens = placeMany(CHICKEN_COUNT); + + return { + seed: null, // filled in by generate() + walkable, + exit, + exitEdge, + linkSpawn, + playerSpawn, + obstacles, + pots, + grass, + chickens, + }; +} + +// Generate a solvable level for `seed`, retrying a few times if an attempt comes +// out blocked. Falls back to whatever the last attempt produced. +export function generate(k, seed) { + k.randSeed(seed); + let level = null; + for (let i = 0; i < 12 && !level; i++) level = attempt(k); + if (!level) level = attempt(k); // last resort, accept as-is + level.seed = seed; + return level; +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..37fa65b --- /dev/null +++ b/src/main.js @@ -0,0 +1,21 @@ +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"; + +const k = kaplay({ + width: CANVAS_W, + height: CANVAS_H, + background: [24, 26, 34], + letterbox: true, + global: false, + pixelDensity: 1, +}); + +// No gravity: this is a top-down game. +k.setGravity(0); + +registerGameScene(k); +k.go("game"); + +// Dev-only handle for headless checks / console poking: open with ?debug. +if (location.search.includes("debug")) window.__k = k; diff --git a/src/scenes/game.js b/src/scenes/game.js new file mode 100644 index 0000000..551d3af --- /dev/null +++ b/src/scenes/game.js @@ -0,0 +1,102 @@ +// 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). + +import { TILE, COLS, ROWS, Z } from "../config.js"; +import { generate } from "../gen.js"; +import { visual } from "../sprites.js"; + +export function registerGameScene(k) { + k.scene("game", (opts = {}) => { + const seed = opts.seed ?? Math.floor(Math.random() * 1e9); + const level = generate(k, seed); + + // Pixel center of a tile. + const tc = (t) => k.vec2((t.x + 0.5) * TILE, (t.y + 0.5) * TILE); + + // --- Ground (checkerboard of two greens) --- + for (let y = 0; y < ROWS; y++) { + for (let x = 0; x < COLS; x++) { + const alt = (x + y) % 2 === 0; + k.add([ + k.rect(TILE, TILE), + k.pos(x * TILE, y * TILE), + k.color(alt ? 104 : 96, alt ? 166 : 156, alt ? 96 : 88), + k.z(Z.GROUND), + ]); + } + } + + // Border walls (every non-walkable border tile except the exit gap). + 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; + const isExit = x === level.exit.x && y === level.exit.y; + if (isBorder && !isExit) { + k.add([ + k.rect(TILE, TILE), + k.pos(x * TILE, y * TILE), + k.color(72, 66, 60), + k.outline(1, k.rgb(52, 48, 42)), + k.z(Z.GROUND + 2), + ]); + } + } + } + + // Exit tile marker (walkable gap in the wall). + k.add([ + k.rect(TILE, TILE), + k.pos(level.exit.x * TILE, level.exit.y * TILE), + k.color(210, 196, 120), + k.opacity(0.6), + k.z(Z.GROUND + 1), + ]); + + // Helper: add a world entity anchored at its tile center, y-sorted. + const spawn = (kind, tile, ...tags) => { + const p = tc(tile); + return k.add([...visual(k, kind), k.pos(p), k.z(p.y), ...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")); + + // --- Actors (static this milestone) --- + spawn("link", level.linkSpawn, "link"); + spawn("player", level.playerSpawn, "player"); + + // --- 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 } + ), + k.pos(10, 8), + k.color(255, 255, 255), + k.fixed(), + k.z(Z.HUD), + ]); + k.add([ + k.text("R: new scene", { size: 14 }), + k.pos(10, CANVAS_HUD_BOTTOM()), + k.color(230, 230, 230), + k.opacity(0.8), + k.fixed(), + k.z(Z.HUD), + ]); + + // Regenerate with a fresh seed. + k.onKeyPress("r", () => k.go("game")); + }); +} + +// Small helper kept out of the layout math above. +function CANVAS_HUD_BOTTOM() { + return ROWS * TILE - 24; +} diff --git a/src/sprites.js b/src/sprites.js new file mode 100644 index 0000000..88f0e67 --- /dev/null +++ b/src/sprites.js @@ -0,0 +1,26 @@ +// Placeholder visuals. Each factory returns the drawing components for a kind of +// thing (shape + color + outline, anchored at center). Game logic never depends +// on these, so a real sprite atlas can replace them later via loadSprite without +// touching the rest of the game. + +const SPEC = { + obstacle: { shape: "rect", w: 34, h: 34, radius: 6, color: [96, 90, 82], outline: [52, 48, 42] }, + pot: { shape: "rect", w: 24, h: 28, radius: 8, color: [196, 110, 62], outline: [122, 66, 36] }, + grass: { shape: "rect", w: 18, h: 18, radius: 3, color: [150, 205, 96], outline: [86, 146, 54] }, + chicken: { shape: "circle", r: 12, color: [242, 242, 236], outline: [206, 176, 60] }, + link: { shape: "rect", w: 28, h: 32, radius: 5, color: [34, 148, 70], outline: [14, 66, 32] }, + player: { shape: "rect", w: 24, h: 28, radius: 5, color: [80, 150, 230], outline: [36, 82, 152] }, + shard: { shape: "rect", w: 12, h: 12, radius: 2, color: [212, 152, 90], outline: [132, 82, 40] }, +}; + +export function visual(k, kind) { + const s = SPEC[kind]; + const comps = + s.shape === "circle" + ? [k.circle(s.r)] + : [k.rect(s.w, s.h, { radius: s.radius ?? 0 })]; + comps.push(k.color(...s.color)); + if (s.outline) comps.push(k.outline(2, k.rgb(...s.outline))); + comps.push(k.anchor("center")); + return comps; +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..ee93f8f --- /dev/null +++ b/styles.css @@ -0,0 +1,13 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } + +html, body { + width: 100%; + height: 100%; + background: #0e0e12; + overflow: hidden; +} + +canvas { + image-rendering: pixelated; + display: block; +}