fable/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Files 7524e23c398cf40fee5ee6886d8b9cb75adfcbbf
| 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 } }; |