Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
1 shot deag. the deag should 1 shot headshot pretty much everywhere #196
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/194-1-shot-deag-the-deag-should-1-shot-heads → main 1 commit fork
7 files
changed +139
−65
README.md +12
| @@ -159,6 +159,18 @@ the bomb still ticking it watches whoever is left alive rather than your own | ||
| 159 | 159 | body. `src/client/spectate.ts` is that camera, and nothing else in the game |
| 160 | 160 | reads it. |
| 161 | 161 | |
| 162 | The guns are in `src/client/weapons.ts` and follow CS's shape: damage falls off | |
| 163 | with range, armour eats a share of it, a helmet is the only thing that helps a | |
| 164 | head, and a rifle held down climbs a pattern you can learn. One number is | |
| 165 | deliberately not CS's. **The Desert Eagle one-taps.** A headshot through a | |
| 166 | helmet kills at every distance a bullet in this game travels — 110 health at | |
| 167 | 8,192 units, which is longer than the diagonal of any map it generates — because | |
| 168 | the Deagle carries at 0.955 per 500 units rather than CS's 0.81, which ran out | |
| 169 | of kill about 2,000 units from the barrel and left the $700 pistol doing 66 to a | |
| 170 | head across a map that is 5,120 units a side. It is still two in the chest at | |
| 171 | any range, and no other gun reaches like that: an AK headshot stops killing well | |
| 172 | short of the far wall, and the AWP is what the far wall is for. | |
| 173 | ||
| 162 | 174 | At `/range` the same controls drive the second game, on its own bindings: |
| 163 | 175 | <kbd>WASD</kbd> move, <kbd>Space</kbd> jump, <kbd>Ctrl</kbd> crouch, |
| 164 | 176 | <kbd>Shift</kbd> walk, <kbd>F</kbd> or the mouse fires, <kbd>R</kbd> reload, |
src/client/combat.test.ts +16 −1
| @@ -2,7 +2,7 @@ import { expect, test } from "bun:test"; | ||
| 2 | 2 | import { applyHit, hitboxes, traceBullet, type Actor } from "./combat.ts"; |
| 3 | 3 | import type { Box } from "./level.ts"; |
| 4 | 4 | import type { Vec3 } from "../engine/index.ts"; |
| 5 | import { WEAPONS } from "./weapons.ts"; | |
| 5 | import { BULLET_RANGE, WEAPONS } from "./weapons.ts"; | |
| 6 | 6 | |
| 7 | 7 | const actor = (over: Partial<Actor> = {}): Actor => ({ |
| 8 | 8 | id: 1, team: "t", pos: [500, 0, 0], yaw: 180, duckAmount: 0, |
| @@ -50,6 +50,21 @@ test("two AK headshots are one too many", () => { | ||
| 50 | 50 | expect(target.alive).toBe(false); |
| 51 | 51 | }); |
| 52 | 52 | |
| 53 | test("a Deagle one-taps a helmeted head from the far corner of the map", () => { | |
| 54 | // A bullet fired the whole length one can be fired, at a head with a helmet | |
| 55 | // and a full vest: the trace says head, the hit says killed, and the body is | |
| 56 | // down in one. This is the end of the wire `weapons.test.ts` measures. | |
| 57 | const far = actor({ pos: [BULLET_RANGE - 500, 0, 0], armor: 100, helmet: true }); | |
| 58 | const hit = traceBullet([0, 0, 66], [BULLET_RANGE, 0, 66], [], [far], 0); | |
| 59 | expect(hit.kind).toBe("actor"); | |
| 60 | if (hit.kind !== "actor") return; | |
| 61 | expect(hit.group).toBe("head"); | |
| 62 | expect(hit.distance).toBeGreaterThan(7000); | |
| 63 | const result = applyHit(far, WEAPONS.deagle, hit.group, hit.distance); | |
| 64 | expect(result).toEqual({ damage: 100, killed: true, headshot: true }); | |
| 65 | expect(far.alive).toBe(false); | |
| 66 | }); | |
| 67 | ||
| 53 | 68 | test("damage credited never exceeds the health that was there", () => { |
| 54 | 69 | const target = actor({ health: 12 }); |
| 55 | 70 | const hit = applyHit(target, WEAPONS.ak47, "chest", 100); |
src/client/weapons.test.ts +23 −1
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import { |
| 3 | blindSeconds, buyable, computeDamage, FLASH, HE, inaccuracy, isThrown, recoilOffset, WEAPONS, | |
| 3 | blindSeconds, BULLET_RANGE, buyable, computeDamage, FLASH, HE, inaccuracy, isThrown, recoilOffset, | |
| 4 | WEAPONS, | |
| 4 | 5 | } from "./weapons.ts"; |
| 5 | 6 | |
| 6 | 7 | test("an AK chest shot through armour is survivable, a headshot is not", () => { |
| @@ -34,6 +35,27 @@ test("damage falls off with range", () => { | ||
| 34 | 35 | expect(far).toBeLessThan(near * 0.7); |
| 35 | 36 | }); |
| 36 | 37 | |
| 38 | test("a Deagle headshot kills a helmeted head at every range a bullet reaches", () => { | |
| 39 | const deagle = WEAPONS.deagle; | |
| 40 | const survived: number[] = []; | |
| 41 | for (let d = 0; d <= BULLET_RANGE; d += 128) | |
| 42 | if (computeDamage(deagle, d, "head", 100, true).health < 100) survived.push(d); | |
| 43 | expect(survived).toEqual([]); // a failure names the ranges that lived | |
| 44 | // The far end is the worst of them, and it still has room to spare. | |
| 45 | expect(computeDamage(deagle, BULLET_RANGE, "head", 100, true).health).toBe(110); | |
| 46 | // And bare, which is only easier. | |
| 47 | expect(computeDamage(deagle, BULLET_RANGE, "head", 0, false).health).toBeGreaterThanOrEqual(100); | |
| 48 | }); | |
| 49 | ||
| 50 | test("carrying that far is the Deagle's alone, and it is not a body-shot gun", () => { | |
| 51 | const far = BULLET_RANGE; | |
| 52 | for (const id of ["glock", "usp", "mp9", "ak47", "m4a4"] as const) | |
| 53 | expect(computeDamage(WEAPONS[id], far, "head", 100, true).health).toBeLessThan(100); | |
| 54 | // Two in the chest at any range, never one: the head is what the gun is for. | |
| 55 | for (const d of [0, 1000, 4000, far]) | |
| 56 | expect(computeDamage(WEAPONS.deagle, d, "chest", 100, true).health).toBeLessThan(100); | |
| 57 | }); | |
| 58 | ||
| 37 | 59 | test("an AWP body shot kills through armour", () => { |
| 38 | 60 | expect(computeDamage(WEAPONS.awp, 1000, "chest", 100, true).health).toBeGreaterThanOrEqual(100); |
| 39 | 61 | }); |
src/client/weapons.ts +28 −1
| @@ -96,7 +96,10 @@ const WEAPON_LIST: Weapon[] = [ | ||
| 96 | 96 | { id: "knife", name: "Knife", kind: "knife", team: "both", price: 0, damage: 55, armorPenetration: 0.85, rangeModifier: 1, rpm: 240, magazine: 0, reserve: 0, reloadTime: 0, killReward: 1500, carrySpeed: 250, spread: 0, moveSpread: 0, recoil: 0, automatic: true }, |
| 97 | 97 | { id: "glock", name: "Glock-18", kind: "pistol", team: "t", price: 200, damage: 30, armorPenetration: 0.47, rangeModifier: 0.75, rpm: 400, magazine: 20, reserve: 120, reloadTime: 2.1, killReward: 300, carrySpeed: 240, spread: 0.22, moveSpread: 1.6, recoil: 0.7, automatic: false }, |
| 98 | 98 | { id: "usp", name: "USP-S", kind: "pistol", team: "ct", price: 200, damage: 35, armorPenetration: 0.505, rangeModifier: 0.79, rpm: 352, magazine: 12, reserve: 24, reloadTime: 2.2, killReward: 300, carrySpeed: 240, spread: 0.18, moveSpread: 1.5, recoil: 0.75, automatic: false }, |
| 99 | { id: "deagle", name: "Desert Eagle", kind: "pistol", team: "both", price: 700, damage: 63, armorPenetration: 0.925, rangeModifier: 0.81, rpm: 267, magazine: 7, reserve: 35, reloadTime: 2.2, killReward: 300, carrySpeed: 230, spread: 0.25, moveSpread: 2.6, recoil: 2.2, automatic: false }, | |
| 99 | // The Deagle carries: `rangeModifier` is 0.955 rather than CS's 0.81 so that | |
| 100 | // a headshot through a helmet kills at every distance a bullet in this game | |
| 101 | // can travel. See `BULLET_RANGE` below for the arithmetic. | |
| 102 | { id: "deagle", name: "Desert Eagle", kind: "pistol", team: "both", price: 700, damage: 63, armorPenetration: 0.925, rangeModifier: 0.955, rpm: 267, magazine: 7, reserve: 35, reloadTime: 2.2, killReward: 300, carrySpeed: 230, spread: 0.25, moveSpread: 2.6, recoil: 2.2, automatic: false }, | |
| 100 | 103 | { id: "mp9", name: "MP9", kind: "smg", team: "both", price: 1250, damage: 26, armorPenetration: 0.6, rangeModifier: 0.74, rpm: 857, magazine: 30, reserve: 120, reloadTime: 2.1, killReward: 600, carrySpeed: 240, spread: 0.3, moveSpread: 1.1, recoil: 0.6, automatic: true }, |
| 101 | 104 | { id: "ak47", name: "AK-47", kind: "rifle", team: "t", price: 2700, damage: 36, armorPenetration: 0.775, rangeModifier: 0.98, rpm: 600, magazine: 30, reserve: 90, reloadTime: 2.5, killReward: 300, carrySpeed: 215, spread: 0.16, moveSpread: 3.4, recoil: 1.5, automatic: true }, |
| 102 | 105 | { id: "m4a4", name: "M4A4", kind: "rifle", team: "ct", price: 3100, damage: 33, armorPenetration: 0.7, rangeModifier: 0.97, rpm: 666, magazine: 30, reserve: 90, reloadTime: 3.1, killReward: 300, carrySpeed: 225, spread: 0.15, moveSpread: 3.2, recoil: 1.25, automatic: true }, |
| @@ -119,6 +122,30 @@ export const WEAPONS: Record<WeaponId, Weapon> = Object.fromEntries( | ||
| 119 | 122 | export const buyable = (team: "t" | "ct"): Weapon[] => |
| 120 | 123 | WEAPON_LIST.filter((w) => w.kind !== "knife" && (w.team === "both" || w.team === team)); |
| 121 | 124 | |
| 125 | /** | |
| 126 | * How far a bullet travels before it stops, in units: CS's 8,192, which is | |
| 127 | * longer than the diagonal of any map this game generates (40 cells of 128 | |
| 128 | * units is 5,120 a side, so 7,241 corner to corner) and therefore the longest | |
| 129 | * shot anybody can take. | |
| 130 | * | |
| 131 | * It lives here because it is a number about guns and because the Deagle's | |
| 132 | * promise is stated against it: *a Deagle headshot kills a helmeted body at | |
| 133 | * any range a bullet reaches*. Working it through `computeDamage` at the far | |
| 134 | * end of that range — | |
| 135 | * | |
| 136 | * 63 × 4 (head) × 0.955^(8192/500) × 0.925 (armour) = 110 health | |
| 137 | * | |
| 138 | * — which is over 100, and every shorter shot hits harder than that one. At | |
| 139 | * CS's own 0.81 the same shot stopped killing past about 2,000 units and was | |
| 140 | * down to 66 health at 3,000, which is a corridor and a half: a Deagle that | |
| 141 | * stops one-tapping halfway across the map is not the gun anybody is aiming | |
| 142 | * with, and the dial that says how well a round carries is this one. | |
| 143 | * | |
| 144 | * `src/client/world.ts` traces every bullet to exactly this distance, so the | |
| 145 | * claim is not an approximation of the simulation — it is the simulation. | |
| 146 | */ | |
| 147 | export const BULLET_RANGE = 8192; | |
| 148 | ||
| 122 | 149 | export const ARMOR_PRICE = 650; |
| 123 | 150 | export const HELMET_PRICE = 350; |
| 124 | 151 | export const DEFUSE_KIT_PRICE = 400; |
src/client/world.ts +2 −4
| @@ -30,7 +30,7 @@ import { | ||
| 30 | 30 | import * as rules from "./match.ts"; |
| 31 | 31 | import { CS_HULL, CS_MOVE } from "./movement.ts"; |
| 32 | 32 | import { |
| 33 | WEAPONS, inaccuracy, recoilOffset, RECOIL_SHARE, | |
| 33 | BULLET_RANGE, WEAPONS, inaccuracy, recoilOffset, RECOIL_SHARE, | |
| 34 | 34 | type ThrownId, type WeaponId, type Weapon, |
| 35 | 35 | } from "./weapons.ts"; |
| 36 | 36 | import { botBuy, botThink, type BotBrain } from "./bots.ts"; |
| @@ -331,8 +331,6 @@ function checkRoundEnd(world: World): void { | ||
| 331 | 331 | |
| 332 | 332 | // --- shooting ------------------------------------------------------------- |
| 333 | 333 | |
| 334 | const RANGE = 8192; | |
| 335 | ||
| 336 | 334 | export function currentWeapon(p: Player): Weapon { |
| 337 | 335 | return WEAPONS[p.weapon] ?? WEAPONS.knife; |
| 338 | 336 | } |
| @@ -387,7 +385,7 @@ export function shoot(world: World, p: Player): boolean { | ||
| 387 | 385 | p.punch = [p.punch[0] + (rx - p.punch[0]) * 0.5, p.punch[1] + (ry - p.punch[1]) * 0.5]; |
| 388 | 386 | |
| 389 | 387 | const from = eyePos(p); |
| 390 | const range = w.kind === "knife" ? 64 : RANGE; | |
| 388 | const range = w.kind === "knife" ? 64 : BULLET_RANGE; | |
| 391 | 389 | const dir = angleVectors(yaw, pitch).forward; |
| 392 | 390 | const to: Vec3 = [from[0] + dir[0] * range, from[1] + dir[1] * range, from[2] + dir[2] * range]; |
| 393 | 391 | const hit = traceBullet(from, to, world.level.boxes, world.players, p.id); |
static/app.6bbbaf11e0.js added +58
Diff not shown — a line 72,599 characters wide (limit 2,000). +58
View filestatic/app.be118e74ac.js deleted −58
Diff not shown — a line 72,602 characters wide (limit 2,000). −58
View file
new above
offline