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

forked from benjivers/val

workbenchthis machine
Files fable/29-above-two-players-every-enemy-is-drawn-4
1
/**
2
 * Agents and their kits.
3
 *
4
 * Five agents, each with two basics bought with credits (charges last the
5
 * round), one signature that is free and comes back on a cooldown, and one
6
 * ultimate paid for in ult points. The effects themselves are a short list of
7
 * kinds — flash, smoke, molly, dart, heal, wall, trip — so an agent is data,
8
 * not code.
9
 *
10
 * The two rules worth testing on their own: a flash blinds by the angle you
11
 * were facing and only with line of sight, and a smoke blocks that sight.
12
 */
13
14
import { rayHitWall, segmentHitsCircle, wallBetween, type Point } from "./map.ts";
15
import type { MatchState, Player, Slot, Zone } from "./state.ts";
16
17
export type Kind =
18
  | "flash"
19
  | "smoke"
20
  | "molly"
21
  | "dart"
22
  | "heal"
23
  | "wall"
24
  | "trip"
25
  | "haste"
26
  | "rage"
27
  | "revealall"
28
  | "slowall";
29
30
export type Ability = {
31
  slot: Slot;
32
  name: string;
33
  kind: Kind;
34
  /** Credits for a basic; 0 for the signature and the ultimate. */
35
  cost: number;
36
  charges: number;
37
  /** Seconds the signature takes to come back. 0 means it does not. */
38
  cooldown: number;
39
  /** Ult points the ultimate costs. */
40
  points: number;
41
  range: number;
42
  radius: number;
43
  duration: number;
44
  damage: number;
45
  fuse: number;
46
  text: string;
47
};
48
49
export type Agent = {
50
  id: string;
51
  name: string;
52
  role: string;
53
  blurb: string;
54
  abilities: Record<Slot, Ability>;
55
};
56
57
const basic = (a: Partial<Ability> & { slot: Slot; name: string; kind: Kind; text: string }): Ability => ({
58
  cost: 200,
59
  charges: 1,
60
  cooldown: 0,
61
  points: 0,
62
  range: 700,
63
  radius: 110,
64
  duration: 10,
65
  damage: 0,
66
  fuse: 0,
67
  ...a,
68
});
69
70
export const AGENTS: Agent[] = [
71
  {
72
    id: "ember",
73
    name: "Ember",
74
    role: "Duelist",
75
    blurb: "Flash in, burn them off the plant, come back from nothing.",
76
    abilities: {
77
      c: basic({ slot: "c", name: "Flashfire", kind: "flash", cost: 250, charges: 2, range: 620, radius: 360, fuse: 0.55, duration: 1.7, text: "Throws a flash that pops after half a second and blinds anyone looking near it." }),
78
      q: basic({ slot: "q", name: "Blaze", kind: "molly", cost: 200, charges: 1, range: 620, radius: 95, duration: 6, damage: 26, text: "A pool of fire: 26 damage a second to anyone standing in it." }),
79
      e: basic({ slot: "e", name: "Second Wind", kind: "haste", cost: 0, charges: 1, cooldown: 35, duration: 4, text: "Signature. Four seconds of speed — take the angle before they re-peek." }),
80
      x: basic({ slot: "x", name: "Rebirth", kind: "rage", cost: 0, charges: 0, points: 6, duration: 6, text: "Ultimate. Full health, armour back, every basic charge refilled, six seconds of speed." }),
81
    },
82
  },
83
  {
84
    id: "veil",
85
    name: "Veil",
86
    role: "Controller",
87
    blurb: "Takes the sightline away and gives the site to whoever she likes.",
88
    abilities: {
89
      c: basic({ slot: "c", name: "Cloudburst", kind: "smoke", cost: 100, charges: 2, range: 760, radius: 105, duration: 8, text: "A short smoke. Cheap enough to throw two on a pistol round." }),
90
      q: basic({ slot: "q", name: "Blind Spot", kind: "flash", cost: 250, charges: 1, range: 600, radius: 340, fuse: 0.5, duration: 1.6, text: "A flash for the entry you are about to make." }),
91
      e: basic({ slot: "e", name: "Curtain", kind: "smoke", cost: 0, charges: 1, cooldown: 35, range: 900, radius: 150, duration: 15, text: "Signature. A wide smoke for fifteen seconds — nobody sees through it, server side." }),
92
      x: basic({ slot: "x", name: "Blackout", kind: "smoke", cost: 0, charges: 0, points: 7, range: 1100, radius: 280, duration: 12, damage: -1, text: "Ultimate. A site-sized smoke that slows every enemy inside it." }),
93
    },
94
  },
95
  {
96
    id: "sable",
97
    name: "Sable",
98
    role: "Initiator",
99
    blurb: "Finds them before the team walks in, and moves them off the angle.",
100
    abilities: {
101
      c: basic({ slot: "c", name: "Recon Dart", kind: "dart", cost: 250, charges: 1, range: 900, radius: 340, duration: 6, text: "Sticks and sweeps three times, revealing enemies it can see for two seconds each." }),
102
      q: basic({ slot: "q", name: "Shock Bolt", kind: "molly", cost: 150, charges: 2, range: 820, radius: 90, duration: 1.1, damage: 70, text: "A bolt that cracks for 70 a second — clears a corner or finishes a plant." }),
103
      e: basic({ slot: "e", name: "Owl Flash", kind: "flash", cost: 0, charges: 1, cooldown: 40, range: 640, radius: 360, fuse: 0.6, duration: 1.6, text: "Signature. A flash on a cooldown, so there is always one for the retake." }),
104
      x: basic({ slot: "x", name: "Hunt", kind: "revealall", cost: 0, charges: 0, points: 7, duration: 6, text: "Ultimate. Every enemy on the map is revealed for six seconds." }),
105
    },
106
  },
107
  {
108
    id: "mender",
109
    name: "Mender",
110
    role: "Sentinel",
111
    blurb: "Holds the flank shut and keeps the entry alive long enough to trade.",
112
    abilities: {
113
      c: basic({ slot: "c", name: "Healing Pulse", kind: "heal", cost: 200, charges: 2, range: 260, radius: 260, duration: 3, damage: 60, text: "Heals you or the nearest hurt ally 60 health over three seconds." }),
114
      q: basic({ slot: "q", name: "Barrier", kind: "wall", cost: 400, charges: 1, range: 520, radius: 120, duration: 12, text: "A solid wall for twelve seconds: nothing walks through it and nothing sees through it." }),
115
      e: basic({ slot: "e", name: "Tripwire", kind: "trip", cost: 0, charges: 1, cooldown: 40, range: 300, radius: 120, duration: 45, text: "Signature. Watches the flank: the enemy who walks in is revealed and slowed." }),
116
      x: basic({ slot: "x", name: "Lockdown", kind: "slowall", cost: 0, charges: 0, points: 8, duration: 5, text: "Ultimate. Every enemy is slowed and revealed for five seconds." }),
117
    },
118
  },
119
  {
120
    id: "rook",
121
    name: "Rook",
122
    role: "Initiator",
123
    blurb: "Builds the angle he wants and sets the site on fire when it is lost.",
124
    abilities: {
125
      c: basic({ slot: "c", name: "Smoke Grenade", kind: "smoke", cost: 150, charges: 1, range: 800, radius: 130, duration: 10, text: "One smoke, thrown long." }),
126
      q: basic({ slot: "q", name: "Flashbang", kind: "flash", cost: 200, charges: 2, range: 600, radius: 320, fuse: 0.6, duration: 1.5, text: "Two flashes: one for the entry, one for the exit." }),
127
      e: basic({ slot: "e", name: "Stone Wall", kind: "wall", cost: 0, charges: 1, cooldown: 45, range: 480, radius: 130, duration: 10, text: "Signature. A free wall, back every forty-five seconds." }),
128
      x: basic({ slot: "x", name: "Firestorm", kind: "molly", cost: 0, charges: 0, points: 7, range: 900, radius: 210, duration: 8, damage: 34, text: "Ultimate. A site-wide fire nobody defuses through." }),
129
    },
130
  },
131
];
132
133
export const agentById = (id: string): Agent => AGENTS.find((a) => a.id === id) ?? AGENTS[0]!;
134
export const isAgentId = (value: unknown): value is string =>
135
  typeof value === "string" && AGENTS.some((a) => a.id === value);
136
137
export const SLOTS: Slot[] = ["c", "q", "e", "x"];
138
/** Bought before the round; the signature and the ultimate are never for sale. */
139
export const buyableSlots: Slot[] = ["c", "q"];
140
141
/**
142
 * The rule, from the person playing it: if you are looking in the direction
143
 * of the flash you get flashed, and if you are not, you do not. Facing is
144
 * the gate. Distance only decides how long.
145
 *
146
 * The browser draws a 75° field of view (`FOV` in `static/engine/raycast.js`), so
147
 * anything inside half of that was on your screen: you looked at it, you
148
 * pay the whole duration. Past the edge of the screen there is a little
149
 * grace for the fact that you were probably turning, falling away quickly
150
 * to nothing by `FLASH_GRACE`. Beyond that it is behind your back and
151
 * somebody else's problem — not a short blind, not a flicker, nothing.
152
 */
153
export const FLASH_CONE = (37.5 * Math.PI) / 180;
154
export const FLASH_GRACE = (55 * Math.PI) / 180;
155
/** Inside this share of the radius a flash is full strength. */
156
export const FLASH_FULL_REACH = 0.4;
157
/**
158
 * What is left of the duration at the very edge of the radius. Never zero:
159
 * facing a flash exactly is not survivable because of a distance table.
160
 */
