feat: skeleton
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.safeclade
|
||||||
|
.safeclaude
|
||||||
|
.claude
|
||||||
12
index.html
Normal file
12
index.html
Normal 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
31
src/config.js
Normal 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
157
src/gen.js
Normal 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
21
src/main.js
Normal 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
102
src/scenes/game.js
Normal 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
26
src/sprites.js
Normal 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
13
styles.css
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user