Compare commits

1 Commits

Author SHA1 Message Date
20e6f3520a feat: basic ux 2026-07-27 10:21:36 -04:00
36 changed files with 40 additions and 385 deletions

1
.gitignore vendored
View File

@ -1,4 +1,3 @@
.safeclade .safeclade
.safeclaude .safeclaude
.claude .claude
.DS_Store

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 591 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 865 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 775 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 970 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 559 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

View File

@ -1,81 +0,0 @@
// 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"], scale: 0.7 }),
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) {
if (Math.abs(d.x) > 1) chicken.flipX = d.x < 0;
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) {
if (Math.abs(d.x) > 1) chicken.flipX = d.x < 0;
chicken.move(d.unit().scale(CHICKEN_SPEED));
}
});
chicken.onUpdate(() => {
if (chicken.hitCooldown > 0) chicken.hitCooldown -= k.dt();
chicken.z = chicken.pos.y;
});
return chicken;
}

View File

@ -1,237 +1,92 @@
// Link: computer-controlled. // Link: computer-controlled. Milestones 4-5-8 slice:
// // seek -> walk to the nearest pot
// States (priority is expressed by which one he's in and what interrupts what): // throw -> pick it up and hurl it (pots.js handles the arc + shatter)
// seek -> path to the nearest pot; may get tempted into a distraction // leave -> no pots left: walk to the exit gap and off-screen (ends the round)
// throw -> pick the pot up and hurl it (pots.js handles arc + shatter) // Distractions (grass/chickens) arrive in a later milestone.
// 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 { import { TILE, LINK_SPEED } from "../config.js";
TILE,
LINK_SPEED,
DISTRACT_RADIUS,
DISTRACT_INTERVAL,
DISTRACT_CHANCE,
GRASS_CUT_PAUSE,
FLEE_TIME,
Z,
} from "../config.js";
import { visual } from "../sprites.js"; import { visual } from "../sprites.js";
import { throwPot } from "../pots.js"; import { throwPot } from "../pots.js";
import { findPath } from "../pathfind.js";
const INTERACT_DIST = TILE * 0.8; 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) { export function addLink(k, world, onLeave) {
const p = world.tc(world.level.linkSpawn); const p = world.tc(world.level.linkSpawn);
const link = k.add([ const link = k.add([
...visual(k, "link"), ...visual(k, "link"),
k.pos(p), k.pos(p),
k.area({ collisionIgnore: ["player", "chicken"], scale: 0.5 }), k.area(),
k.body(), k.body(),
k.state("seek", ["seek", "throw", "cutGrass", "hitChicken", "flee", "leave"]), k.state("seek", ["seek", "throw", "leave"]),
"link", "link",
]); ]);
link.path = null; // Move toward a world position; returns distance remaining.
link.pathGoal = null; const stepToward = (target) => {
link.repath = 0; const d = target.sub(link.pos);
link.distractCd = DISTRACT_INTERVAL; const dist = d.len();
if (dist > 1) link.move(d.unit().scale(LINK_SPEED));
// --- Navigation: path to a tile and take one step along it --- return dist;
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 = () => { const nearestPot = () => {
link.path = null; const pots = k.get("pot");
link.pathGoal = null;
link.repath = 0;
};
const nearest = (tag, filter) => {
let best = null; let best = null;
let bestD = Infinity; let bestD = Infinity;
for (const o of k.get(tag)) { for (const pot of pots) {
if (filter && !filter(o)) continue; const d = pot.pos.dist(link.pos);
const d = o.pos.dist(link.pos);
if (d < bestD) { if (d < bestD) {
bestD = d; bestD = d;
best = o; best = pot;
} }
} }
return best ? { obj: best, dist: bestD } : null; return best;
};
// 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 --- // --- SEEK ---
link.onStateUpdate("seek", () => { link.onStateUpdate("seek", () => {
const divert = rollDistraction(); const pot = nearestPot();
if (divert) {
link.enterState(divert);
return;
}
const pot = nearest("pot");
if (!pot) { if (!pot) {
link.enterState("leave"); link.enterState("leave");
return; return;
} }
if (pot.dist <= INTERACT_DIST) { if (stepToward(pot.pos) <= INTERACT_DIST) {
link.target = pot.obj; link.target = pot;
link.enterState("throw"); link.enterState("throw");
return;
} }
navTo(world.toTile(pot.obj.pos));
}); });
// --- THROW --- // --- THROW ---
link.onStateEnter("throw", () => { link.onStateEnter("throw", () => {
clearPath();
const pot = link.target; const pot = link.target;
if (!alive(pot)) { if (!pot || pot.destroyed) {
link.enterState("seek"); link.enterState("seek");
return; return;
} }
const at = pot.pos.clone(); const at = pot.pos.clone();
pot.destroy(); pot.destroy(); // picked up
throwPot(k, world, at, () => link.enterState("seek")); link.throwing = true;
}); throwPot(k, world, at, () => {
link.throwing = false;
// --- 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"); 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);
}); });
// Stand still while the throw animation plays.
// --- LEAVE --- // --- LEAVE ---
link.onStateEnter("leave", () => { link.onStateEnter("leave", () => {
clearPath();
link.leaving = true; link.leaving = true;
}); });
link.onStateUpdate("leave", () => { link.onStateUpdate("leave", () => {
const exitPos = world.tc(world.level.exit); 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.pos.dist(exitPos) < TILE * 0.5) {
if (!link.gone) { if (!link.gone) {
link.gone = true; link.gone = true;
onLeave && onLeave(); onLeave && onLeave();
} }
return;
} }
navTo(world.level.exit);
}); });
link.onUpdate(() => { link.onUpdate(() => {
@ -240,17 +95,3 @@ export function addLink(k, world, onLeave) {
return link; 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());
}

View File

@ -8,7 +8,7 @@ export function addPlayer(k, world) {
const player = k.add([ const player = k.add([
...visual(k, "player"), ...visual(k, "player"),
k.pos(p), k.pos(p),
k.area({ collisionIgnore: ["link", "chicken"], scale: 0.4 }), k.area(),
k.body(), // dynamic body: pushed out of static obstacles/walls k.body(), // dynamic body: pushed out of static obstacles/walls
"player", "player",
]); ]);

Binary file not shown.

View File

@ -16,21 +16,11 @@ export const POT_COUNT = [5, 8];
export const GRASS_COUNT = [8, 15]; export const GRASS_COUNT = [8, 15];
export const CHICKEN_COUNT = [2, 4]; export const CHICKEN_COUNT = [2, 4];
// Gameplay tuning. // Gameplay tuning (used from later milestones).
export const THROW_TILES = [2, 3]; export const THROW_TILES = [2, 3];
export const SHARDS_PER_POT = [1, 3]; export const SHARDS_PER_POT = [1, 3];
export const DISTRACT_RADIUS = 4; // tiles
// Distractions. export const FLEE_TIME = 1.5; // seconds
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 // Draw layers. World entities use z = pos.y so lower sprites overlap higher
// ones; ground sits below everything, flying pots and HUD above. // ones; ground sits below everything, flying pots and HUD above.

View File

@ -15,31 +15,6 @@ const k = kaplay({
// No gravity: this is a top-down game. // No gravity: this is a top-down game.
k.setGravity(0); k.setGravity(0);
// Actor sprites.
k.loadSprite("link", "assets/link.png");
k.loadSprite("grass", "assets/grasss.png");
k.loadSprite("player", "assets/actor.png");
k.loadSprite("shard", "assets/shard.png");
k.loadSprite("chicken", "assets/chicken.png");
k.loadSprite("pot", "assets/pot.gen.png");
k.loadSprite("obstacle", "assets/rock.gen.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); registerGameScene(k);
registerEndScenes(k); registerEndScenes(k);
k.go("game"); k.go("game");

View File

@ -1,50 +0,0 @@
// 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 [];
}

View File

@ -112,9 +112,9 @@ function spawnShard(k, world, tile) {
const s = k.add([ const s = k.add([
...visual(k, "shard"), ...visual(k, "shard"),
k.pos(p.x + jx, p.y + jy), k.pos(p.x + jx, p.y + jy),
k.area({ scale: 0.55 }), k.area(),
k.z(p.y), k.z(p.y),
k.rotate((k.rand() - 0.5) * 24), // slight tilt; keep debris upright-ish k.rotate(k.rand() * 360),
"shard", "shard",
]); ]);
return s; return s;

View File

@ -5,13 +5,10 @@ import { generate } from "../gen.js";
import { visual } from "../sprites.js"; import { visual } from "../sprites.js";
import { addPlayer } from "../actors/player.js"; import { addPlayer } from "../actors/player.js";
import { addLink } from "../actors/link.js"; import { addLink } from "../actors/link.js";
import { addChicken } from "../actors/chicken.js";
export function registerGameScene(k) { export function registerGameScene(k) {
k.scene("game", (opts = {}) => { k.scene("game", (opts = {}) => {
const urlSeed = Number(new URLSearchParams(location.search).get("seed")); const seed = opts.seed ?? Math.floor(Math.random() * 1e9);
const seed =
opts.seed ?? (Number.isFinite(urlSeed) && urlSeed > 0 ? urlSeed : Math.floor(Math.random() * 1e9));
const level = generate(k, seed); const level = generate(k, seed);
let roundOver = false; let roundOver = false;
@ -82,7 +79,7 @@ export function registerGameScene(k) {
); );
level.grass.forEach((t) => spawn("grass", t, [k.area()], "grass")); level.grass.forEach((t) => spawn("grass", t, [k.area()], "grass"));
level.pots.forEach((t) => spawn("pot", t, [k.area()], "pot")); level.pots.forEach((t) => spawn("pot", t, [k.area()], "pot"));
level.chickens.forEach((t) => addChicken(k, world, t)); level.chickens.forEach((t) => spawn("chicken", t, [k.area()], "chicken"));
// --- Actors --- // --- Actors ---
addPlayer(k, world); addPlayer(k, world);

View File

@ -13,23 +13,7 @@ const SPEC = {
shard: { shape: "rect", w: 12, h: 12, radius: 2, color: [212, 152, 90], outline: [132, 82, 40] }, 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 },
grass: { name: "grass", scale: 1.6 }, // 16px tile -> ~26px clump
player: { name: "player", scale: 0.13 }, // 428px art -> ~52px tall
shard: { name: "shard", scale: 0.085 }, // 500px debris -> ~26px clump
chicken: { name: "chicken", scale: 1.1 },
pot: { name: "pot", scale: 0.6 }, // 48px art -> ~29px
obstacle: { name: "obstacle", scale: 0.78 }, // 48px art -> ~37px
};
export function visual(k, kind) { 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 s = SPEC[kind];
const comps = const comps =
s.shape === "circle" s.shape === "circle"