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

forked from benjivers/val

workbenchthis machine
Files 67215f710113bc2875d484e3beaed9d381f49946
286 lines · 10.1 KB · ~3.0k tok Raw History
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, speedOf, 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
      spike: me.spike,
153
      planting: round2(me.planting),
154
      // How fast the server is about to move you, in units a second, and not
155
      // the reasons behind it: the browser's prediction runs at this number
156
      // and re-derives nothing. A rule that holds you still — planting,
157
      // defusing, a decided round — arrives as a zero. To a tenth, like every
158
      // other number here: rounded to a whole unit instead, a hasted walk
159
      // (337.5) is drawn a half-unit a second fast and clipped back by the
160
      // cap every frame, which measured 0.2 points of per-frame spread. A
161
      // tenth is a no-op for feel and costs at most two bytes.
162
      speed: round2(speedOf(state, me)),
163
      bot: me.bot,
164
    },
165
    players: seen.map((p) => ({
166
      id: p.id,
167
      name: p.name,
168
      agent: p.agent,
169
      x: round2(p.x),
170
      y: round2(p.y),
171
      angle: round2(p.angle),
172
      hp: Math.round(p.hp),
173
      armour: Math.round(p.armour),
174
      alive: p.alive,
175
      mate: p.side === me.side,
176
      spike: p.spike,
177
      blind: p.blind > 0,
178
      revealed: p.revealed > 0,
179
    })),
180
    zones: state.zones
181
      .filter((z) => (z.kind === "trip" ? z.side === me.side : canSeeZone(z)))
182
      .map((z) => ({ k: z.kind, x: round2(z.x), y: round2(z.y), r: z.r, f: round2(z.fuse), l: round2(z.life) })),
183
    spike: {
184
      planted: state.spike.planted,
185
      carried: state.spike.carried !== null,
186
      fuse: round2(state.spike.fuse),
187
      defuse: round2(state.spike.defuse),
188
      checkpoint: state.spike.checkpoint,
189
      // A loose spike is the attackers' business until it is armed.
190
      x: state.spike.planted || me.side === "atk" ? round2(state.spike.x) : null,
191
      y: state.spike.planted || me.side === "atk" ? round2(state.spike.y) : null,
192
    },
193
    orbs: state.orbs.filter((o) => !o.taken).map((o) => ({ x: o.x, y: o.y })),
194
    shots: state.shots.filter((s) => canSee({ x: s.x1, y: s.y1 }) || canSee({ x: s.x2, y: s.y2 })),
195
    // The scoreboard — ten names, agents and tallies — was almost half of
196
    // every snapshot and changes a few times a round, so the arena sends it
197
    // only when it differs from what the seat last received.
198
    board: state.players.map((p) => ({
199
      id: p.id,
200
      name: p.name,
201
      team: p.team,
202
      side: p.side,
203
      agent: p.agent,
204
      bot: p.bot,
205
      alive: p.alive,
206
      kills: p.kills,
207
      deaths: p.deaths,
208
      damage: Math.round(p.damageDone),
209
      credits: p.team === me.team ? p.credits : null,
210
      ult: p.ult,
211
      ultNeeds: agentById(p.agent).abilities.x.points,
212
    })),
213
    events: state.events.slice(0, 5).map((e) => e.text),
214
    feed: feedFor(state),
215
  };
216
}
217
218
/**
219
 * Who fell on this tick. A shot that landed this tick and started where the
220
 * killer stands is what tells a gun kill from an ability kill — the position
221
 * is used here and goes no further.
222
 */
223
function readFalls(state: MatchState): void {
224
  const after = marksOf(state.players);
225
  const shooters = new Set<string>();
226
  for (const shot of state.shots) {
227
    if (!shot.hit || shot.age > 0) continue;
228
    for (const player of state.players)
229
      if (Math.hypot(player.x - shot.x1, player.y - shot.y1) < 2) shooters.add(player.id);
230
  }
231
  const falls = fallsBetween(marks, after, {
232
    at: state.now,
233
    events: state.events.slice(0, 6).map((e) => e.text),
234
    shooters,
235
  });
236
  marks = after;
237
  if (falls.length) feed = [...feed, ...falls];
238
  feed = trimFeed(feed, state.now);
239
}
240
241
/** One tick of this game: the bots, the rules, the feed, and the record. */
242
function step(state: MatchState, dt: number): void {
243
  runBots(state, dt);
244
  tick(state, dt);
245
  readFalls(state);
246
  if (state.result && state.result !== lastResult) {
247
    lastResult = state.result;
248
    recordResult(state.result, state.score.red, state.score.blue, state.round);
249
  }
250
  if (state.result === null) lastResult = null;
251
}
252
253
const arena = makeArena<MatchState, Player>({
254
  state,
255
  players: (s) => s.players,
256
  step,
257
  sit,
258
  stand,
259
  visible,
260
  snapshot: snapshotShape,
261
  sticky: "board",
262
  spectatorSnapshot: spectatorShape,
263
  command: runCommand,
264
});
265
266
export const {
267
  match,
268
  findPlayer,
269
  addressCapped,
270
  takeSeat,
271
  attach,
272
  release,
273
  releaseAddress,
274
  seatCount,
275
  watch,
276
  unwatch,
277
  spectatorSnapshot,
278
  command,
279
  snapshot,
280
  snapshotForSeat,
281
  attendance,
282
  beat,
283
  advance,
284
} = arena;
285
export const startArena = arena.start;
286
export const stopArena = arena.stop;
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts