Split The code is public; the app answers only to the crew.
The knife is a 55-damage hitscan with no back to stab: a knife from behind should end it #178
Check · Review · Land · Live done — Landed and live in production.
vesper/cs:vesper/175-the-knife-is-a-55-damage-hitscan-with-no → main 1 commit fork
7 files
changed +223
−9
README.md +5 −1
| @@ -45,7 +45,11 @@ wants one, but the running server never reads it. | ||
| 45 | 45 | |
| 46 | 46 | - **Real.** The game: WebGL2 written by hand, a fixed 64 Hz simulation, Source-style |
| 47 | 47 | movement (ground friction, air acceleration, an 18-unit step, strafe jumping), |
| 48 | CS damage with range falloff, armour and hitgroups, deterministic spray patterns, | |
| 48 | CS damage with range falloff, armour and hitgroups, a knife that pays for a flank | |
| 49 | — 55 in the chest, 180 in the back, where "the back" is CS's own test of standing | |
| 50 | within 61.65° of straight behind someone and armour stops the same share either | |
| 51 | way, so one stab from behind ends a full-health player with a vest on — | |
| 52 | deterministic spray patterns, | |
| 49 | 53 | a 1:55 round with a 40-second bomb, plant and defuse timers, kill rewards and |
| 50 | 54 | loss bonuses. Cover is not all equal: a bullet goes **through a crate** if |
| 51 | 55 | the round is strong enough for the thickness it meets. Crates are the only |
src/client/combat.test.ts +34
| @@ -120,3 +120,37 @@ test("a knife has no penetration: a crate is a crate", () => { | ||
| 120 | 120 | const hit = traceBullet([0, 0, 40], [1000, 0, 40], [crate(200, 216)], [actor()], 0, WEAPONS.knife); |
| 121 | 121 | expect(hit.kind).toBe("world"); |
| 122 | 122 | }); |
| 123 | ||
| 124 | // --- the knife's back ----------------------------------------------------- | |
| 125 | ||
| 126 | /** One stab at a full-health, fully armoured target facing `yaw`, from `from`. */ | |
| 127 | const stab = (from: Vec3, yaw: number, weapon: Weapon = WEAPONS.knife) => { | |
| 128 | const target = actor({ pos: [0, 0, 0], yaw, armor: 100, helmet: true }); | |
| 129 | const result = applyHit(target, weapon, "chest", 48, 1, from); | |
| 130 | return { target, result }; | |
| 131 | }; | |
| 132 | ||
| 133 | test("a stab in the back ends a 100/100 player in one; the same stab in the chest does not", () => { | |
| 134 | const back = stab([-40, 0, 64], 0); // they are looking the other way | |
| 135 | expect(back.result.killed).toBe(true); | |
| 136 | expect(back.target.health).toBe(0); | |
| 137 | expect(back.target.alive).toBe(false); | |
| 138 | ||
| 139 | const front = stab([-40, 0, 64], 180); // same spot, they turned round | |
| 140 | expect(front.result.killed).toBe(false); | |
| 141 | expect(front.target.health).toBeGreaterThan(50); | |
| 142 | expect(front.target.armor).toBeLessThan(100); // armour still took its share | |
| 143 | }); | |
| 144 | ||
| 145 | test("a hit with nowhere to have come from is never a back", () => { | |
| 146 | const target = actor({ pos: [0, 0, 0], yaw: 0, armor: 100, helmet: true }); | |
| 147 | expect(applyHit(target, WEAPONS.knife, "chest", 48).killed).toBe(false); | |
| 148 | }); | |
| 149 | ||
| 150 | test("a rifle in the back is still a rifle", () => { | |
| 151 | const behind = stab([-400, 0, 64], 0, WEAPONS.ak47); | |
| 152 | const ahead = stab([400, 0, 64], 0, WEAPONS.ak47); | |
| 153 | expect(behind.target.health).toBe(ahead.target.health); | |
| 154 | expect(behind.result.damage).toBe(ahead.result.damage); | |
| 155 | expect(behind.result.killed).toBe(false); | |
| 156 | }); |
src/client/combat.ts +10 −3
| @@ -8,7 +8,7 @@ import { MAT, type Box } from "./map.ts"; | ||
| 8 | 8 | import { dist, type Vec3 } from "./math.ts"; |
| 9 | 9 | import { HULL } from "./physics.ts"; |
| 10 | 10 | import { rayBox, traceWorld, type Hit } from "./trace.ts"; |
| 11 | import { computeDamage, penetration, type Hitgroup, type Weapon } from "./weapons.ts"; | |
| 11 | import { computeDamage, fromBehind, penetration, type Hitgroup, type Weapon } from "./weapons.ts"; | |
| 12 | 12 | |
| 13 | 13 | export type Actor = { |
| 14 | 14 | id: number; |
| @@ -171,15 +171,22 @@ export function traceBullet( | ||
| 171 | 171 | return { kind: "none", point: to, through: entry }; |
| 172 | 172 | } |
| 173 | 173 | |
| 174 | /** Apply a hit and say what it cost: health off, armour off, and whether it killed. */ | |
| 174 | /** | |
| 175 | * Apply a hit and say what it cost: health off, armour off, and whether it | |
| 176 | * killed. `from` is where the shot came from — the only thing the facing rule | |
| 177 | * needs — and leaving it out is a hit with no direction, never a back. | |
| 178 | */ | |
| 175 | 179 | export function applyHit( |
| 176 | 180 | target: Actor, |
| 177 | 181 | weapon: Weapon, |
| 178 | 182 | group: Hitgroup, |
| 179 | 183 | distance: number, |
| 180 | 184 | damageScale = 1, |
| 185 | from: Vec3 | null = null, | |
| 181 | 186 | ): { damage: number; killed: boolean; headshot: boolean } { |
| 182 | const { health, armor } = computeDamage(weapon, distance, group, target.armor, target.helmet, damageScale); | |
| 187 | const behind = from !== null && fromBehind(target.yaw, target.pos, from); | |
| 188 | const { health, armor } = | |
| 189 | computeDamage(weapon, distance, group, target.armor, target.helmet, damageScale, behind); | |
| 183 | 190 | const dealt = Math.min(health, target.health); |
| 184 | 191 | target.armor = Math.max(0, target.armor - armor); |
| 185 | 192 | target.health -= health; |
src/client/weapons.test.ts +54 −1
| @@ -1,5 +1,9 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { buyable, computeDamage, inaccuracy, penetration, recoilOffset, WEAPONS, type WeaponId } from "./weapons.ts"; | |
| 2 | import { rad, type Vec3 } from "./math.ts"; | |
| 3 | import { | |
| 4 | BACKSTAB_ARC, buyable, computeDamage, fromBehind, inaccuracy, penetration, recoilOffset, WEAPONS, | |
| 5 | type WeaponId, | |
| 6 | } from "./weapons.ts"; | |
| 3 | 7 | |
| 4 | 8 | test("an AK chest shot through armour is survivable, a headshot is not", () => { |
| 5 | 9 | const ak = WEAPONS.ak47; |
| @@ -88,3 +92,52 @@ test("what comes out the far side of a crate is less than what went in", () => { | ||
| 88 | 92 | expect(punched.health).toBeLessThan(full.health); |
| 89 | 93 | expect(punched.health).toBeGreaterThan(0); |
| 90 | 94 | }); |
| 95 | ||
| 96 | // --- the knife's back ----------------------------------------------------- | |
| 97 | ||
| 98 | /** Someone standing `bearing` degrees round from a victim at the origin. */ | |
| 99 | const standingAt = (bearing: number): Vec3 => | |
| 100 | [Math.cos(rad(bearing)) * 100, Math.sin(rad(bearing)) * 100, 0]; | |
| 101 | ||
| 102 | test("the back is a cone with an edge, and both edges are the same edge", () => { | |
| 103 | const origin: Vec3 = [0, 0, 0]; | |
| 104 | const edge = 180 - BACKSTAB_ARC; // how far off their facing you have to get | |
| 105 | expect(fromBehind(0, origin, standingAt(180))).toBe(true); // straight behind | |
| 106 | expect(fromBehind(0, origin, standingAt(0))).toBe(false); // face to face | |
| 107 | expect(fromBehind(0, origin, standingAt(edge + 1))).toBe(true); | |
| 108 | expect(fromBehind(0, origin, standingAt(edge - 1))).toBe(false); | |
| 109 | expect(fromBehind(0, origin, standingAt(-(edge + 1)))).toBe(true); // other shoulder | |
| 110 | expect(fromBehind(0, origin, standingAt(-(edge - 1)))).toBe(false); | |
| 111 | }); | |
| 112 | ||
| 113 | test("the cone turns with the victim, across the wrap at 180", () => { | |
| 114 | const origin: Vec3 = [0, 0, 0]; | |
| 115 | expect(fromBehind(170, origin, standingAt(-10))).toBe(true); // behind someone facing 170 | |
| 116 | expect(fromBehind(170, origin, standingAt(170))).toBe(false); | |
| 117 | expect(fromBehind(-90, origin, standingAt(90))).toBe(true); | |
| 118 | }); | |
| 119 | ||
| 120 | test("the back is flat: height and distance are not part of the angle", () => { | |
| 121 | expect(fromBehind(0, [0, 0, 0], [-40, 0, 300])).toBe(true); // stabbing from a box | |
| 122 | expect(fromBehind(0, [0, 0, 0], [-4000, 0, 0])).toBe(true); | |
| 123 | expect(fromBehind(0, [0, 0, 0], [0, 0, 0])).toBe(false); // no direction at all | |
| 124 | }); | |
| 125 | ||
| 126 | test("a knife in the back kills through armour; in the chest it is three stabs", () => { | |
| 127 | const knife = WEAPONS.knife; | |
| 128 | const back = computeDamage(knife, 40, "chest", 100, true, 1, true); | |
| 129 | expect(back.health).toBeGreaterThanOrEqual(100); | |
| 130 | const front = computeDamage(knife, 40, "chest", 100, true, 1, false); | |
| 131 | expect(front.health * 2).toBeLessThan(100); // two in the front still leaves them up | |
| 132 | expect(front.health * 3).toBeGreaterThanOrEqual(100); | |
| 133 | // Armour is the same rule it is for a bullet: 85% of it reaches health. | |
| 134 | expect(back.health).toBe(Math.round((knife.backstabDamage as number) * knife.armorPenetration)); | |
| 135 | }); | |
| 136 | ||
| 137 | test("the facing rule stops at the knife: every gun does the same from behind", () => { | |
| 138 | for (const id of Object.keys(WEAPONS) as WeaponId[]) { | |
| 139 | if (id === "knife") continue; | |
| 140 | const front = computeDamage(WEAPONS[id], 500, "chest", 100, true); | |
| 141 | expect(computeDamage(WEAPONS[id], 500, "chest", 100, true, 1, true)).toEqual(front); | |
| 142 | } | |
| 143 | }); |
src/client/weapons.ts +37 −3
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | * bullets every round, which is the property that makes spray control a skill. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { rng } from "./math.ts"; | |
| 11 | import { angleDelta, deg, rng, type Vec3 } from "./math.ts"; | |
| 12 | 12 | |
| 13 | 13 | export type WeaponId = |
| 14 | 14 | | "knife" | "glock" | "usp" | "deagle" | "mp9" | "ak47" | "m4a4" | "awp" | "he" | "bomb"; |
| @@ -20,6 +20,7 @@ export type Weapon = { | ||
| 20 | 20 | team: "t" | "ct" | "both"; |
| 21 | 21 | price: number; |
| 22 | 22 | damage: number; |
| 23 | backstabDamage?: number; // what it does instead when it lands in someone's back | |
| 23 | 24 | armorPenetration: number; // 0..1, the share that reaches health through armour |
| 24 | 25 | rangeModifier: number; // damage multiplier per 500 units |
| 25 | 26 | rpm: number; |
| @@ -36,7 +37,10 @@ export type Weapon = { | ||
| 36 | 37 | }; |
| 37 | 38 | |
| 38 | 39 | const WEAPON_LIST: Weapon[] = [ |
| 39 | { 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 }, | |
| 40 | // CS's two knife numbers: 55 in the front, 180 in the back. 180 through | |
| 41 | // `armorPenetration` is 153 to health, which ends a full-health player with | |
| 42 | // a vest on in one; 55 is three stabs through armour, the way it always was. | |
| 43 | { id: "knife", name: "Knife", kind: "knife", team: "both", price: 0, damage: 55, backstabDamage: 180, 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 }, | |
| 40 | 44 | { 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 }, |
| 41 | 45 | { 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 }, |
| 42 | 46 | { 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 }, |
| @@ -164,12 +168,40 @@ export function armorSplit( | ||
| 164 | 168 | return { health: Math.max(0, Math.round(health)), armor: Math.round(armorDamage) }; |
| 165 | 169 | } |
| 166 | 170 | |
| 171 | /** | |
| 172 | * How far round from straight behind someone a knife still finds their back, | |
| 173 | * in degrees. CS's own test is a dot product of 0.475 between where the victim | |
| 174 | * is looking and the line from their attacker to them; `acos(0.475)` off the | |
| 175 | * front is the same as this much off the back, so you have to be past their | |
| 176 | * shoulder and then some — a flank, not a duel won by strafing. | |
| 177 | */ | |
| 178 | export const BACKSTAB_ARC = 61.65; | |
| 179 | ||
| 180 | /** | |
| 181 | * Did the attacker come at this victim from behind? Flat on the ground plane: | |
| 182 | * the victim's `yaw` against the bearing to whoever is stabbing them, so | |
| 183 | * standing on a box above someone is no more a back than standing in front of | |
| 184 | * them. The one geometric test the whole rule rests on; two bodies in the same | |
| 185 | * spot have no direction between them and so are not a back. | |
| 186 | */ | |
| 187 | export function fromBehind(victimYaw: number, victimPos: Vec3, attackerPos: Vec3): boolean { | |
| 188 | const dx = attackerPos[0] - victimPos[0], dy = attackerPos[1] - victimPos[1]; | |
| 189 | if (Math.hypot(dx, dy) < 1e-6) return false; | |
| 190 | const bearing = deg(Math.atan2(dy, dx)); | |
| 191 | return Math.abs(angleDelta(victimYaw, bearing)) >= 180 - BACKSTAB_ARC; | |
| 192 | } | |
| 193 | ||
| 167 | 194 | /** |
| 168 | 195 | * CS's damage sum: range falloff, then what cover took out of it, then the |
| 169 | 196 | * hitgroup, then armour. Armour takes half of what it stops; a helmet is the |
| 170 | 197 | * only thing that helps a headshot. `scale` is 1 for a bullet that met |
| 171 | 198 | * nothing on the way and `Penetration.survives` for one that came through a |
| 172 | 199 | * crate — the loss is spent once, here, before armour sees the number. |
| 200 | * | |
| 201 | * `behind` swaps the base damage for the weapon's `backstabDamage` and nothing | |
| 202 | * else: only the knife carries one, so a rifle fired at someone's back does | |
| 203 | * exactly what a rifle has always done, and armour still decides by the same | |
| 204 | * `armorPenetration` it uses for a bullet. | |
| 173 | 205 | */ |
| 174 | 206 | export function computeDamage( |
| 175 | 207 | weapon: Weapon, |
| @@ -178,8 +210,10 @@ export function computeDamage( | ||
| 178 | 210 | armor: number, |
| 179 | 211 | helmet: boolean, |
| 180 | 212 | scale = 1, |
| 213 | behind = false, | |
| 181 | 214 | ): { health: number; armor: number } { |
| 182 | let damage = weapon.damage * Math.pow(weapon.rangeModifier, distance / 500) * scale; | |
| 215 | const base = behind ? weapon.backstabDamage ?? weapon.damage : weapon.damage; | |
| 216 | let damage = base * Math.pow(weapon.rangeModifier, distance / 500) * scale; | |
| 183 | 217 | damage *= HITGROUP[group]; |
| 184 | 218 | const protectedHere = armor > 0 && (group !== "head" || helmet) && group !== "leg"; |
| 185 | 219 | if (!protectedHere) return { health: Math.round(damage), armor: 0 }; |
src/client/world.test.ts +80
| @@ -1259,3 +1259,83 @@ test("the side's callouts are rate limited, not just capped at six", () => { | ||
| 1259 | 1259 | world.time += REPEAT_GAP; |
| 1260 | 1260 | expect(callout(world, mates[1] as Player, DE_FORGE.sites.A)).toBe(true); |
| 1261 | 1261 | }); |
| 1262 | ||
| 1263 | // --- the knife ------------------------------------------------------------- | |
| 1264 | ||
| 1265 | /** | |
| 1266 | * A knifer within reach of someone, on an empty map, blade up and aimed at | |
| 1267 | * them. The victim is put at the origin looking `victimYaw`, so the same | |
| 1268 | * helper sets up a back and a chest, and either player can hold the knife. | |
| 1269 | */ | |
| 1270 | function knifeRange(world: World, attacker: Player, victim: Player, victimYaw: number): void { | |
| 1271 | world.boxes = []; | |
| 1272 | world.match.phase = "live"; | |
| 1273 | for (const p of world.players) if (p !== attacker && p !== victim) p.alive = false; | |
| 1274 | victim.pos = [0, 0, 0]; | |
| 1275 | victim.vel = [0, 0, 0]; | |
| 1276 | victim.yaw = victimYaw; | |
| 1277 | victim.health = 100; | |
| 1278 | victim.armor = 100; | |
| 1279 | victim.helmet = true; | |
| 1280 | victim.alive = true; | |
| 1281 | attacker.pos = [-40, 0, 0]; // behind someone looking down +x | |
| 1282 | attacker.vel = [0, 0, 0]; | |
| 1283 | attacker.onGround = true; | |
| 1284 | attacker.weapon = "knife"; | |
| 1285 | attacker.nextShotAt = 0; | |
| 1286 | attacker.reloadUntil = 0; | |
| 1287 | attacker.shotIndex = 0; | |
| 1288 | const aim = aimAt(attacker, bodyCentre(victim)); | |
| 1289 | attacker.yaw = aim.yaw; | |
| 1290 | attacker.pitch = aim.pitch; | |
| 1291 | world.effects.length = 0; | |
| 1292 | } | |
| 1293 | ||
| 1294 | test("a bot's knife in your back ends it in one, and is credited like any kill", () => { | |
| 1295 | const world = createWorld(SHORT, "me", "ct", 5, 401); | |
| 1296 | const bot = world.players.find((p) => p.team === "t") as Player; | |
| 1297 | knifeRange(world, bot, world.me, 0); | |
| 1298 | expect(shoot(world, bot)).toBe(true); | |
| 1299 | expect(world.me.alive).toBe(false); | |
| 1300 | expect(world.me.health).toBe(0); | |
| 1301 | expect(bot.kills).toBe(1); | |
| 1302 | const line = world.feed[world.feed.length - 1]; | |
| 1303 | expect(line.weapon).toBe("knife"); | |
| 1304 | expect(line.killerId).toBe(bot.id); | |
| 1305 | expect(line.victimId).toBe(world.me.id); | |
| 1306 | // The feedback already in the game carries it: a pain cue and the ledger. | |
| 1307 | expect(hurtsIn(world).length).toBe(1); | |
| 1308 | expect(damageDealt(world, bot.id)[0].entry.damage).toBe(100); | |
| 1309 | }); | |
| 1310 | ||
| 1311 | test("the same bot stabbing you in the chest leaves you standing", () => { | |
| 1312 | const world = createWorld(SHORT, "me", "ct", 5, 403); | |
| 1313 | const bot = world.players.find((p) => p.team === "t") as Player; | |
| 1314 | knifeRange(world, bot, world.me, 180); // turned round to face the knife | |
| 1315 | expect(shoot(world, bot)).toBe(true); | |
| 1316 | expect(world.me.alive).toBe(true); | |
| 1317 | expect(world.me.health).toBeGreaterThan(40); | |
| 1318 | expect(world.me.armor).toBeLessThan(100); | |
| 1319 | }); | |
| 1320 | ||
| 1321 | test("you stabbing a bot in the back is the same rule, and marks the hit", () => { | |
| 1322 | const world = createWorld(SHORT, "me", "ct", 5, 405); | |
| 1323 | const bot = world.players.find((p) => p.team === "t") as Player; | |
| 1324 | knifeRange(world, world.me, bot, 0); | |
| 1325 | expect(shoot(world, world.me)).toBe(true); | |
| 1326 | expect(bot.alive).toBe(false); | |
| 1327 | expect(world.me.kills).toBe(1); | |
| 1328 | expect(world.effects.filter((e) => e.kind === "hitmark").length).toBe(1); | |
| 1329 | expect(world.feed[world.feed.length - 1].weapon).toBe("knife"); | |
| 1330 | }); | |
| 1331 | ||
| 1332 | test("a rifle in someone's back does what a rifle did before the knife learned this", () => { | |
| 1333 | const world = createWorld(SHORT, "me", "ct", 5, 407); | |
| 1334 | const bot = world.players.find((p) => p.team === "t") as Player; | |
| 1335 | knifeRange(world, bot, world.me, 0); | |
| 1336 | bot.weapon = "ak47"; | |
| 1337 | bot.ammo.ak47 = { mag: 30, reserve: 90 }; | |
| 1338 | expect(shoot(world, bot)).toBe(true); | |
| 1339 | expect(world.me.alive).toBe(true); | |
| 1340 | expect(world.me.health).toBeGreaterThan(50); | |
| 1341 | }); |
src/client/world.ts +3 −1
| @@ -829,7 +829,9 @@ export function shoot(world: World, p: Player): boolean { | ||
| 829 | 829 | } else if (hit.kind === "actor") { |
| 830 | 830 | const victim = hit.actor as Player; |
| 831 | 831 | if (victim.team === p.team && victim !== p) return true; // no friendly fire in this match |
| 832 | const result = applyHit(victim, w, hit.group, hit.distance, hit.damageScale); | |
| 832 | // `from` is the eye the shot left: it is what decides a knife in the back, | |
| 833 | // and every shooter — bot or person — comes through here with one. | |
| 834 | const result = applyHit(victim, w, hit.group, hit.distance, hit.damageScale, from); | |
| 833 | 835 | p.damage += result.damage; |
| 834 | 836 | recordDamage(world, p.id, victim.id, result.damage, result.killed); |
| 835 | 837 | world.effects.push({ kind: "blood", at: world.time, pos: hit.point }); |
new above
offline