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

forked from benjivers/val

workbenchthis machine
Files b6659f7ce5f631d7b5dda87f8af0bd7d2e0a5b4f
273 lines · 12.1 KB · ~3.5k tok Raw History
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: 900, 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: 850, 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: 900, 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: 800, 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
 * How long a flash blinds, in seconds. Facing it is the worst case; looking
143
 * away is free; a wall or a smoke between you and it means nothing at all.
144
 * `blocked` is the caller's line-of-sight answer, so this stays pure.
145
 */
146
export function flashDuration(
147
  full: number,
148
  from: Point,
149
  at: Point,
150
  facing: number,
151
  blocked: boolean,
152
): number {
153
  if (blocked) return 0;
154
  const toFlash = Math.atan2(at.y - from.y, at.x - from.x);
155
  let off = Math.abs(((toFlash - facing + Math.PI * 3) % (Math.PI * 2)) - Math.PI);
156
  off = Math.min(off, Math.PI);
157
  const wide = (100 * Math.PI) / 180;
158
  if (off > wide) return 0;
159
  const narrow = (45 * Math.PI) / 180;
160
  const facingFactor = off <= narrow ? 1 : 1 - ((off - narrow) / (wide - narrow)) * 0.75;
161
  const distance = Math.hypot(from.x - at.x, from.y - at.y);
162
  const near = distance < 900 ? 1 : distance < 1500 ? 0.6 : 0.35;
163
  return Math.round(full * facingFactor * near * 100) / 100;
164
}
165
166
/** Whether a sight line survives the smokes and walls standing on the map. */
167
export const sightBlocked = (from: Point, to: Point, zones: Zone[]): boolean => {
168
  if (wallBetween(from, to)) return true;
169
  for (const zone of zones)
170
    if (zone.blocks && zone.fuse <= 0 && segmentHitsCircle(from, to, zone, zone.r)) return true;
171
  return false;
172
};
173
174
/** Can this player cast this slot right now, and why not. */
175
export function castable(player: Player, slot: Slot): boolean {
176
  if (!player.alive) return false;
177
  const ability = agentById(player.agent).abilities[slot];
178
  if (slot === "x") return player.ult >= ability.points;
179
  if (player.cooldown[slot] > 0) return false;
180
  return player.charges[slot] > 0;
181
}
182
183
/** Where an ability lands: down the aim line, stopped by the first wall. */
184
export const castPoint = (player: Player, range: number): Point =>
185
  rayHitWall(player, player.angle, range);
186
187
const zone = (state: MatchState, z: Omit<Zone, "id">): Zone => {
188
  const made = { ...z, id: state.nextZone++ };
189
  state.zones.push(made);
190
  return made;
191
};
192
193
/**
194
 * Spend the charge and put the effect on the map. Returns false when the
195
 * cast was refused — no charge, still on cooldown, not enough ult points —
196
 * so the caller never has to guess.
197
 */
198
export function cast(state: MatchState, player: Player, slot: Slot): boolean {
199
  if (!castable(player, slot)) return false;
200
  const agent = agentById(player.agent);
201
  const ability = agent.abilities[slot];
202
  const spot = castPoint(player, ability.range);
203
  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 };
204
205
  switch (ability.kind) {
206
    case "flash":
207
      zone(state, { ...base, kind: "flash", x: spot.x, y: spot.y, r: ability.radius, life: ability.fuse + 0.2, damage: ability.duration });
208
      break;
209
    case "smoke":
210
      zone(state, { ...base, kind: "smoke", x: spot.x, y: spot.y, r: ability.radius, blocks: true });
211
      break;
212
    case "molly":
213
      zone(state, { ...base, kind: "molly", x: spot.x, y: spot.y, r: ability.radius, beat: 0.5 });
214
      break;
215
    case "dart":
216
      zone(state, { ...base, kind: "dart", x: spot.x, y: spot.y, r: ability.radius, beat: 0.6, pulses: 3 });
217
      break;
218
    case "trip":
219
      zone(state, { ...base, kind: "trip", x: spot.x, y: spot.y, r: ability.radius, fuse: 1 });
220
      break;
221
    case "heal": {
222
      const hurt = state.players
223
        .filter((p) => p.alive && p.side === player.side && p.hp < 100 && Math.hypot(p.x - player.x, p.y - player.y) <= ability.range)
224
        .sort((a, b) => a.hp - b.hp)[0];
225
      const target = hurt ?? player;
226
      zone(state, { ...base, kind: "heal", x: target.x, y: target.y, r: 40, beat: 0.5, owner: target.id });
227
      break;
228
    }
229
    case "wall": {
230
      // Three blockers across the aim line: solid to walk through and to see
231
      // through, and made of circles so every other test still works.
232
      const across = player.angle + Math.PI / 2;
233
      for (let i = -1; i <= 1; i++)
234
        zone(state, {
235
          ...base,
236
          kind: "wall",
237
          x: spot.x + Math.cos(across) * i * (ability.radius * 0.7),
238
          y: spot.y + Math.sin(across) * i * (ability.radius * 0.7),
239
          r: ability.radius * 0.45,
240
          blocks: true,
241
          solid: true,
242
        });
243
      break;
244
    }
245
    case "haste":
246
      player.haste = Math.max(player.haste, ability.duration);
247
      break;
248
    case "rage":
249
      player.hp = 100;
250
      player.armour = Math.max(player.armour, 25);
251
      player.haste = Math.max(player.haste, ability.duration);
252
      for (const s of buyableSlots) player.charges[s] = Math.max(player.charges[s], agent.abilities[s].charges);
253
      break;
254
    case "revealall":
255
      for (const other of state.players)
256
        if (other.side !== player.side) other.revealed = Math.max(other.revealed, ability.duration);
257
      break;
258
    case "slowall":
259
      for (const other of state.players)
260
        if (other.side !== player.side) {
261
          other.revealed = Math.max(other.revealed, ability.duration);
262
          other.slow = Math.max(other.slow, ability.duration);
263
        }
264
      break;
265
  }
266
267
  if (slot === "x") player.ult = 0;
268
  else {
269
    player.charges[slot] = Math.max(0, player.charges[slot] - 1);
270
    if (ability.cooldown > 0) player.cooldown[slot] = ability.cooldown;
271
  }
272
  return true;
273
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts