Compare commits
7 Commits
main
...
5b97b362c6
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b97b362c6 | |||
| 81961e53f3 | |||
| c1e190a559 | |||
| 37c534ef0f | |||
| 1e5d9560ab | |||
| bcd71923d4 | |||
| 2c2f6ceda7 |
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
.safeclade
|
||||
.safeclaude
|
||||
.claude
|
||||
.DS_Store
|
||||
|
||||
BIN
assets/actors
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
assets/grasss.png
Normal file
|
After Width: | Height: | Size: 332 B |
BIN
assets/link.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
assets/link2.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
assets/sprites/chicken-idle.png
Normal file
|
After Width: | Height: | Size: 970 B |
BIN
assets/sprites/chicken-walk.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/sprites/gen/chicken-idle.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
assets/sprites/gen/chicken-walk.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
assets/sprites/gen/link-alt11.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
assets/sprites/gen/link-carry.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
assets/sprites/gen/link-down.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
assets/sprites/gen/link-pot-sprite.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
assets/sprites/gen/link-throw.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
assets/sprites/gen/link-up.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
assets/sprites/gen/preview-chicken-idle.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/sprites/gen/preview-chicken-walk.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
assets/sprites/gen/preview-link.png
Normal file
|
After Width: | Height: | Size: 559 KiB |
BIN
assets/sprites/gen/preview-strip.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
assets/sprites/link-pot-sprite.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
@ -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>
|
||||
|
||||
76
src/actors/chicken.js
Normal file
@ -0,0 +1,76 @@
|
||||
// Chickens: amble around idly; when Link hits one, it chases him for a beat,
|
||||
// then loses interest. Chickens ignore actor-vs-actor physics (only terrain
|
||||
// blocks them) so they never wedge Link or the player against a wall.
|
||||
|
||||
import { CHICKEN_SPEED, CHICKEN_CHASE_TIME, CHICKEN_HIT_COOLDOWN } from "../config.js";
|
||||
import { visual } from "../sprites.js";
|
||||
|
||||
const CARDINALS = [
|
||||
[1, 0],
|
||||
[-1, 0],
|
||||
[0, 1],
|
||||
[0, -1],
|
||||
];
|
||||
|
||||
export function addChicken(k, world, tile) {
|
||||
const p = world.tc(tile);
|
||||
const chicken = k.add([
|
||||
...visual(k, "chicken"),
|
||||
k.pos(p),
|
||||
k.area({ collisionIgnore: ["player", "link", "chicken"] }),
|
||||
k.body(),
|
||||
k.state("idle", ["idle", "chase"]),
|
||||
"chicken",
|
||||
]);
|
||||
|
||||
chicken.hitCooldown = 0;
|
||||
chicken.wanderCd = k.rand() * 2;
|
||||
chicken.wanderTarget = null;
|
||||
chicken.chaseTarget = null;
|
||||
chicken.chaseTimer = 0;
|
||||
|
||||
// Called by Link when he hits it.
|
||||
chicken.provoke = (target) => {
|
||||
chicken.chaseTarget = target;
|
||||
chicken.chaseTimer = CHICKEN_CHASE_TIME;
|
||||
chicken.hitCooldown = CHICKEN_HIT_COOLDOWN;
|
||||
chicken.enterState("chase");
|
||||
};
|
||||
|
||||
chicken.onStateUpdate("idle", () => {
|
||||
chicken.wanderCd -= k.dt();
|
||||
if (chicken.wanderCd <= 0) {
|
||||
chicken.wanderCd = 1 + k.rand() * 2.5;
|
||||
const from = world.toTile(chicken.pos);
|
||||
const opts = CARDINALS.map(([dx, dy]) => ({ x: from.x + dx, y: from.y + dy })).filter(
|
||||
(t) => world.walkable(t.x, t.y)
|
||||
);
|
||||
chicken.wanderTarget = opts.length ? k.choose(opts) : null;
|
||||
}
|
||||
if (chicken.wanderTarget) {
|
||||
const d = world.tc(chicken.wanderTarget).sub(chicken.pos);
|
||||
if (d.len() > 2) chicken.move(d.unit().scale(CHICKEN_SPEED * 0.4));
|
||||
else chicken.wanderTarget = null;
|
||||
}
|
||||
});
|
||||
|
||||
chicken.onStateUpdate("chase", () => {
|
||||
chicken.chaseTimer -= k.dt();
|
||||
const tgt = chicken.chaseTarget;
|
||||
const alive = tgt && tgt.exists && tgt.exists();
|
||||
if (chicken.chaseTimer <= 0 || !alive) {
|
||||
chicken.chaseTarget = null;
|
||||
chicken.enterState("idle");
|
||||
return;
|
||||
}
|
||||
const d = tgt.pos.sub(chicken.pos);
|
||||
if (d.len() > 2) chicken.move(d.unit().scale(CHICKEN_SPEED));
|
||||
});
|
||||
|
||||
chicken.onUpdate(() => {
|
||||
if (chicken.hitCooldown > 0) chicken.hitCooldown -= k.dt();
|
||||
chicken.z = chicken.pos.y;
|
||||
});
|
||||
|
||||
return chicken;
|
||||
}
|
||||
256
src/actors/link.js
Normal file
@ -0,0 +1,256 @@
|
||||
// Link: computer-controlled.
|
||||
//
|
||||
// States (priority is expressed by which one he's in and what interrupts what):
|
||||
// seek -> path to the nearest pot; may get tempted into a distraction
|
||||
// throw -> pick the pot up and hurl it (pots.js handles arc + shatter)
|
||||
// cutGrass -> detour to nearby grass and cut it
|
||||
// hitChicken-> detour to a nearby chicken and smack it (provokes a chase)
|
||||
// flee -> run from the chasing chicken for a beat
|
||||
// leave -> no pots left: path to the exit gap and off-screen (ends round)
|
||||
//
|
||||
// Movement follows a BFS tile path (pathfind.js) so Link routes around terrain
|
||||
// instead of snagging on it. Actor-vs-actor collision is ignored (see area()).
|
||||
|
||||
import {
|
||||
TILE,
|
||||
LINK_SPEED,
|
||||
DISTRACT_RADIUS,
|
||||
DISTRACT_INTERVAL,
|
||||
DISTRACT_CHANCE,
|
||||
GRASS_CUT_PAUSE,
|
||||
FLEE_TIME,
|
||||
Z,
|
||||
} from "../config.js";
|
||||
import { visual } from "../sprites.js";
|
||||
import { throwPot } from "../pots.js";
|
||||
import { findPath } from "../pathfind.js";
|
||||
|
||||
const INTERACT_DIST = TILE * 0.8;
|
||||
const WAYPOINT_DIST = TILE * 0.3;
|
||||
const REPATH_INTERVAL = 0.6;
|
||||
|
||||
const alive = (o) => o && o.exists && o.exists();
|
||||
|
||||
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({ collisionIgnore: ["player", "chicken"], scale: 0.5 }),
|
||||
k.body(),
|
||||
k.state("seek", ["seek", "throw", "cutGrass", "hitChicken", "flee", "leave"]),
|
||||
"link",
|
||||
]);
|
||||
|
||||
link.path = null;
|
||||
link.pathGoal = null;
|
||||
link.repath = 0;
|
||||
link.distractCd = DISTRACT_INTERVAL;
|
||||
|
||||
// --- Navigation: path to a tile and take one step along it ---
|
||||
const navTo = (goalTile) => {
|
||||
link.repath -= k.dt();
|
||||
const from = world.toTile(link.pos);
|
||||
const sameGoal =
|
||||
link.pathGoal && link.pathGoal.x === goalTile.x && link.pathGoal.y === goalTile.y;
|
||||
if (!sameGoal || !link.path || link.path.length === 0 || link.repath <= 0) {
|
||||
link.path = findPath(world, from, goalTile);
|
||||
link.pathGoal = { ...goalTile };
|
||||
link.repath = REPATH_INTERVAL;
|
||||
}
|
||||
while (link.path.length && link.pos.dist(world.tc(link.path[0])) <= WAYPOINT_DIST) {
|
||||
link.path.shift();
|
||||
}
|
||||
const targetPos = link.path.length ? world.tc(link.path[0]) : world.tc(goalTile);
|
||||
const d = targetPos.sub(link.pos);
|
||||
if (d.len() > 1) link.move(d.unit().scale(LINK_SPEED));
|
||||
};
|
||||
|
||||
const clearPath = () => {
|
||||
link.path = null;
|
||||
link.pathGoal = null;
|
||||
link.repath = 0;
|
||||
};
|
||||
|
||||
const nearest = (tag, filter) => {
|
||||
let best = null;
|
||||
let bestD = Infinity;
|
||||
for (const o of k.get(tag)) {
|
||||
if (filter && !filter(o)) continue;
|
||||
const d = o.pos.dist(link.pos);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = o;
|
||||
}
|
||||
}
|
||||
return best ? { obj: best, dist: bestD } : null;
|
||||
};
|
||||
|
||||
// Should Link get distracted right now? Rolls on a cooldown so he doesn't
|
||||
// divert every single frame. Returns "cutGrass" | "hitChicken" | null.
|
||||
const rollDistraction = () => {
|
||||
link.distractCd -= k.dt();
|
||||
if (link.distractCd > 0) return null;
|
||||
link.distractCd = DISTRACT_INTERVAL;
|
||||
|
||||
const radius = DISTRACT_RADIUS * TILE;
|
||||
const grass = nearest("grass");
|
||||
const chicken = nearest("chicken", (c) => (c.hitCooldown ?? 0) <= 0);
|
||||
|
||||
const candidates = [];
|
||||
if (grass && grass.dist <= radius) candidates.push({ kind: "cutGrass", ...grass });
|
||||
if (chicken && chicken.dist <= radius) candidates.push({ kind: "hitChicken", ...chicken });
|
||||
if (!candidates.length) return null;
|
||||
if (k.rand() >= DISTRACT_CHANCE) return null;
|
||||
|
||||
candidates.sort((a, b) => a.dist - b.dist);
|
||||
link.distractTarget = candidates[0].obj;
|
||||
return candidates[0].kind;
|
||||
};
|
||||
|
||||
// --- SEEK ---
|
||||
link.onStateUpdate("seek", () => {
|
||||
const divert = rollDistraction();
|
||||
if (divert) {
|
||||
link.enterState(divert);
|
||||
return;
|
||||
}
|
||||
const pot = nearest("pot");
|
||||
if (!pot) {
|
||||
link.enterState("leave");
|
||||
return;
|
||||
}
|
||||
if (pot.dist <= INTERACT_DIST) {
|
||||
link.target = pot.obj;
|
||||
link.enterState("throw");
|
||||
return;
|
||||
}
|
||||
navTo(world.toTile(pot.obj.pos));
|
||||
});
|
||||
|
||||
// --- THROW ---
|
||||
link.onStateEnter("throw", () => {
|
||||
clearPath();
|
||||
const pot = link.target;
|
||||
if (!alive(pot)) {
|
||||
link.enterState("seek");
|
||||
return;
|
||||
}
|
||||
const at = pot.pos.clone();
|
||||
pot.destroy();
|
||||
throwPot(k, world, at, () => link.enterState("seek"));
|
||||
});
|
||||
|
||||
// --- CUT GRASS ---
|
||||
link.onStateEnter("cutGrass", () => {
|
||||
clearPath();
|
||||
link.cutPause = -1;
|
||||
});
|
||||
link.onStateUpdate("cutGrass", () => {
|
||||
if (link.cutPause >= 0) {
|
||||
link.cutPause -= k.dt();
|
||||
if (link.cutPause <= 0) link.enterState("seek");
|
||||
return;
|
||||
}
|
||||
const g = link.distractTarget;
|
||||
if (!alive(g)) {
|
||||
link.enterState("seek");
|
||||
return;
|
||||
}
|
||||
if (g.pos.dist(link.pos) <= INTERACT_DIST) {
|
||||
slashEffect(k, g.pos);
|
||||
g.destroy();
|
||||
link.cutPause = GRASS_CUT_PAUSE;
|
||||
} else {
|
||||
navTo(world.toTile(g.pos));
|
||||
}
|
||||
});
|
||||
|
||||
// --- HIT CHICKEN ---
|
||||
link.onStateEnter("hitChicken", () => clearPath());
|
||||
link.onStateUpdate("hitChicken", () => {
|
||||
const c = link.distractTarget;
|
||||
if (!alive(c)) {
|
||||
link.enterState("seek");
|
||||
return;
|
||||
}
|
||||
if (c.pos.dist(link.pos) <= INTERACT_DIST) {
|
||||
slashEffect(k, c.pos);
|
||||
if (c.provoke) c.provoke(link);
|
||||
link.enterState("flee");
|
||||
} else {
|
||||
navTo(world.toTile(c.pos));
|
||||
}
|
||||
});
|
||||
|
||||
// --- FLEE ---
|
||||
const fleeTileFrom = (threatPos) => {
|
||||
const away = link.pos.sub(threatPos);
|
||||
const dir = away.len() > 0 ? away.unit() : k.vec2(1, 0);
|
||||
const from = world.toTile(link.pos);
|
||||
for (let d = 3; d >= 1; d--) {
|
||||
const tx = Math.round(from.x + dir.x * d);
|
||||
const ty = Math.round(from.y + dir.y * d);
|
||||
if (world.walkable(tx, ty)) return { x: tx, y: ty };
|
||||
}
|
||||
return from;
|
||||
};
|
||||
|
||||
link.onStateEnter("flee", () => {
|
||||
clearPath();
|
||||
link.fleeTimer = FLEE_TIME;
|
||||
link.fleeRecalc = 0;
|
||||
});
|
||||
link.onStateUpdate("flee", () => {
|
||||
link.fleeTimer -= k.dt();
|
||||
if (link.fleeTimer <= 0) {
|
||||
link.enterState("seek");
|
||||
return;
|
||||
}
|
||||
const threat =
|
||||
nearest("chicken", (c) => c.state === "chase") || nearest("chicken");
|
||||
link.fleeRecalc -= k.dt();
|
||||
if (link.fleeRecalc <= 0 || !link.fleeTarget) {
|
||||
link.fleeRecalc = 0.35;
|
||||
link.fleeTarget = fleeTileFrom(threat ? threat.obj.pos : link.pos);
|
||||
}
|
||||
navTo(link.fleeTarget);
|
||||
});
|
||||
|
||||
// --- LEAVE ---
|
||||
link.onStateEnter("leave", () => {
|
||||
clearPath();
|
||||
link.leaving = true;
|
||||
});
|
||||
link.onStateUpdate("leave", () => {
|
||||
const exitPos = world.tc(world.level.exit);
|
||||
if (link.pos.dist(exitPos) < TILE * 0.5) {
|
||||
if (!link.gone) {
|
||||
link.gone = true;
|
||||
onLeave && onLeave();
|
||||
}
|
||||
return;
|
||||
}
|
||||
navTo(world.level.exit);
|
||||
});
|
||||
|
||||
link.onUpdate(() => {
|
||||
link.z = link.pos.y;
|
||||
});
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
// A brief white slash arc where Link cuts grass / smacks a chicken.
|
||||
function slashEffect(k, pos) {
|
||||
const fx = k.add([
|
||||
k.circle(4),
|
||||
k.pos(pos),
|
||||
k.anchor("center"),
|
||||
k.color(255, 255, 255),
|
||||
k.opacity(0.9),
|
||||
k.z(Z.FLYING),
|
||||
]);
|
||||
k.tween(4, 18, 0.2, (r) => (fx.radius = r), k.easings.easeOutQuad);
|
||||
k.tween(0.9, 0, 0.2, (o) => (fx.opacity = o), k.easings.linear).then(() => fx.destroy());
|
||||
}
|
||||
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({ collisionIgnore: ["link", "chicken"] }),
|
||||
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;
|
||||
}
|
||||
BIN
src/assets/bg-music.mp3
Normal file
@ -16,11 +16,21 @@ export const POT_COUNT = [5, 8];
|
||||
export const GRASS_COUNT = [8, 15];
|
||||
export const CHICKEN_COUNT = [2, 4];
|
||||
|
||||
// Gameplay tuning (used from later milestones).
|
||||
// Gameplay tuning.
|
||||
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
|
||||
|
||||
// Distractions.
|
||||
export const DISTRACT_RADIUS = 4; // tiles: how far grass/chickens tempt Link
|
||||
export const DISTRACT_INTERVAL = 0.5; // seconds between distraction decisions
|
||||
export const DISTRACT_CHANCE = 0.6; // per-decision chance to divert when tempted
|
||||
export const GRASS_CUT_PAUSE = 0.35; // seconds Link lingers after cutting grass
|
||||
export const FLEE_TIME = 1.5; // seconds Link flees after hitting a chicken
|
||||
|
||||
// Chickens.
|
||||
export const CHICKEN_SPEED = 105; // slower than Link so he can escape
|
||||
export const CHICKEN_CHASE_TIME = 2.2; // seconds a hit chicken chases
|
||||
export const CHICKEN_HIT_COOLDOWN = 4; // seconds before Link is tempted by it again
|
||||
|
||||
// Draw layers. World entities use z = pos.y so lower sprites overlap higher
|
||||
// ones; ground sits below everything, flying pots and HUD above.
|
||||
|
||||
21
src/main.js
@ -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,
|
||||
@ -14,7 +15,27 @@ const k = kaplay({
|
||||
// No gravity: this is a top-down game.
|
||||
k.setGravity(0);
|
||||
|
||||
// Actor sprites.
|
||||
k.loadSprite("link", "assets/link.png");
|
||||
|
||||
// Background music. Browsers block autoplay until the user interacts, so start
|
||||
// looping on the first key/click and keep the handle across scene changes.
|
||||
k.loadSound("bg-music", "src/assets/bg-music.mp3");
|
||||
let music = null;
|
||||
let muted = false;
|
||||
function startMusic() {
|
||||
if (music || muted) return;
|
||||
music = k.play("bg-music", { loop: true, volume: 0.5 });
|
||||
}
|
||||
k.onKeyPress(startMusic);
|
||||
k.onMousePress(startMusic);
|
||||
k.onKeyPress("m", () => {
|
||||
muted = !muted;
|
||||
if (music) music.paused = muted;
|
||||
});
|
||||
|
||||
registerGameScene(k);
|
||||
registerEndScenes(k);
|
||||
k.go("game");
|
||||
|
||||
// Dev-only handle for headless checks / console poking: open with ?debug.
|
||||
|
||||
50
src/pathfind.js
Normal file
@ -0,0 +1,50 @@
|
||||
// Breadth-first shortest path over walkable tiles (4-directional).
|
||||
//
|
||||
// Link steers directly toward his target, which means solid terrain between him
|
||||
// and a pot would trap him. Routing along a tile path of only walkable tiles
|
||||
// keeps him off obstacles/walls, and because steps are orthogonal he never cuts
|
||||
// a corner into a solid tile.
|
||||
|
||||
const key = (x, y) => `${x},${y}`;
|
||||
const NEIGHBORS = [
|
||||
[1, 0],
|
||||
[-1, 0],
|
||||
[0, 1],
|
||||
[0, -1],
|
||||
];
|
||||
|
||||
// Returns the list of tiles AFTER `from` up to and including `goal`, or [] if
|
||||
// the goal is unreachable / equals the start (callers fall back to direct steer).
|
||||
export function findPath(world, from, goal) {
|
||||
if (from.x === goal.x && from.y === goal.y) return [];
|
||||
if (!world.walkable(goal.x, goal.y)) return [];
|
||||
|
||||
const prev = new Map();
|
||||
const seen = new Set([key(from.x, from.y)]);
|
||||
const queue = [from];
|
||||
let head = 0;
|
||||
|
||||
while (head < queue.length) {
|
||||
const cur = queue[head++];
|
||||
if (cur.x === goal.x && cur.y === goal.y) {
|
||||
const path = [];
|
||||
let c = cur;
|
||||
while (!(c.x === from.x && c.y === from.y)) {
|
||||
path.push(c);
|
||||
c = prev.get(key(c.x, c.y));
|
||||
}
|
||||
return path.reverse();
|
||||
}
|
||||
for (const [dx, dy] of NEIGHBORS) {
|
||||
const nx = cur.x + dx;
|
||||
const ny = cur.y + dy;
|
||||
if (!world.walkable(nx, ny)) continue;
|
||||
const kk = key(nx, ny);
|
||||
if (seen.has(kk)) continue;
|
||||
seen.add(kk);
|
||||
prev.set(kk, { ...cur });
|
||||
queue.push({ x: nx, y: ny });
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
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
@ -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,33 @@
|
||||
// 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";
|
||||
import { addChicken } from "../actors/chicken.js";
|
||||
|
||||
export function registerGameScene(k) {
|
||||
k.scene("game", (opts = {}) => {
|
||||
const seed = opts.seed ?? Math.floor(Math.random() * 1e9);
|
||||
const urlSeed = Number(new URLSearchParams(location.search).get("seed"));
|
||||
const seed =
|
||||
opts.seed ?? (Number.isFinite(urlSeed) && urlSeed > 0 ? urlSeed : 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 +41,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 +52,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 +70,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) => addChicken(k, world, t));
|
||||
|
||||
// --- 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;
|
||||
}
|
||||
|
||||
@ -13,7 +13,17 @@ const SPEC = {
|
||||
shard: { shape: "rect", w: 12, h: 12, radius: 2, color: [212, 152, 90], outline: [132, 82, 40] },
|
||||
};
|
||||
|
||||
// Kinds backed by a real loaded sprite (loadSprite name + render scale). Others
|
||||
// fall back to the placeholder shapes in SPEC.
|
||||
const SPRITES = {
|
||||
link: { name: "link", scale: 0.44 },
|
||||
};
|
||||
|
||||
export function visual(k, kind) {
|
||||
const sp = SPRITES[kind];
|
||||
if (sp) {
|
||||
return [k.sprite(sp.name), k.anchor("center"), k.scale(sp.scale)];
|
||||
}
|
||||
const s = SPEC[kind];
|
||||
const comps =
|
||||
s.shape === "circle"
|
||||
|
||||