feat: distractions
This commit is contained in:
76
src/actors/chicken.js
Normal file
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;
|
||||||
|
}
|
||||||
@ -1,105 +1,221 @@
|
|||||||
// Link: computer-controlled. Milestones 4-5-8 slice:
|
// Link: computer-controlled.
|
||||||
// seek -> path to the nearest pot
|
//
|
||||||
// throw -> pick it up and hurl it (pots.js handles the arc + shatter)
|
// States (priority is expressed by which one he's in and what interrupts what):
|
||||||
// leave -> no pots left: path to the exit gap and off-screen (ends the round)
|
// 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
|
// 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 { visual } from "../sprites.js";
|
||||||
import { throwPot } from "../pots.js";
|
import { throwPot } from "../pots.js";
|
||||||
import { findPath } from "../pathfind.js";
|
import { findPath } from "../pathfind.js";
|
||||||
|
|
||||||
const INTERACT_DIST = TILE * 0.8;
|
const INTERACT_DIST = TILE * 0.8;
|
||||||
const WAYPOINT_DIST = TILE * 0.3; // how close before advancing to the next tile
|
const WAYPOINT_DIST = TILE * 0.3;
|
||||||
const REPATH_INTERVAL = 0.6; // seconds; recompute to self-heal if bumped
|
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(),
|
k.area({ collisionIgnore: ["player", "chicken"] }),
|
||||||
k.body(),
|
k.body(),
|
||||||
k.state("seek", ["seek", "throw", "leave"]),
|
k.state("seek", ["seek", "throw", "cutGrass", "hitChicken", "flee", "leave"]),
|
||||||
"link",
|
"link",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
link.path = null;
|
link.path = null;
|
||||||
link.pathGoal = null;
|
link.pathGoal = null;
|
||||||
link.repath = 0;
|
link.repath = 0;
|
||||||
|
link.distractCd = DISTRACT_INTERVAL;
|
||||||
|
|
||||||
// Path to `goalTile` and take one step along it. Recomputes when the goal
|
// --- Navigation: path to a tile and take one step along it ---
|
||||||
// changes, the path is spent, or the refresh timer elapses.
|
|
||||||
const navTo = (goalTile) => {
|
const navTo = (goalTile) => {
|
||||||
link.repath -= k.dt();
|
link.repath -= k.dt();
|
||||||
const from = world.toTile(link.pos);
|
const from = world.toTile(link.pos);
|
||||||
const sameGoal =
|
const sameGoal =
|
||||||
link.pathGoal && link.pathGoal.x === goalTile.x && link.pathGoal.y === goalTile.y;
|
link.pathGoal && link.pathGoal.x === goalTile.x && link.pathGoal.y === goalTile.y;
|
||||||
|
|
||||||
if (!sameGoal || !link.path || link.path.length === 0 || link.repath <= 0) {
|
if (!sameGoal || !link.path || link.path.length === 0 || link.repath <= 0) {
|
||||||
link.path = findPath(world, from, goalTile);
|
link.path = findPath(world, from, goalTile);
|
||||||
link.pathGoal = { ...goalTile };
|
link.pathGoal = { ...goalTile };
|
||||||
link.repath = REPATH_INTERVAL;
|
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) {
|
while (link.path.length && link.pos.dist(world.tc(link.path[0])) <= WAYPOINT_DIST) {
|
||||||
link.path.shift();
|
link.path.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetPos = link.path.length ? world.tc(link.path[0]) : world.tc(goalTile);
|
const targetPos = link.path.length ? world.tc(link.path[0]) : world.tc(goalTile);
|
||||||
const d = targetPos.sub(link.pos);
|
const d = targetPos.sub(link.pos);
|
||||||
if (d.len() > 1) link.move(d.unit().scale(LINK_SPEED));
|
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 = () => {
|
const clearPath = () => {
|
||||||
link.path = null;
|
link.path = null;
|
||||||
link.pathGoal = null;
|
link.pathGoal = null;
|
||||||
link.repath = 0;
|
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 ---
|
// --- SEEK ---
|
||||||
link.onStateUpdate("seek", () => {
|
link.onStateUpdate("seek", () => {
|
||||||
const pot = nearestPot();
|
const divert = rollDistraction();
|
||||||
|
if (divert) {
|
||||||
|
link.enterState(divert);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pot = nearest("pot");
|
||||||
if (!pot) {
|
if (!pot) {
|
||||||
link.enterState("leave");
|
link.enterState("leave");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pot.pos.dist(link.pos) <= INTERACT_DIST) {
|
if (pot.dist <= INTERACT_DIST) {
|
||||||
link.target = pot;
|
link.target = pot.obj;
|
||||||
link.enterState("throw");
|
link.enterState("throw");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navTo(world.toTile(pot.pos));
|
navTo(world.toTile(pot.obj.pos));
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- THROW ---
|
// --- THROW ---
|
||||||
link.onStateEnter("throw", () => {
|
link.onStateEnter("throw", () => {
|
||||||
clearPath();
|
clearPath();
|
||||||
const pot = link.target;
|
const pot = link.target;
|
||||||
if (!pot || pot.destroyed) {
|
if (!alive(pot)) {
|
||||||
link.enterState("seek");
|
link.enterState("seek");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const at = pot.pos.clone();
|
const at = pot.pos.clone();
|
||||||
pot.destroy(); // picked up
|
pot.destroy();
|
||||||
throwPot(k, world, at, () => link.enterState("seek"));
|
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 ---
|
// --- LEAVE ---
|
||||||
link.onStateEnter("leave", () => {
|
link.onStateEnter("leave", () => {
|
||||||
@ -124,3 +240,17 @@ 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());
|
||||||
|
}
|
||||||
|
|||||||
@ -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(),
|
k.area({ collisionIgnore: ["link", "chicken"] }),
|
||||||
k.body(), // dynamic body: pushed out of static obstacles/walls
|
k.body(), // dynamic body: pushed out of static obstacles/walls
|
||||||
"player",
|
"player",
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -16,11 +16,21 @@ 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 (used from later milestones).
|
// Gameplay tuning.
|
||||||
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
|
|
||||||
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
|
// 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.
|
||||||
|
|||||||
@ -5,6 +5,7 @@ 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 = {}) => {
|
||||||
@ -81,7 +82,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) => spawn("chicken", t, [k.area()], "chicken"));
|
level.chickens.forEach((t) => addChicken(k, world, t));
|
||||||
|
|
||||||
// --- Actors ---
|
// --- Actors ---
|
||||||
addPlayer(k, world);
|
addPlayer(k, world);
|
||||||
|
|||||||
Reference in New Issue
Block a user