From 81961e53f31357d7bd60aac0bbadcd5e642d5a18 Mon Sep 17 00:00:00 2001 From: Lexical Bits Date: Mon, 27 Jul 2026 10:31:18 -0400 Subject: [PATCH] feat: distractions --- src/actors/chicken.js | 76 ++++++++++++++++ src/actors/link.js | 202 ++++++++++++++++++++++++++++++++++-------- src/actors/player.js | 2 +- src/config.js | 16 +++- src/scenes/game.js | 3 +- 5 files changed, 258 insertions(+), 41 deletions(-) create mode 100644 src/actors/chicken.js diff --git a/src/actors/chicken.js b/src/actors/chicken.js new file mode 100644 index 0000000..cc26186 --- /dev/null +++ b/src/actors/chicken.js @@ -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; +} diff --git a/src/actors/link.js b/src/actors/link.js index 6e20d97..625435f 100644 --- a/src/actors/link.js +++ b/src/actors/link.js @@ -1,105 +1,221 @@ -// Link: computer-controlled. Milestones 4-5-8 slice: -// seek -> path to the nearest pot -// throw -> pick it up and hurl it (pots.js handles the arc + shatter) -// leave -> no pots left: path to the exit gap and off-screen (ends the round) +// 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 getting caught on it. Distractions arrive in a later milestone. +// instead of snagging on it. Actor-vs-actor collision is ignored (see area()). -import { TILE, LINK_SPEED } from "../config.js"; +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; // how close before advancing to the next tile -const REPATH_INTERVAL = 0.6; // seconds; recompute to self-heal if bumped +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(), + k.area({ collisionIgnore: ["player", "chicken"] }), k.body(), - k.state("seek", ["seek", "throw", "leave"]), + k.state("seek", ["seek", "throw", "cutGrass", "hitChicken", "flee", "leave"]), "link", ]); link.path = null; link.pathGoal = null; link.repath = 0; + link.distractCd = DISTRACT_INTERVAL; - // Path to `goalTile` and take one step along it. Recomputes when the goal - // changes, the path is spent, or the refresh timer elapses. + // --- 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; } - - // Advance past any waypoint we've effectively reached. 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 nearestPot = () => { - let best = null; - let bestD = Infinity; - for (const pot of k.get("pot")) { - const d = pot.pos.dist(link.pos); - if (d < bestD) { - bestD = d; - best = pot; - } - } - return best; - }; - 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 pot = nearestPot(); + const divert = rollDistraction(); + if (divert) { + link.enterState(divert); + return; + } + const pot = nearest("pot"); if (!pot) { link.enterState("leave"); return; } - if (pot.pos.dist(link.pos) <= INTERACT_DIST) { - link.target = pot; + if (pot.dist <= INTERACT_DIST) { + link.target = pot.obj; link.enterState("throw"); return; } - navTo(world.toTile(pot.pos)); + navTo(world.toTile(pot.obj.pos)); }); // --- THROW --- link.onStateEnter("throw", () => { clearPath(); const pot = link.target; - if (!pot || pot.destroyed) { + if (!alive(pot)) { link.enterState("seek"); return; } const at = pot.pos.clone(); - pot.destroy(); // picked up + pot.destroy(); throwPot(k, world, at, () => link.enterState("seek")); }); - // Stand still while the throw animation plays. + + // --- 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", () => { @@ -124,3 +240,17 @@ export function addLink(k, world, onLeave) { 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()); +} diff --git a/src/actors/player.js b/src/actors/player.js index c190f03..05b9289 100644 --- a/src/actors/player.js +++ b/src/actors/player.js @@ -8,7 +8,7 @@ export function addPlayer(k, world) { const player = k.add([ ...visual(k, "player"), k.pos(p), - k.area(), + k.area({ collisionIgnore: ["link", "chicken"] }), k.body(), // dynamic body: pushed out of static obstacles/walls "player", ]); diff --git a/src/config.js b/src/config.js index e42b1ed..d0cae17 100644 --- a/src/config.js +++ b/src/config.js @@ -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. diff --git a/src/scenes/game.js b/src/scenes/game.js index ce704ce..c11e9fb 100644 --- a/src/scenes/game.js +++ b/src/scenes/game.js @@ -5,6 +5,7 @@ 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 = {}) => { @@ -81,7 +82,7 @@ export function registerGameScene(k) { ); 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")); + level.chickens.forEach((t) => addChicken(k, world, t)); // --- Actors --- addPlayer(k, world);