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

forked from benjivers/val

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

Keyboard shortcuts