feat: skeleton

This commit is contained in:
2026-07-27 10:16:03 -04:00
parent bd3f0d99ee
commit 6bc16cb135
8 changed files with 365 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.safeclade
.safeclaude
.claude

12
index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pot Patrol</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<script type="module" src="./src/main.js"></script>
</body>
</html>

31
src/config.js Normal file
View File

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

157
src/gen.js Normal file
View File

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

21
src/main.js Normal file
View File

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

102
src/scenes/game.js Normal file
View File

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

26
src/sprites.js Normal file
View File

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

13
styles.css Normal file
View File

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