Files
tilt/shared/ai.js
T
2026-08-03 10:28:11 -05:00

200 lines
8.1 KiB
JavaScript

import { RINK, randomIcePoint, rinkPenetration } from './rink.js';
import { SKATE, speedOf } from './skaterSim.js';
import { clamp } from './scalar.js';
/**
* Waypoint AI.
*
* Deliberately dumb: pick a spot, steer at it, get out of everyone's way.
* It exists to exercise the locomotion — the interesting question for this
* spike is whether the *skating* reads, and a bot that just holds a direction
* answers that better than one making tactical decisions.
*
* Everything is produced as a steering *intent*, never as a position fix, so
* when a real controller or a puck-chasing brain replaces this the movement
* layer underneath does not change at all.
*/
const AI = {
/** Considered arrived inside this radius. */
arriveR: 2.6,
/** New waypoints are at least this far away, so bots commit to a line. */
minTravel: 9,
/** Repath anyway after this long, in case a waypoint became unreachable. */
patience: 9,
/** Start avoiding another skater inside this range. */
personalSpace: 3.4,
/** Weight of the avoidance push relative to the waypoint pull. */
avoidGain: 1.5,
/** Much weaker while racing for a puck — you contest it, you don't yield. */
avoidChasing: 0.25,
/** Seconds of velocity looked ahead when checking for boards. */
boardLookahead: 0.9,
/** Steer away once the lookahead point is within this of the boards. */
boardMargin: 2.2,
boardGain: 2.2,
/** Slow down for the last stretch so they don't overshoot every waypoint. */
easeR: 6,
};
export function createBrain(rand, opts = {}) {
return {
rand,
target: null,
age: 0,
/** Personality: some bots cruise, some chase every waypoint flat out. */
eagerness: opts.eagerness ?? (0.35 + rand() * 0.6),
/** Small heading wobble so three bots on the same errand don't lockstep. */
wobblePhase: rand() * Math.PI * 2,
wobbleRate: 0.5 + rand() * 0.7,
};
}
function pickWaypoint(brain, from) {
for (let i = 0; i < 12; i++) {
const p = randomIcePoint(brain.rand, 3.5);
if (Math.hypot(p.x - from.x, p.z - from.z) >= AI.minTravel) return p;
}
return randomIcePoint(brain.rand, 3.5);
}
/**
* Produce this frame's intent for one skater.
*
* @param {object} brain from createBrain
* @param {object} s skater state (mutated: ix, iz, sprint)
* @param {object[]} others other skater states to keep clear of
* @param {number} dt
*/
export function steer(brain, s, others, dt, play = null) {
brain.age += dt;
// ---- what am I doing --------------------------------------------------
// With a puck on the ice there is something to want. Without one this falls
// back to wandering, which is what spike 1 did and is still what happens
// between whistles.
if (play?.puck) {
const mine = play.carrier === s.id;
const teammateHas = play.carrier != null && play.carrierTeam === s.team && !mine;
// Only the closest skater on each side actually goes for it. Letting all
// six chase does produce contact, but it also produces a single moving
// scrum with nobody anywhere else on the ice, which reads as a bug rather
// than as hockey.
const chaser = play.chaser?.[s.team] === s.id;
if (mine) {
// Carrying: head for open ice up the attacking end.
const attackX = s.team === 0 ? RINK.halfX * 0.7 : -RINK.halfX * 0.7;
brain.target = { x: attackX, z: clamp(play.puck.z * 0.6, -RINK.halfZ * 0.6, RINK.halfZ * 0.6) };
brain.age = 0;
} else if (chaser) {
// Go and get it. This is what makes contact happen on its own.
brain.target = { x: play.puck.x, z: play.puck.z };
brain.age = 0;
} else {
// Everyone else finds space: ahead of the puck when their side has it,
// between the puck and their own end when it does not.
const dir = s.team === 0 ? 1 : -1;
const depth = teammateHas ? RINK.halfX * 0.42 : -RINK.halfX * 0.18;
const side = s.id % 2 === 0 ? 1 : -1;
brain.target = {
x: clamp(play.puck.x + dir * depth, -RINK.halfX * 0.85, RINK.halfX * 0.85),
z: clamp(play.puck.z + side * RINK.halfZ * 0.55, -RINK.halfZ * 0.8, RINK.halfZ * 0.8),
};
brain.age = 0;
}
}
const reached = brain.target
&& Math.hypot(brain.target.x - s.x, brain.target.z - s.z) < AI.arriveR;
if (!brain.target || (reached && !play?.puck) || brain.age > AI.patience) {
brain.target = pickWaypoint(brain, s);
brain.age = 0;
}
// ---- pull toward the waypoint ------------------------------------------
let dx = brain.target.x - s.x;
let dz = brain.target.z - s.z;
const dist = Math.hypot(dx, dz) || 1e-6;
dx /= dist;
dz /= dist;
// ---- push away from other skaters --------------------------------------
// Box3D resolves the actual bump; this only stops the bots from queueing up
// to walk through each other, which looks like a bug even when it isn't.
//
// Chasing a loose puck is the exception, and an important one: at full
// avoidance six skaters converging on the same puck politely peel off before
// they ever touch, and the entire hit system never fires in normal play.
// Competing for a puck means being willing to skate into somebody.
const avoid = play?.puck && play.chaser?.[s.team] === s.id ? AI.avoidChasing : AI.avoidGain;
for (const o of others) {
if (o === s) continue;
const ox = s.x - o.x;
const oz = s.z - o.z;
const d = Math.hypot(ox, oz);
if (d >= AI.personalSpace || d < 1e-4) continue;
const w = (1 - d / AI.personalSpace) * avoid;
dx += (ox / d) * w;
dz += (oz / d) * w;
}
// ---- push away from the boards -----------------------------------------
// Checked against where they will be, not where they are: on ice, noticing
// the boards at arm's length is already too late.
const ahead = AI.boardLookahead;
const pen = rinkPenetration(s.x + s.vx * ahead, s.z + s.vz * ahead, SKATE.radius);
if (pen.dist > -AI.boardMargin) {
const w = clamp((pen.dist + AI.boardMargin) / AI.boardMargin, 0, 1) * AI.boardGain;
dx += pen.nx * w;
dz += pen.nz * w;
}
// ---- wobble + normalise -------------------------------------------------
brain.wobblePhase += brain.wobbleRate * dt;
const wob = Math.sin(brain.wobblePhase) * 0.12;
const len = Math.hypot(dx, dz) || 1e-6;
const yaw = Math.atan2(dx / len, dz / len) + wob;
s.ix = Math.sin(yaw);
s.iz = Math.cos(yaw);
// Ease off approaching the waypoint, and only sprint on the long straights.
// Chasing a puck is the exception: you do not coast in on a loose puck, you
// get there first, so the ease and the arrival brake are both dropped.
const chasing = !!play?.puck && play.chaser?.[s.team] === s.id;
const ease = chasing ? 1 : clamp(dist / AI.easeR, 0.25, 1);
s.ix *= ease;
s.iz *= ease;
s.sprint = chasing ? dist > 2 : (dist > AI.easeR * 1.5 && brain.eagerness > 0.5);
// Hard stop rather than a lazy drift when arriving hot.
s.brake = !chasing && dist < AI.arriveR * 1.4 && speedOf(s) > 4.5;
}
/**
* Starting lineup: each team in its own half, everyone facing centre ice.
*
* Laid out like a faceoff rather than scattered at random, because that is the
* arrangement every later spike starts from — drop a puck at centre and this is
* already the right picture. Ordered team by team, so index `i` belongs to
* team `Math.floor(i / perTeam)` and the returned `team` field agrees.
*
* The depth pattern is one skater up, the rest spread behind: with three a side
* that reads as a forward and two defenders without encoding any positional
* rules the game does not have yet.
*/
export function spawnLineup(perTeam = 3, teams = 2) {
const out = [];
for (let t = 0; t < teams; t++) {
// Team 0 defends the -X end, team 1 the +X end.
const sign = t === 0 ? -1 : 1;
for (let i = 0; i < perTeam; i++) {
const lead = i === 0;
const x = sign * (lead ? RINK.halfX * 0.18 : RINK.halfX * 0.42);
// Fan the back line across the width; a single one sits on the centre.
const back = perTeam > 1 ? (i - 1) / Math.max(1, perTeam - 2) : 0.5;
const z = lead ? 0 : (back * 2 - 1) * RINK.halfZ * 0.55;
out.push({ x, z, yaw: Math.atan2(-x, -z), team: t });
}
}
return out;
}