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

forked from benjivers/val

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

Keyboard shortcuts