161
export const FLASH_EDGE = 0.2;
162
163
/**
164
 * Whether you were looking at it, as a share of the duration: 1 anywhere on
165
 * screen, falling quickly through the grace past its edge, 0 beyond.
166
 */
167
export function facingFlash(from: Point, at: Point, facing: number): number {
168
  const toFlash = Math.atan2(at.y - from.y, at.x - from.x);
169
  let off = Math.abs(((toFlash - facing + Math.PI * 3) % (Math.PI * 2)) - Math.PI);
170
  off = Math.min(off, Math.PI);
171
  if (off <= FLASH_CONE) return 1;
172
  if (off >= FLASH_GRACE) return 0;
173
  const past = (off - FLASH_CONE) / (FLASH_GRACE - FLASH_CONE);
174
  return (1 - past) * (1 - past);
175
}
176
177
/**
178
 * How far the light carries, as a share of the duration: full near the pop,
179
 * a flicker at the edge of the radius, nothing past it. Distance decides
180
 * how long, never whether.
181
 */
182
export function flashReach(distance: number, radius: number): number {
183
  if (distance > radius) return 0;
184
  const reach = radius * FLASH_FULL_REACH;
185
  if (distance <= reach) return 1;
186
  return 1 - ((distance - reach) / (radius - reach)) * (1 - FLASH_EDGE);
187
}
188
189
/**
190
 * How long a flash blinds, in seconds. Facing decides whether: on screen is
191
 * the full duration, behind your back is nothing, and a wall or a smoke
192
 * between you and the pop means you were not looking at it whatever your
193
 * angle says. Distance decides how long, inside the radius the light
194
 * carries. `blocked` is the caller's line-of-sight answer, so this stays
195
 * pure.
196
 */
197
export function flashDuration(
198
  full: number,
199
  from: Point,
200
  at: Point,
201
  facing: number,
202
  blocked: boolean,
203
  radius: number,
204
): number {
205
  if (blocked) return 0;
206
  const looking = facingFlash(from, at, facing);
207
  if (looking === 0) return 0;
208
  const near = flashReach(Math.hypot(from.x - at.x, from.y - at.y), radius);
209
  return Math.round(full * looking * near * 100) / 100;
210
}
211
212
/** Whether a sight line survives the smokes and walls standing on the map. */
213
export const sightBlocked = (from: Point, to: Point, zones: Zone[]): boolean => {
214
  if (wallBetween(from, to)) return true;
215
  for (const zone of zones)
216
    if (zone.blocks && zone.fuse <= 0 && segmentHitsCircle(from, to, zone, zone.r)) return true;
217
  return false;
218
};
219
220
/** Can this player cast this slot right now, and why not. */
221
export function castable(player: Player, slot: Slot): boolean {
222
  if (!player.alive) return false;
223
  const ability = agentById(player.agent).abilities[slot];
224
  if (slot === "x") return player.ult >= ability.points;
225
  if (player.cooldown[slot] > 0) return false;
226
  return player.charges[slot] > 0;
227
}
228
229
/** Where an ability lands: down the aim line, stopped by the first wall. */
230
export const castPoint = (player: Player, range: number): Point =>
231
  rayHitWall(player, player.angle, range);
232
233
const zone = (state: MatchState, z: Omit<Zone, "id">): Zone => {
234
  const made = { ...z, id: state.nextZone++ };
235
  state.zones.push(made);
236
  return made;
237
};
238
239
/**
240
 * Spend the charge and put the effect on the map. Returns false when the
241
 * cast was refused — no charge, still on cooldown, not enough ult points —
242
 * so the caller never has to guess.
243
 */
244
export function cast(state: MatchState, player: Player, slot: Slot): boolean {
245
  if (!castable(player, slot)) return false;
246
  const agent = agentById(player.agent);
247
  const ability = agent.abilities[slot];
248
  const spot = castPoint(player, ability.range);
249
  const base = { owner: player.id, side: player.side, life: ability.duration, fuse: ability.fuse, beat: 0, pulses: 0, damage: ability.damage, blocks: false, solid: false };
250
251
  switch (ability.kind) {
252
    case "flash":
253
      zone(state, { ...base, kind: "flash", x: spot.x, y: spot.y, r: ability.radius, life: ability.fuse + 0.2, damage: ability.duration });
254
      break;
255
    case "smoke":
256
      zone(state, { ...base, kind: "smoke", x: spot.x, y: spot.y, r: ability.radius, blocks: true });
257
      break;
258
    case "molly":
259
      zone(state, { ...base, kind: "molly", x: spot.x, y: spot.y, r: ability.radius, beat: 0.5 });
260
      break;
261
    case "dart":
262
      zone(state, { ...base, kind: "dart", x: spot.x, y: spot.y, r: ability.radius, beat: 0.6, pulses: 3 });
263
      break;
264
    case "trip":
265
      zone(state, { ...base, kind: "trip", x: spot.x, y: spot.y, r: ability.radius, fuse: 1 });
266
      break;
267
    case "heal": {
268
      const hurt = state.players
269
        .filter((p) => p.alive && p.side === player.side && p.hp < 100 && Math.hypot(p.x - player.x, p.y - player.y) <= ability.range)
270
        .sort((a, b) => a.hp - b.hp)[0];
271
      const target = hurt ?? player;
272
      zone(state, { ...base, kind: "heal", x: target.x, y: target.y, r: 40, beat: 0.5, owner: target.id });
273
      break;
274
    }
275
    case "wall": {
276
      // Three blockers across the aim line: solid to walk through and to see
277
      // through, and made of circles so every other test still works.
278
      const across = player.angle + Math.PI / 2;
279
      for (let i = -1; i <= 1; i++)
280
        zone(state, {
281
          ...base,
282
          kind: "wall",
283
          x: spot.x + Math.cos(across) * i * (ability.radius * 0.7),
284
          y: spot.y + Math.sin(across) * i * (ability.radius * 0.7),
285
          r: ability.radius * 0.45,
286
          blocks: true,
287
          solid: true,
288
        });
289
      break;
290
    }
291
    case "haste":
292
      player.haste = Math.max(player.haste, ability.duration);
293
      break;
294
    case "rage":
295
      player.hp = 100;
296
      player.armour = Math.max(player.armour, 25);
297
      player.haste = Math.max(player.haste, ability.duration);
298
      for (const s of buyableSlots) player.charges[s] = Math.max(player.charges[s], agent.abilities[s].charges);
299
      break;
300
    case "revealall":
301
      for (const other of state.players)
302
        if (other.side !== player.side) other.revealed = Math.max(other.revealed, ability.duration);
303
      break;
304
    case "slowall":
305
      for (const other of state.players)
306
        if (other.side !== player.side) {
307
          other.revealed = Math.max(other.revealed, ability.duration);
308
          other.slow = Math.max(other.slow, ability.duration);
309
        }
310
      break;
311
  }
312
313
  if (slot === "x") player.ult = 0;
314
  else {
315
    player.charges[slot] = Math.max(0, player.charges[slot] - 1);
316
    if (ability.cooldown > 0) player.cooldown[slot] = ability.cooldown;
317
  }
318
  return true;
319
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts