Initial commit
This commit is contained in:
+199
@@ -0,0 +1,199 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user