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; }