Split The code is public; the app answers only to the crew.
A kill looks exactly like a hit, and a grenade that lands looks like nothing #212
Check · Review · Land · Live done — Landed and live in production.
vesper/cs:vesper/199-a-kill-looks-exactly-like-a-hit-and-a-gr → main 1 commit fork
7 files
changed +152
−11
README.md +5 −1
| @@ -88,7 +88,11 @@ wants one, but the running server never reads it. | ||
| 88 | 88 | kept is still yours next round, the same rule the guns follow. The bots do not |
| 89 | 89 | buy or throw one: their fight was learned in an arena that has no grenades in |
| 90 | 90 | it, and they keep buying guns. Hit feedback runs both ways: a hit marker and a beep when you hit |
| 91 | someone, and when you are hit a view punch away from the shot, a wedge around | |
| 91 | someone — four ticks around the crosshair, red for a headshot, and for the hit | |
| 92 | that **finished** them a heavier mark, held twice as long, closed into an X | |
| 93 | through the middle, so the fifth bullet does not look like the four before it; | |
| 94 | your own grenade confirms the same way, once for each enemy the blast caught — | |
| 95 | and when you are hit a view punch away from the shot, a wedge around | |
| 92 | 96 | the crosshair pointing at whoever fired, a vignette that reddens as your health |
| 93 | 97 | falls and a pain sound. The gun says what it is doing, too. Every shot lights |
| 94 | 98 | a **muzzle flash** — a star of additive light at the end of the barrel, sized |
src/client/earshot.test.ts +9 −2
| @@ -74,7 +74,14 @@ test("distance is measured from wherever `listener` is passed as, not any fixed | ||
| 74 | 74 | |
| 75 | 75 | test("a shot and a hitmark carry whatever audio.ts needs and nothing else", () => { |
| 76 | 76 | const shot: Effect = { kind: "shot", at: 1, pos: [10, 20, 30], weapon: "awp", mine: true, shooter: 1 }; |
| 77 | const hit: Effect = { kind: "hitmark", at: 1, headshot: true }; | |
| 77 | const hit: Effect = { kind: "hitmark", at: 1, headshot: true, killed: false }; | |
| 78 | 78 | expect(cuesHeard([shot], 0, HERE, 1)).toEqual([{ kind: "shot", pos: [10, 20, 30], weapon: "awp" }]); |
| 79 | expect(cuesHeard([hit], 0, HERE, 1)).toEqual([{ kind: "hit", headshot: true }]); | |
| 79 | expect(cuesHeard([hit], 0, HERE, 1)).toEqual([{ kind: "hit", headshot: true, killed: false }]); | |
| 80 | }); | |
| 81 | ||
| 82 | test("the hit cue carries whether it was the one that killed, ready for the kill tone", () => { | |
| 83 | // `audio.ts` still plays `hit(headshot)` and ignores this: the flag is on | |
| 84 | // the cue so the rewrite under #110 has it without touching earshot again. | |
| 85 | const fatal: Effect = { kind: "hitmark", at: 1, headshot: false, killed: true }; | |
| 86 | expect(cuesHeard([fatal], 0, HERE, 1)).toEqual([{ kind: "hit", headshot: false, killed: true }]); | |
| 80 | 87 | }); |
src/client/earshot.ts +6 −2
| @@ -35,7 +35,10 @@ const EARSHOT: Partial<Record<SoundName, number>> = { | ||
| 35 | 35 | /** One thing worth playing: exactly what `audio.ts` and `flinch` need, and nothing else. */ |
| 36 | 36 | export type Cue = |
| 37 | 37 | | { kind: "shot"; pos: Vec3; weapon: WeaponId } |
| 38 | | { kind: "hit"; headshot: boolean } | |
| 38 | // A hit of your own: where it landed, and whether it was the one that | |
| 39 | // finished them. `audio.ts` plays `hit(headshot)` today and ignores | |
| 40 | // `killed` — the kill tone is #110's, and this is the flag waiting for it. | |
| 41 | | { kind: "hit"; headshot: boolean; killed: boolean } | |
| 39 | 42 | | { kind: "hurt"; from: Vec3; amount: number } |
| 40 | 43 | | { kind: "step"; pos: Vec3 } |
| 41 | 44 | | { kind: "world"; name: WorldSoundName; pos: Vec3; pitch?: number }; |
| @@ -51,7 +54,8 @@ export function cuesHeard(effects: Effect[], since: number, listener: Vec3, loca | ||
| 51 | 54 | for (const effect of effects) { |
| 52 | 55 | if (effect.at <= since) continue; |
| 53 | 56 | if (effect.kind === "shot") cues.push({ kind: "shot", pos: effect.pos, weapon: effect.weapon }); |
| 54 | else if (effect.kind === "hitmark") cues.push({ kind: "hit", headshot: effect.headshot }); | |
| 57 | else if (effect.kind === "hitmark") | |
| 58 | cues.push({ kind: "hit", headshot: effect.headshot, killed: effect.killed }); | |
| 55 | 59 | else if (effect.kind === "hurt") { |
| 56 | 60 | if (effect.victim === localId) cues.push({ kind: "hurt", from: effect.from, amount: effect.amount }); |
| 57 | 61 | } else if (effect.kind === "sound") { |
src/client/hud.test.ts +29 −2
| @@ -272,7 +272,7 @@ test("dead, the HUD is the watched player's: their numbers, no crosshair, and wh | ||
| 272 | 272 | test("a spectator does not inherit the dead player's hit markers", () => { |
| 273 | 273 | const world = spectatedWorld(); |
| 274 | 274 | const mate = world.players.find((p) => p.alive && p.team === "ct") as Player; |
| 275 | world.effects.push({ kind: "hitmark", at: world.time, headshot: false }); | |
| 275 | world.effects.push({ kind: "hitmark", at: world.time, headshot: false, killed: false }); | |
| 276 | 276 | const watching = recorder(); |
| 277 | 277 | draw(watching, world, mate); |
| 278 | 278 | // The radar draws its own moveTo near the corner for the self arrow; the |
| @@ -282,12 +282,39 @@ test("a spectator does not inherit the dead player's hit markers", () => { | ||
| 282 | 282 | |
| 283 | 283 | // Alive, the same effect is the marker it was drawn for. |
| 284 | 284 | const alive = createWorld(SHORT, "me", "ct", 5, 79); |
| 285 | alive.effects.push({ kind: "hitmark", at: alive.time, headshot: false }); | |
| 285 | alive.effects.push({ kind: "hitmark", at: alive.time, headshot: false, killed: false }); | |
| 286 | 286 | const own = recorder(); |
| 287 | 287 | draw(own, alive); |
| 288 | 288 | expect(own.calls.filter((c) => c.name === "moveTo").length).toBeGreaterThan(4); |
| 289 | 289 | }); |
| 290 | 290 | |
| 291 | test("the mark for a kill is a different shape from the one for a hit, and lasts longer", () => { | |
| 292 | /** The strokes a frame put in the middle of the screen: the crosshair's and the marker's. */ | |
| 293 | const centred = (killed: boolean, age = 0, headshot = false): Call[] => { | |
| 294 | const world = createWorld(SHORT, "me", "ct", 5, 81); | |
| 295 | world.effects.push({ kind: "hitmark", at: world.time - age, headshot, killed }); | |
| 296 | const frame = recorder(); | |
| 297 | draw(frame, world); | |
| 298 | return frame.calls.filter((c) => | |
| 299 | c.name === "moveTo" && Math.abs(c.args[0] - W / 2) < 50 && Math.abs(c.args[1] - H / 2) < 50); | |
| 300 | }; | |
| 301 | // The crosshair alone, with the marker long expired: the baseline to subtract. | |
| 302 | const crosshairOnly = centred(false, 10).length; | |
| 303 | expect(centred(false, 10, false).length).toBe(crosshairOnly); | |
| 304 | ||
| 305 | expect(centred(false).length - crosshairOnly).toBe(4); // four ticks out of the corners | |
| 306 | expect(centred(true).length - crosshairOnly).toBe(6); // and an X through the middle | |
| 307 | expect(centred(true).some((c) => c.lineWidth === 3)).toBe(true); // heavier | |
| 308 | expect(centred(false).every((c) => c.lineWidth !== 3)).toBe(true); | |
| 309 | ||
| 310 | // A third of a second on, the plain mark is gone and the kill is still up. | |
| 311 | expect(centred(false, 0.35).length).toBe(crosshairOnly); | |
| 312 | expect(centred(true, 0.35).length - crosshairOnly).toBe(6); | |
| 313 | ||
| 314 | // A headshot kill is still the headshot's red, X and all. | |
| 315 | expect(centred(true, 0, true).some((c) => c.stroke === "rgba(255,90,90,0.95)")).toBe(true); | |
| 316 | }); | |
| 317 | ||
| 291 | 318 | test("a gun under your feet that you would have to ask for says so", () => { |
| 292 | 319 | const world = createWorld(SHORT, "me", "ct", 5, 89); |
| 293 | 320 | world.me.primary = "m4a4"; |
src/client/hud.ts +19 −2
| @@ -36,6 +36,14 @@ const HURT_FADE = 1.1; | ||
| 36 | 36 | /** How long a round message stays up when the clock is not holding it there. */ |
| 37 | 37 | const MESSAGE_FADE = 4; |
| 38 | 38 | |
| 39 | /** | |
| 40 | * How long a hit marker lives, and how much longer the one for a kill does: | |
| 41 | * long enough to register as its own thing at a glance, still well inside the | |
| 42 | * two seconds `update` keeps an effect for. | |
| 43 | */ | |
| 44 | const HIT_MARK_LIFE = 0.25; | |
| 45 | const KILL_MARK_LIFE = 0.45; | |
| 46 | ||
| 39 | 47 | // The vignette is one radial gradient per canvas size, kept between frames: |
| 40 | 48 | // building it costs an allocation and drawHud runs sixty times a second, so it |
| 41 | 49 | // is built with its colours at full strength and faded with globalAlpha. |
| @@ -361,15 +369,24 @@ export function drawHud( | ||
| 361 | 369 | for (const effect of world.effects) { |
| 362 | 370 | if (effect.kind !== "hitmark") continue; |
| 363 | 371 | const age = world.time - effect.at; |
| 364 | if (age > 0.25) continue; | |
| 372 | if (age > (effect.killed ? KILL_MARK_LIFE : HIT_MARK_LIFE)) continue; | |
| 365 | 373 | ctx.strokeStyle = effect.headshot ? "rgba(255,90,90,0.95)" : "rgba(255,255,255,0.9)"; |
| 366 | ctx.lineWidth = 2; | |
| 374 | ctx.lineWidth = effect.killed ? 3 : 2; | |
| 367 | 375 | const r = 9 + age * 20; |
| 368 | 376 | ctx.beginPath(); |
| 369 | 377 | for (const [dx, dy] of [[1, 1], [-1, -1], [1, -1], [-1, 1]]) { |
| 370 | 378 | ctx.moveTo(width / 2 + dx * r * 0.55, height / 2 + dy * r * 0.55); |
| 371 | 379 | ctx.lineTo(width / 2 + dx * r, height / 2 + dy * r); |
| 372 | 380 | } |
| 381 | // The one that finished them closes the four ticks into an X through the | |
| 382 | // middle — two more strokes on the same path, heavier and held longer, | |
| 383 | // so a kill is a different shape and not a shade of the same one. A | |
| 384 | // headshot kill is that X in the headshot's red. | |
| 385 | if (effect.killed) | |
| 386 | for (const [dx, dy] of [[1, 1], [1, -1]]) { | |
| 387 | ctx.moveTo(width / 2 - dx * r * 0.45, height / 2 - dy * r * 0.45); | |
| 388 | ctx.lineTo(width / 2 + dx * r * 0.45, height / 2 + dy * r * 0.45); | |
| 389 | } | |
| 373 | 390 | ctx.stroke(); |
| 374 | 391 | } |
| 375 | 392 | } |
src/client/world.test.ts +68
| @@ -1137,6 +1137,57 @@ test("a blast counts to the thrower, spares their own side and can kill them", ( | ||
| 1137 | 1137 | expect(world.bodies.length).toBe(2); |
| 1138 | 1138 | }); |
| 1139 | 1139 | |
| 1140 | /** The hit markers sitting in the world, flattened to what they say. */ | |
| 1141 | const marksIn = (world: World): { headshot: boolean; killed: boolean }[] => | |
| 1142 | world.effects.flatMap((e) => (e.kind === "hitmark" ? [{ headshot: e.headshot, killed: e.killed }] : [])); | |
| 1143 | ||
| 1144 | /** Two enemies and a teammate around a blast, with the person well out of it. */ | |
| 1145 | function blastCrowd(seed: number): { world: World; enemies: Player[]; mate: Player } { | |
| 1146 | const world = createWorld(SHORT, "me", "ct", 5, seed); | |
| 1147 | armWithGrenade(world); | |
| 1148 | const enemies = world.players.filter((p) => p.team === "t").slice(0, 2); | |
| 1149 | const mate = world.players.find((p) => p.team === "ct" && p !== world.me) as Player; | |
| 1150 | for (const p of world.players) if (p !== world.me && p !== mate && !enemies.includes(p)) p.alive = false; | |
| 1151 | enemies[0].pos = [0, 40, 0]; | |
| 1152 | enemies[1].pos = [40, 0, 0]; | |
| 1153 | enemies[1].health = 30; // the blast finishes this one and only wounds the other | |
| 1154 | for (const e of enemies) e.armor = 0; | |
| 1155 | mate.pos = [20, 20, 0]; | |
| 1156 | world.me.pos = [-2000, 0, 0]; | |
| 1157 | return { world, enemies, mate }; | |
| 1158 | } | |
| 1159 | ||
| 1160 | test("your own blast confirms every enemy it catches, once each, and says which one it ended", () => { | |
| 1161 | const { world, enemies, mate } = blastCrowd(243); | |
| 1162 | blastAt(world, world.me, [20, 20, 40]); | |
| 1163 | ||
| 1164 | expect(enemies[0].alive).toBe(true); | |
| 1165 | expect(enemies[1].alive).toBe(false); | |
| 1166 | expect(mate.health).toBe(100); // the teammate it spared is not a hit either | |
| 1167 | const marks = marksIn(world); | |
| 1168 | expect(marks.length).toBe(2); // one per enemy damaged, not one per blast | |
| 1169 | expect(marks.every((m) => !m.headshot)).toBe(true); // a blast has no head to find | |
| 1170 | expect(marks.filter((m) => m.killed).length).toBe(1); | |
| 1171 | }); | |
| 1172 | ||
| 1173 | test("a teammate's grenade on the same two pushes no marker at all", () => { | |
| 1174 | const { world, enemies, mate } = blastCrowd(245); | |
| 1175 | blastAt(world, mate, [20, 20, 40]); // the bot beside you threw it, not you | |
| 1176 | expect(enemies[0].health).toBeLessThan(100); // it landed on both of them | |
| 1177 | expect(enemies[1].alive).toBe(false); | |
| 1178 | expect(marksIn(world)).toEqual([]); | |
| 1179 | }); | |
| 1180 | ||
| 1181 | test("a blast that only reaches the person who threw it marks nothing", () => { | |
| 1182 | const world = createWorld(SHORT, "me", "ct", 5, 247); | |
| 1183 | armWithGrenade(world); | |
| 1184 | for (const p of world.players) if (p !== world.me) p.alive = false; | |
| 1185 | world.me.armor = 0; | |
| 1186 | blastAt(world, world.me, [20, 20, 40]); | |
| 1187 | expect(world.me.health).toBeLessThan(100); // it hurt | |
| 1188 | expect(marksIn(world)).toEqual([]); // your own body is not a hit confirmed | |
| 1189 | }); | |
| 1190 | ||
| 1140 | 1191 | test("a blast can end a round, and cannot end one twice", () => { |
| 1141 | 1192 | const world = createWorld(SHORT, "me", "ct", 5, 239); |
| 1142 | 1193 | armWithGrenade(world); |
| @@ -1547,6 +1598,23 @@ test("you stabbing a bot in the back is the same rule, and marks the hit", () => | ||
| 1547 | 1598 | expect(world.feed[world.feed.length - 1].weapon).toBe("knife"); |
| 1548 | 1599 | }); |
| 1549 | 1600 | |
| 1601 | test("the hit that finished them is marked killed; the ones that only hurt are not", () => { | |
| 1602 | const world = createWorld(SHORT, "me", "ct", 5, 411); | |
| 1603 | const bot = world.players.find((p) => p.team === "t") as Player; | |
| 1604 | knifeRange(world, world.me, bot, 180); // turned round: a stab in the chest, not the back | |
| 1605 | expect(shoot(world, world.me)).toBe(true); | |
| 1606 | expect(bot.alive).toBe(true); | |
| 1607 | expect(marksIn(world)).toEqual([{ headshot: false, killed: false }]); | |
| 1608 | ||
| 1609 | // The same stab into what is left of them: the same mark, saying killed. | |
| 1610 | bot.health = 20; | |
| 1611 | world.me.nextShotAt = 0; | |
| 1612 | world.effects.length = 0; | |
| 1613 | expect(shoot(world, world.me)).toBe(true); | |
| 1614 | expect(bot.alive).toBe(false); | |
| 1615 | expect(marksIn(world)).toEqual([{ headshot: false, killed: true }]); | |
| 1616 | }); | |
| 1617 | ||
| 1550 | 1618 | test("a rifle in someone's back does what a rifle did before the knife learned this", () => { |
| 1551 | 1619 | const world = createWorld(SHORT, "me", "ct", 5, 407); |
| 1552 | 1620 | const bot = world.players.find((p) => p.team === "t") as Player; |
src/client/world.ts +16 −2
| @@ -118,7 +118,10 @@ export type Effect = | ||
| 118 | 118 | // teammate shoot — and it is also the ear the bots listen with, which skips |
| 119 | 119 | // its own side's guns. |
| 120 | 120 | | { kind: "shot"; at: number; pos: Vec3; weapon: WeaponId; mine: boolean; shooter: number } |
| 121 | | { kind: "hitmark"; at: number; headshot: boolean } | |
| 121 | // A hit of the person's own, and only theirs: `headshot` is where it landed | |
| 122 | // and `killed` is whether it was the one that finished them, so the mark for | |
| 123 | // the fifth bullet reads differently from the four before it. | |
| 124 | | { kind: "hitmark"; at: number; headshot: boolean; killed: boolean } | |
| 122 | 125 | // Damage the person took: who took it, where it came from in the world, and |
| 123 | 126 | // how much. Only ever pushed for the person — a bot being shot is silent. |
| 124 | 127 | | { kind: "hurt"; at: number; victim: number; from: Vec3; amount: number } |
| @@ -868,7 +871,10 @@ export function shoot(world: World, p: Player): boolean { | ||
| 868 | 871 | p.damage += result.damage; |
| 869 | 872 | recordDamage(world, p.id, victim.id, result.damage, result.killed); |
| 870 | 873 | world.effects.push({ kind: "blood", at: world.time, pos: hit.point }); |
| 871 | if (p === world.me) world.effects.push({ kind: "hitmark", at: world.time, headshot: result.headshot }); | |
| 874 | if (p === world.me) | |
| 875 | world.effects.push({ | |
| 876 | kind: "hitmark", at: world.time, headshot: result.headshot, killed: result.killed, | |
| 877 | }); | |
| 872 | 878 | if (result.damage > 0) hurt(world, victim, from, result.damage); |
| 873 | 879 | if (result.killed) onKill(world, p, victim, w.id, result.headshot); |
| 874 | 880 | } |
| @@ -1055,6 +1061,9 @@ function detonate(world: World, g: Grenade): void { | ||
| 1055 | 1061 | }); |
| 1056 | 1062 | } |
| 1057 | 1063 | const thrower = world.players.find((p) => p.id === g.owner) ?? null; |
| 1064 | // Whether the person threw this one, decided once: a bot's grenade pushes | |
| 1065 | // nothing extra, so the arena's hot loop is exactly what it was. | |
| 1066 | const mine = thrower === world.me; | |
| 1058 | 1067 | for (const victim of world.players) { |
| 1059 | 1068 | if (!victim.alive) continue; |
| 1060 | 1069 | if (victim.team === g.team && victim.id !== g.owner) continue; |
| @@ -1070,6 +1079,11 @@ function detonate(world: World, g: Grenade): void { | ||
| 1070 | 1079 | const owner = thrower !== null && thrower !== victim ? thrower : null; |
| 1071 | 1080 | if (owner !== null) owner.damage += dealt; |
| 1072 | 1081 | recordDamage(world, owner?.id ?? ENVIRONMENT, victim.id, dealt, killed); |
| 1082 | // The confirmation a bullet already gives, once per enemy the blast | |
| 1083 | // reached: your own damage, never your own body, and never a headshot — | |
| 1084 | // a blast has no head to find. | |
| 1085 | if (mine && victim !== thrower) | |
| 1086 | world.effects.push({ kind: "hitmark", at: world.time, headshot: false, killed }); | |
| 1073 | 1087 | hurt(world, victim, g.pos, dealt); |
| 1074 | 1088 | if (killed) { |
| 1075 | 1089 | victim.health = 0; |