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