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

forked from benjivers/val

workbenchthis machine
Files b6659f7ce5f631d7b5dda87f8af0bd7d2e0a5b4f
194 lines · 5.0 KB · ~1.5k tok Raw History
1
/**
2
 * Guns: the damage table and what a shot does with it. Pure functions —
3
 * nothing here knows about a match.
4
 *
5
 * Distance is in world units; fifty units is a metre, so the map is about
6
 * forty-eight metres across and the Phantom's fifteen-metre falloff lands
7
 * where it should. The rule that matters: a Vandal head shot kills through
8
 * full armour at any range, a Phantom head shot does not.
9
 */
10
11
export type WeaponId =
12
  | "classic"
13
  | "ghost"
14
  | "sheriff"
15
  | "spectre"
16
  | "phantom"
17
  | "vandal"
18
  | "operator";
19
20
export type Zone = "head" | "body" | "legs";
21
22
export type Band = { upTo: number; head: number; body: number; legs: number };
23
24
export type Weapon = {
25
  id: WeaponId;
26
  name: string;
27
  cost: number;
28
  /** Shots a second. */
29
  rate: number;
30
  magazine: number;
31
  reload: number;
32
  /** Cone half-angle in radians when standing still, and the walking penalty. */
33
  spread: number;
34
  movePenalty: number;
35
  range: number;
36
  auto: boolean;
37
  bands: Band[];
38
};
39
40
const METRE = 50;
41
42
export const WEAPONS: Record<WeaponId, Weapon> = {
43
  classic: {
44
    id: "classic",
45
    name: "Classic",
46
    cost: 0,
47
    rate: 6.75,
48
    magazine: 12,
49
    reload: 1.75,
50
    spread: 0.022,
51
    movePenalty: 0.05,
52
    range: 1600,
53
    auto: false,
54
    bands: [{ upTo: Infinity, head: 78, body: 26, legs: 22 }],
55
  },
56
  ghost: {
57
    id: "ghost",
58
    name: "Ghost",
59
    cost: 500,
60
    rate: 6.75,
61
    magazine: 15,
62
    reload: 1.5,
63
    spread: 0.016,
64
    movePenalty: 0.05,
65
    range: 1800,
66
    auto: false,
67
    bands: [
68
      { upTo: 30 * METRE, head: 105, body: 30, legs: 26 },
69
      { upTo: Infinity, head: 88, body: 25, legs: 21 },
70
    ],
71
  },
72
  sheriff: {
73
    id: "sheriff",
74
    name: "Sheriff",
75
    cost: 800,
76
    rate: 4,
77
    magazine: 6,
78
    reload: 2.25,
79
    spread: 0.02,
80
    movePenalty: 0.09,
81
    range: 2000,
82
    auto: false,
83
    bands: [
84
      { upTo: 30 * METRE, head: 159, body: 55, legs: 46 },
85
      { upTo: Infinity, head: 145, body: 50, legs: 42 },
86
    ],
87
  },
88
  spectre: {
89
    id: "spectre",
90
    name: "Spectre",
91
    cost: 1600,
92
    rate: 13.33,
93
    magazine: 30,
94
    reload: 2.5,
95
    spread: 0.03,
96
    movePenalty: 0.03,
97
    range: 1600,
98
    auto: true,
99
    bands: [
100
      { upTo: 20 * METRE, head: 78, body: 26, legs: 22 },
101
      { upTo: Infinity, head: 66, body: 22, legs: 18 },
102
    ],
103
  },
104
  phantom: {
105
    id: "phantom",
106
    name: "Phantom",
107
    cost: 2900,
108
    rate: 11,
109
    magazine: 30,
110
    reload: 2.5,
111
    spread: 0.021,
112
    movePenalty: 0.06,
113
    range: 2400,
114
    auto: true,
115
    bands: [
116
      { upTo: 15 * METRE, head: 156, body: 39, legs: 33 },
117
      { upTo: 30 * METRE, head: 140, body: 35, legs: 29 },
118
      { upTo: Infinity, head: 124, body: 31, legs: 26 },
119
    ],
120
  },
121
  vandal: {
122
    id: "vandal",
123
    name: "Vandal",
124
    cost: 2900,
125
    rate: 9.75,
126
    magazine: 25,
127
    reload: 2.5,
128
    spread: 0.024,
129
    movePenalty: 0.07,
130
    range: 2400,
131
    auto: true,
132
    bands: [{ upTo: Infinity, head: 160, body: 40, legs: 34 }],
133
  },
134
  operator: {
135
    id: "operator",
136
    name: "Operator",
137
    cost: 4700,
138
    rate: 0.6,
139
    magazine: 5,
140
    reload: 3.7,
141
    spread: 0.004,
142
    movePenalty: 0.2,
143
    range: 2800,
144
    auto: false,
145
    bands: [{ upTo: Infinity, head: 255, body: 150, legs: 120 }],
146
  },
147
};
148
149
export const weaponIds = (): WeaponId[] => Object.keys(WEAPONS) as WeaponId[];
150
151
export const isWeaponId = (value: unknown): value is WeaponId =>
152
  typeof value === "string" && value in WEAPONS;
153
154
/** Raw damage for a hit: the band the distance falls in, by zone. */
155
export function damage(id: WeaponId, zone: Zone, distance: number): number {
156
  const weapon = WEAPONS[id];
157
  const band = weapon.bands.find((b) => distance <= b.upTo) ?? weapon.bands[weapon.bands.length - 1]!;
158
  return band[zone];
159
}
160
161
/**
162
 * Armour absorbs point for point — a hundred health behind fifty armour is a
163
 * hundred and fifty to chew through — but it takes two thirds of each hit,
164
 * so it runs out before health does.
165
 */
166
export function applyDamage(
167
  hp: number,
168
  armour: number,
169
  amount: number,
170
): { hp: number; armour: number } {
171
  const toArmour = Math.min(armour, Math.ceil(amount * (2 / 3)));
172
  return { hp: Math.max(0, hp - (amount - toArmour)), armour: armour - toArmour };
173
}
174
175
/** True when this hit kills outright: the test the Vandal exists to pass. */
176
export const isLethal = (hp: number, armour: number, amount: number): boolean =>
177
  applyDamage(hp, armour, amount).hp <= 0;
178
179
/**
180
 * Where a shot lands, from how close the ray passes the target's centre.
181
 * Top-down has no vertical, so precision stands in for it: dead centre is a
182
 * head shot, the outer edge is legs.
183
 */
184
export function zoneFromMiss(miss: number, radius: number): Zone {
185
  if (miss <= radius * 0.33) return "head";
186
  if (miss <= radius * 0.75) return "body";
187
  return "legs";
188
}
189
190
/** Cone half-angle for a shot: standing still is accurate, running is not. */
191
export const shotSpread = (id: WeaponId, speedFraction: number): number =>
192
  WEAPONS[id].spread + WEAPONS[id].movePenalty * Math.min(1, Math.max(0, speedFraction));
193
194
export const SHIELD = { light: { cost: 400, armour: 25 }, heavy: { cost: 1000, armour: 50 } };
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts