pike/val Public Public: anyone can read this; nobody can change it.

forked from benjivers/val

workbenchthis machine
Files 884b6d52e6dbc7666a34ac021063e9d1241a348a
1
/**
2
 * The arena: this game's one live match, served the way `engine/arena.ts`
3
 * serves any match — ticking only while somebody is connected, one playing
4
 * seat an address, a positionless view for whoever has no seat, and every
5
 * snapshot built for one viewer from what they can see.
6
 *
7
 * What is this game's own is here: taking a seat replaces a bot in the
8
 * emptiest team (same body, same credits, same position, so joining
9
 * mid-round hands you a live player rather than a lobby), the commands a
10
 * client may send, the shape of the snapshot, and the kill feed read off the
11
 * match a tick at a time.
12
 */
13
14
import { agentById, sightBlocked } from "./abilities.ts";
15
import { runBots } from "./bots.ts";
16
import { recordResult } from "./db.ts";
17
import { makeArena, type Command, type Why } from "./engine/arena.ts";
18
import { fallsBetween, marksOf, trimFeed, type Fall, type Mark } from "./killfeed.ts";
19
import type { Point } from "./map.ts";
20
import { wallBetween } from "./map.ts";
21
import { buyAbility, buyShield, buyWeapon, newMatch, pickAgent, PLAYER_R, setInput, tick, visible } from "./match.ts";
22
import type { MatchState, Player, Zone } from "./state.ts";
23
import { isWeaponId, WEAPONS } from "./weapons.ts";
24
25
export { broadcastEvery, TICK, type Command, type Seat, type Why } from "./engine/arena.ts";
26
27
const state: MatchState = newMatch();
28
let lastResult: string | null = null;
29
// The kill feed is read off the match a tick at a time; the rules keep no
30
// list of their own, and this is a view, not a rule.
31
let marks: Mark[] = marksOf(state.players);
32
let feed: Fall[] = [];
33
34
const round2 = (n: number): number => Math.round(n * 10) / 10;
35
36
/** Sit in the emptiest team, on a bot's seat, keeping its body as it stands. */
37
function sit(state: MatchState): Player | null {
38
  const humans = { red: 0, blue: 0 };
39
  for (const player of state.players) if (!player.bot) humans[player.team] += 1;
40
  const wanted = humans.red <= humans.blue ? "red" : "blue";
41
  const seat = state.players.find((p) => p.bot && p.team === wanted) ?? state.players.find((p) => p.bot);
42
  if (!seat) return null;
43
  seat.bot = false;
44
  seat.think = 0;
45
  seat.input = { mx: 0, my: 0, angle: seat.angle, fire: false, use: false, reload: false, cast: null };
46
  return seat;
47
}
48
49
/** The seat goes back to a bot, mid-round and all. */
50
function stand(_state: MatchState, player: Player): void {
51
  player.bot = true;
52
  player.think = 0;
53
  player.input.fire = false;
54
  player.input.use = false;
55
}
56
57
const num = (value: unknown): number | undefined =>
58
  typeof value === "number" && Number.isFinite(value) ? value : undefined;
59
60
/** Everything a client may ask for, validated here and nowhere else. */
61
function runCommand(state: MatchState, player: Player, message: Command): void {
62
  switch (message.t) {
63
    case "i":
64
      setInput(player, {
65
        mx: num(message.mx),
66
        my: num(message.my),
67
        angle: num(message.a),
68
        fire: typeof message.f === "boolean" ? message.f : undefined,
69
        use: typeof message.u === "boolean" ? message.u : undefined,
70
        reload: typeof message.r === "boolean" ? message.r : undefined,
71
        cast: message.c === "c" || message.c === "q" || message.c === "e" || message.c === "x" ? message.c : undefined,
72
      });
73
      break;
74
    case "buy": {
75
      const what = String(message.what ?? "");
76
      if (isWeaponId(what)) buyWeapon(state, player, what);
77
      else if (what === "light" || what === "heavy") buyShield(state, player, what);
78
      else if (what === "c" || what === "q") buyAbility(state, player, what);
79
      break;
80
    }
81
    case "agent":
82
      if (typeof message.id === "string") pickAgent(state, player, message.id);
83
      break;
84
  }
85
}
86
87
// Names and a gun, never a position: the feed says what happened, not
88
// where, so it can never be read as a call-out.
89
const feedFor = (state: MatchState) =>
90
  feed.map((f) => ({
91
    k: f.killer,
92
    v: f.victim,
93
    w: f.weapon ? WEAPONS[f.weapon].name : null,
94
    h: f.headshot,
95
    t: round2(Math.max(0, state.now - f.at)),
96
  }));
97
98
/** The public state everyone already sees on the page itself. */
99
function spectatorShape(state: MatchState, _why: Why) {
100
  return {
101
    phase: state.phase,
102
    clock: round2(Math.max(0, state.clock)),
103
    round: state.round,
104
    score: state.score,
105
    attackers: state.attackers,
106
    result: state.result,
107
    last: state.last,
108
    events: state.events.slice(0, 5).map((e) => e.text),
109
    feed: feedFor(state),
110
  };
111
}
112
113
/** The snapshot for one seat: `seen` is exactly who the arena says they may see. */
114
function snapshotShape(state: MatchState, me: Player, seen: Player[]) {
115
  const agent = agentById(me.agent);
116
  const canSee = (point: Point): boolean => !sightBlocked(me, point, state.zones);
117
  // A zone is known to the viewer if they are standing in it (so your own
118
  // smoke never vanishes from under you) or, failing that, if no wall sits
119
  // between the two — the wall test only, not `sightBlocked`, which also
120
  // counts zones and would let a smoke hide the wall behind it.
121
  const canSeeZone = (z: Zone): boolean =>
122
    Math.hypot(me.x - z.x, me.y - z.y) <= z.r + PLAYER_R || !wallBetween(me, z);
123
124
  return {
125
    phase: state.phase,
126
    clock: round2(Math.max(0, state.clock)),
127
    round: state.round,
128
    score: state.score,
129
    attackers: state.attackers,
130
    result: state.result,
131
    last: state.last,
132
    me: {
133
      id: me.id,
134
      side: me.side,
135
      team: me.team,
136
      agent: me.agent,
137
      alive: me.alive,
138
      x: round2(me.x),
139
      y: round2(me.y),
140
      angle: round2(me.angle),
141
      hp: Math.round(me.hp),
142
      armour: Math.round(me.armour),
143
      credits: me.credits,
144
      weapon: me.weapon,
145
      ammo: me.ammo,
146
      reloading: me.reloadIn > 0,
147
      charges: me.charges,
148
      cooldown: { c: round2(me.cooldown.c), q: round2(me.cooldown.q), e: round2(me.cooldown.e), x: 0 },
149
      ult: me.ult,
150
      ultNeeds: agent.abilities.x.points,
151
      blind: round2(me.blind),
152
      slow: round2(me.slow),
153
      haste: round2(me.haste),
154
      spike: me.spike,
155
      planting: round2(me.planting),
156
      // Your own two ways of being held still, so the browser's prediction
157
      // of your own body runs at the speed the server is about to use.
158
      defusing: me.defusing > 0,
159
      bot: me.bot,
160
    },
161
    players: seen.map((p) => ({
162
      id: p.id,
163
      name: p.name,
164
      agent: p.agent,
165
      x: round2(p.x),
166
      y: round2(p.y),
167
      angle: round2(p.angle),
168
      hp: Math.round(p.hp),
169
      armour: Math.round(p.armour),
170
      alive: p.alive,
171
      mate: p.side === me.side,
172
      spike: p.spike,
173
      blind: p.blind > 0,
174
      revealed: p.revealed > 0,
175
    })),
176
    zones: state.zones
177
      .filter((z) => (z.kind === "trip" ? z.side === me.side : canSeeZone(z)))
178
      .map((z) => ({ k: z.kind, x: round2(z.x), y: round2(z.y), r: z.r, f: round2(z.fuse), l: round2(z.life) })),
179
    spike: {
180
      planted: state.spike.planted,
181
      carried: state.spike.carried !== null,
182
      fuse: round2(state.spike.fuse),
183
      defuse: round2(state.spike.defuse),
184
      checkpoint: state.spike.checkpoint,
185
      // A loose spike is the attackers' business until it is armed.
186
      x: state.spike.planted || me.side === "atk" ? round2(state.spike.x) : null,
187
      y: state.spike.planted || me.side === "atk" ? round2(state.spike.y) : null,
188
    },
189
    orbs: state.orbs.filter((o) => !o.taken).map((o) => ({ x: o.x, y: o.y })),
190
    shots: state.shots.filter((s) => canSee({ x: s.x1, y: s.y1 }) || canSee({ x: s.x2, y: s.y2 })),
191
    // The scoreboard — ten names, agents and tallies — was almost half of
192
    // every snapshot and changes a few times a round, so the arena sends it
193
    // only when it differs from what the seat last received.
194
    board: state.players.map((p) => ({
195
      id: p.id,
196
      name: p.name,
197
      team: p.team,
198
      side: p.side,
199
      agent: p.agent,
200
      bot: p.bot,
201
      alive: p.alive,
202
      kills: p.kills,
203
      deaths: p.deaths,
204
      damage: Math.round(p.damageDone),
205
      credits: p.team === me.team ? p.credits : null,
206
      ult: p.ult,
207
      ultNeeds: agentById(p.agent).abilities.x.points,
208
    })),
209
    events: state.events.slice(0, 5).map((e) => e.text),
210
    feed: feedFor(state),
211
  };
212
}
213
214
/**
215
 * Who fell on this tick. A shot that landed this tick and started where the
216
 * killer stands is what tells a gun kill from an ability kill — the position
217
 * is used here and goes no further.
218
 */
219
function readFalls(state: MatchState): void {
220
  const after = marksOf(state.players);
221
  const shooters = new Set<string>();
222
  for (const shot of state.shots) {
223
    if (!shot.hit || shot.age > 0) continue;
224
    for (const player of state.players)
225
      if (Math.hypot(player.x - shot.x1, player.y - shot.y1) < 2) shooters.add(player.id);
226
  }
227
  const falls = fallsBetween(marks, after, {
228
    at: state.now,
229
    events: state.events.slice(0, 6).map((e) => e.text),
230
    shooters,
231
  });
232
  marks = after;
233
  if (falls.length) feed = [...feed, ...falls];
234
  feed = trimFeed(feed, state.now);
235
}
236
237
/** One tick of this game: the bots, the rules, the feed, and the record. */
238
function step(state: MatchState, dt: number): void {
239
  runBots(state, dt);
240
  tick(state, dt);
241
  readFalls(state);
242
  if (state.result && state.result !== lastResult) {
243
    lastResult = state.result;
244
    recordResult(state.result, state.score.red, state.score.blue, state.round);
245
  }
246
  if (state.result === null) lastResult = null;
247
}
248
249
const arena = makeArena<MatchState, Player>({
250
  state,
251
  players: (s) => s.players,
252
  step,
253
  sit,
254
  stand,
255
  visible,
256
  snapshot: snapshotShape,
257
  sticky: "board",
258
  spectatorSnapshot: spectatorShape,
259
  command: runCommand,
260
});
261
262
export const {
263
  match,
264
  findPlayer,
265
  addressCapped,
266
  takeSeat,
267
  attach,
268
  release,
269
  releaseAddress,
270
  seatCount,
271
  watch,
272
  unwatch,
273
  spectatorSnapshot,
274
  command,
275
  snapshot,
276
  snapshotForSeat,
277
  attendance,
278
  beat,
279
  advance,
280
} = arena;
281
export const startArena = arena.start;
282
export const stopArena = arena.stop;
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts