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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

vesper/cs:vesper/175-the-knife-is-a-55-damage-hitscan-with-no → main 1 commit fork

  • Merged. vesper/175-the-knife-is-a-55-damage-hitscan-with-no was merged into main as 53ce30c.

Review

Sign in to review this change.
7 files changed +223 −9
README.md +5 −1
@@ -45,7 +45,11 @@ wants one, but the running server never reads it.
4545
4646 - **Real.** The game: WebGL2 written by hand, a fixed 64 Hz simulation, Source-style
4747 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,
4953 a 1:55 round with a 40-second bomb, plant and defuse timers, kill rewards and
5054 loss bonuses. Cover is not all equal: a bullet goes **through a crate** if
5155 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", () => {
120120 const hit = traceBullet([0, 0, 40], [1000, 0, 40], [crate(200, 216)], [actor()], 0, WEAPONS.knife);
121121 expect(hit.kind).toBe("world");
122122 });
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";
88 import { dist, type Vec3 } from "./math.ts";
99 import { HULL } from "./physics.ts";
1010 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";
1212
1313 export type Actor = {
1414 id: number;
@@ -171,15 +171,22 @@ export function traceBullet(
171171 return { kind: "none", point: to, through: entry };
172172 }
173173
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 */
175179 export function applyHit(
176180 target: Actor,
177181 weapon: Weapon,
178182 group: Hitgroup,
179183 distance: number,
180184 damageScale = 1,
185 from: Vec3 | null = null,
181186 ): { 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);
183190 const dealt = Math.min(health, target.health);
184191 target.armor = Math.max(0, target.armor - armor);
185192 target.health -= health;
src/client/weapons.test.ts +54 −1
@@ -1,5 +1,9 @@
11 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";
37
48 test("an AK chest shot through armour is survivable, a headshot is not", () => {
59 const ak = WEAPONS.ak47;
@@ -88,3 +92,52 @@ test("what comes out the far side of a crate is less than what went in", () => {
8892 expect(punched.health).toBeLessThan(full.health);
8993 expect(punched.health).toBeGreaterThan(0);
9094 });
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 @@
88 * bullets every round, which is the property that makes spray control a skill.
99 */
1010
11 import { rng } from "./math.ts";
11 import { angleDelta, deg, rng, type Vec3 } from "./math.ts";
1212
1313 export type WeaponId =
1414 | "knife" | "glock" | "usp" | "deagle" | "mp9" | "ak47" | "m4a4" | "awp" | "he" | "bomb";
@@ -20,6 +20,7 @@ export type Weapon = {
2020 team: "t" | "ct" | "both";
2121 price: number;
2222 damage: number;
23 backstabDamage?: number; // what it does instead when it lands in someone's back
2324 armorPenetration: number; // 0..1, the share that reaches health through armour
2425 rangeModifier: number; // damage multiplier per 500 units
2526 rpm: number;
@@ -36,7 +37,10 @@ export type Weapon = {
3637 };
3738
3839 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 },
4044 { 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 },
4145 { 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 },
4246 { 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(
164168 return { health: Math.max(0, Math.round(health)), armor: Math.round(armorDamage) };
165169 }
166170
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
167194 /**
168195 * CS's damage sum: range falloff, then what cover took out of it, then the
169196 * hitgroup, then armour. Armour takes half of what it stops; a helmet is the
170197 * only thing that helps a headshot. `scale` is 1 for a bullet that met
171198 * nothing on the way and `Penetration.survives` for one that came through a
172199 * 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.
173205 */
174206 export function computeDamage(
175207 weapon: Weapon,
@@ -178,8 +210,10 @@ export function computeDamage(
178210 armor: number,
179211 helmet: boolean,
180212 scale = 1,
213 behind = false,
181214 ): { 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;
183217 damage *= HITGROUP[group];
184218 const protectedHere = armor > 0 && (group !== "head" || helmet) && group !== "leg";
185219 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", () => {
12591259 world.time += REPEAT_GAP;
12601260 expect(callout(world, mates[1] as Player, DE_FORGE.sites.A)).toBe(true);
12611261 });
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 {
829829 } else if (hit.kind === "actor") {
830830 const victim = hit.actor as Player;
831831 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);
833835 p.damage += result.damage;
834836 recordDamage(world, p.id, victim.id, result.damage, result.killed);
835837 world.effects.push({ kind: "blood", at: world.time, pos: hit.point });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs