/** * The arena: this game's one live match, served the way `engine/arena.ts` * serves any match — ticking only while somebody is connected, one playing * seat an address, a positionless view for whoever has no seat, and every * snapshot built for one viewer from what they can see. * * What is this game's own is here: taking a seat replaces a bot in the * emptiest team (same body, same credits, same position, so joining * mid-round hands you a live player rather than a lobby), the commands a * client may send, the shape of the snapshot, and the kill feed read off the * match a tick at a time. */ import { agentById, sightBlocked } from "./abilities.ts"; import { runBots } from "./bots.ts"; import { recordResult } from "./db.ts"; import { makeArena, type Command, type Why } from "./engine/arena.ts"; import { fallsBetween, marksOf, trimFeed, type Fall, type Mark } from "./killfeed.ts"; import type { Point } from "./map.ts"; import { buyAbility, buyShield, buyWeapon, newMatch, pickAgent, setInput, tick, visible } from "./match.ts"; import type { MatchState, Player } from "./state.ts"; import { isWeaponId, WEAPONS } from "./weapons.ts"; export { broadcastEvery, TICK, type Command, type Seat, type Why } from "./engine/arena.ts"; const state: MatchState = newMatch(); let lastResult: string | null = null; // The kill feed is read off the match a tick at a time; the rules keep no // list of their own, and this is a view, not a rule. let marks: Mark[] = marksOf(state.players); let feed: Fall[] = []; const round2 = (n: number): number => Math.round(n * 10) / 10; /** Sit in the emptiest team, on a bot's seat, keeping its body as it stands. */ function sit(state: MatchState): Player | null { const humans = { red: 0, blue: 0 }; for (const player of state.players) if (!player.bot) humans[player.team] += 1; const wanted = humans.red <= humans.blue ? "red" : "blue"; const seat = state.players.find((p) => p.bot && p.team === wanted) ?? state.players.find((p) => p.bot); if (!seat) return null; seat.bot = false; seat.think = 0; seat.input = { mx: 0, my: 0, angle: seat.angle, fire: false, use: false, reload: false, cast: null }; return seat; } /** The seat goes back to a bot, mid-round and all. */ function stand(_state: MatchState, player: Player): void { player.bot = true; player.think = 0; player.input.fire = false; player.input.use = false; } const num = (value: unknown): number | undefined => typeof value === "number" && Number.isFinite(value) ? value : undefined; /** Everything a client may ask for, validated here and nowhere else. */ function runCommand(state: MatchState, player: Player, message: Command): void { switch (message.t) { case "i": setInput(player, { mx: num(message.mx), my: num(message.my), angle: num(message.a), fire: typeof message.f === "boolean" ? message.f : undefined, use: typeof message.u === "boolean" ? message.u : undefined, reload: typeof message.r === "boolean" ? message.r : undefined, cast: message.c === "c" || message.c === "q" || message.c === "e" || message.c === "x" ? message.c : undefined, }); break; case "buy": { const what = String(message.what ?? ""); if (isWeaponId(what)) buyWeapon(state, player, what); else if (what === "light" || what === "heavy") buyShield(state, player, what); else if (what === "c" || what === "q") buyAbility(state, player, what); break; } case "agent": if (typeof message.id === "string") pickAgent(state, player, message.id); break; } } // Names and a gun, never a position: the feed says what happened, not // where, so it can never be read as a call-out. const feedFor = (state: MatchState) => feed.map((f) => ({ k: f.killer, v: f.victim, w: f.weapon ? WEAPONS[f.weapon].name : null, h: f.headshot, t: round2(Math.max(0, state.now - f.at)), })); /** The public state everyone already sees on the page itself. */ function spectatorShape(state: MatchState, _why: Why) { return { phase: state.phase, clock: round2(Math.max(0, state.clock)), round: state.round, score: state.score, attackers: state.attackers, result: state.result, last: state.last, events: state.events.slice(0, 5).map((e) => e.text), feed: feedFor(state), }; } /** The snapshot for one seat: `seen` is exactly who the arena says they may see. */ function snapshotShape(state: MatchState, me: Player, seen: Player[]) { const agent = agentById(me.agent); const canSee = (point: Point): boolean => !sightBlocked(me, point, state.zones); return { phase: state.phase, clock: round2(Math.max(0, state.clock)), round: state.round, score: state.score, attackers: state.attackers, result: state.result, last: state.last, me: { id: me.id, side: me.side, team: me.team, agent: me.agent, alive: me.alive, x: round2(me.x), y: round2(me.y), angle: round2(me.angle), hp: Math.round(me.hp), armour: Math.round(me.armour), credits: me.credits, weapon: me.weapon, ammo: me.ammo, reloading: me.reloadIn > 0, charges: me.charges, cooldown: { c: round2(me.cooldown.c), q: round2(me.cooldown.q), e: round2(me.cooldown.e), x: 0 }, ult: me.ult, ultNeeds: agent.abilities.x.points, blind: round2(me.blind), slow: round2(me.slow), haste: round2(me.haste), spike: me.spike, planting: round2(me.planting), // Your own two ways of being held still, so the browser's prediction // of your own body runs at the speed the server is about to use. defusing: me.defusing > 0, bot: me.bot, }, players: seen.map((p) => ({ id: p.id, name: p.name, agent: p.agent, x: round2(p.x), y: round2(p.y), angle: round2(p.angle), hp: Math.round(p.hp), armour: Math.round(p.armour), alive: p.alive, mate: p.side === me.side, spike: p.spike, blind: p.blind > 0, revealed: p.revealed > 0, })), zones: state.zones .filter((z) => (z.kind === "trip" ? z.side === me.side : true)) .map((z) => ({ k: z.kind, x: round2(z.x), y: round2(z.y), r: z.r, f: round2(z.fuse), l: round2(z.life) })), spike: { planted: state.spike.planted, carried: state.spike.carried !== null, fuse: round2(state.spike.fuse), defuse: round2(state.spike.defuse), checkpoint: state.spike.checkpoint, // A loose spike is the attackers' business until it is armed. x: state.spike.planted || me.side === "atk" ? round2(state.spike.x) : null, y: state.spike.planted || me.side === "atk" ? round2(state.spike.y) : null, }, orbs: state.orbs.filter((o) => !o.taken).map((o) => ({ x: o.x, y: o.y })), shots: state.shots.filter((s) => canSee({ x: s.x1, y: s.y1 }) || canSee({ x: s.x2, y: s.y2 })), // The scoreboard — ten names, agents and tallies — was almost half of // every snapshot and changes a few times a round, so the arena sends it // only when it differs from what the seat last received. board: state.players.map((p) => ({ id: p.id, name: p.name, team: p.team, side: p.side, agent: p.agent, bot: p.bot, alive: p.alive, kills: p.kills, deaths: p.deaths, damage: Math.round(p.damageDone), credits: p.team === me.team ? p.credits : null, ult: p.ult, ultNeeds: agentById(p.agent).abilities.x.points, })), events: state.events.slice(0, 5).map((e) => e.text), feed: feedFor(state), }; } /** * Who fell on this tick. A shot that landed this tick and started where the * killer stands is what tells a gun kill from an ability kill — the position * is used here and goes no further. */ function readFalls(state: MatchState): void { const after = marksOf(state.players); const shooters = new Set(); for (const shot of state.shots) { if (!shot.hit || shot.age > 0) continue; for (const player of state.players) if (Math.hypot(player.x - shot.x1, player.y - shot.y1) < 2) shooters.add(player.id); } const falls = fallsBetween(marks, after, { at: state.now, events: state.events.slice(0, 6).map((e) => e.text), shooters, }); marks = after; if (falls.length) feed = [...feed, ...falls]; feed = trimFeed(feed, state.now); } /** One tick of this game: the bots, the rules, the feed, and the record. */ function step(state: MatchState, dt: number): void { runBots(state, dt); tick(state, dt); readFalls(state); if (state.result && state.result !== lastResult) { lastResult = state.result; recordResult(state.result, state.score.red, state.score.blue, state.round); } if (state.result === null) lastResult = null; } const arena = makeArena({ state, players: (s) => s.players, step, sit, stand, visible, snapshot: snapshotShape, sticky: "board", spectatorSnapshot: spectatorShape, command: runCommand, }); export const { match, findPlayer, addressCapped, takeSeat, attach, release, releaseAddress, seatCount, watch, unwatch, spectatorSnapshot, command, snapshot, snapshotForSeat, attendance, beat, advance, } = arena; export const startArena = arena.start; export const stopArena = arena.stop;