feat: basic ux
This commit is contained in:
@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Pot Patrol</title>
|
||||
<link rel="icon" href="data:," />
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
97
src/actors/link.js
Normal file
97
src/actors/link.js
Normal file
@ -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;
|
||||
}
|
||||
37
src/actors/player.js
Normal file
37
src/actors/player.js
Normal file
@ -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;
|
||||
}
|
||||
@ -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.
|
||||
|
||||
121
src/pots.js
Normal file
121
src/pots.js
Normal file
@ -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;
|
||||
}
|
||||
45
src/scenes/end.js
Normal file
45
src/scenes/end.js
Normal file
@ -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)
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user