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

Merged opened this

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

Merged

vesper/cs:vesper/199-a-kill-looks-exactly-like-a-hit-and-a-gr → main 1 commit fork

  • Merged. vesper/199-a-kill-looks-exactly-like-a-hit-and-a-gr was merged into main as 1a1e520.

Review

Sign in to review this change.
7 files changed +152 −11
README.md +5 −1
@@ -88,7 +88,11 @@ wants one, but the running server never reads it.
8888 kept is still yours next round, the same rule the guns follow. The bots do not
8989 buy or throw one: their fight was learned in an arena that has no grenades in
9090 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
9296 the crosshair pointing at whoever fired, a vignette that reddens as your health
9397 falls and a pain sound. The gun says what it is doing, too. Every shot lights
9498 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
7474
7575 test("a shot and a hitmark carry whatever audio.ts needs and nothing else", () => {
7676 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 };
7878 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 }]);
8087 });
src/client/earshot.ts +6 −2
@@ -35,7 +35,10 @@ const EARSHOT: Partial<Record<SoundName, number>> = {
3535 /** One thing worth playing: exactly what `audio.ts` and `flinch` need, and nothing else. */
3636 export type Cue =
3737 | { 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 }
3942 | { kind: "hurt"; from: Vec3; amount: number }
4043 | { kind: "step"; pos: Vec3 }
4144 | { kind: "world"; name: WorldSoundName; pos: Vec3; pitch?: number };
@@ -51,7 +54,8 @@ export function cuesHeard(effects: Effect[], since: number, listener: Vec3, loca
5154 for (const effect of effects) {
5255 if (effect.at <= since) continue;
5356 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 });
5559 else if (effect.kind === "hurt") {
5660 if (effect.victim === localId) cues.push({ kind: "hurt", from: effect.from, amount: effect.amount });
5761 } 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
272272 test("a spectator does not inherit the dead player's hit markers", () => {
273273 const world = spectatedWorld();
274274 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 });
276276 const watching = recorder();
277277 draw(watching, world, mate);
278278 // 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", () => {
282282
283283 // Alive, the same effect is the marker it was drawn for.
284284 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 });
286286 const own = recorder();
287287 draw(own, alive);
288288 expect(own.calls.filter((c) => c.name === "moveTo").length).toBeGreaterThan(4);
289289 });
290290
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
291318 test("a gun under your feet that you would have to ask for says so", () => {
292319 const world = createWorld(SHORT, "me", "ct", 5, 89);
293320 world.me.primary = "m4a4";
src/client/hud.ts +19 −2
@@ -36,6 +36,14 @@ const HURT_FADE = 1.1;
3636 /** How long a round message stays up when the clock is not holding it there. */
3737 const MESSAGE_FADE = 4;
3838
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
3947 // The vignette is one radial gradient per canvas size, kept between frames:
4048 // building it costs an allocation and drawHud runs sixty times a second, so it
4149 // is built with its colours at full strength and faded with globalAlpha.
@@ -361,15 +369,24 @@ export function drawHud(
361369 for (const effect of world.effects) {
362370 if (effect.kind !== "hitmark") continue;
363371 const age = world.time - effect.at;
364 if (age > 0.25) continue;
372 if (age > (effect.killed ? KILL_MARK_LIFE : HIT_MARK_LIFE)) continue;
365373 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;
367375 const r = 9 + age * 20;
368376 ctx.beginPath();
369377 for (const [dx, dy] of [[1, 1], [-1, -1], [1, -1], [-1, 1]]) {
370378 ctx.moveTo(width / 2 + dx * r * 0.55, height / 2 + dy * r * 0.55);
371379 ctx.lineTo(width / 2 + dx * r, height / 2 + dy * r);
372380 }
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 }
373390 ctx.stroke();
374391 }
375392 }
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", (
11371137 expect(world.bodies.length).toBe(2);
11381138 });
11391139
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
11401191 test("a blast can end a round, and cannot end one twice", () => {
11411192 const world = createWorld(SHORT, "me", "ct", 5, 239);
11421193 armWithGrenade(world);
@@ -1547,6 +1598,23 @@ test("you stabbing a bot in the back is the same rule, and marks the hit", () =>
15471598 expect(world.feed[world.feed.length - 1].weapon).toBe("knife");
15481599 });
15491600
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
15501618 test("a rifle in someone's back does what a rifle did before the knife learned this", () => {
15511619 const world = createWorld(SHORT, "me", "ct", 5, 407);
15521620 const bot = world.players.find((p) => p.team === "t") as Player;
src/client/world.ts +16 −2
@@ -118,7 +118,10 @@ export type Effect =
118118 // teammate shoot — and it is also the ear the bots listen with, which skips
119119 // its own side's guns.
120120 | { 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 }
122125 // Damage the person took: who took it, where it came from in the world, and
123126 // how much. Only ever pushed for the person — a bot being shot is silent.
124127 | { kind: "hurt"; at: number; victim: number; from: Vec3; amount: number }
@@ -868,7 +871,10 @@ export function shoot(world: World, p: Player): boolean {
868871 p.damage += result.damage;
869872 recordDamage(world, p.id, victim.id, result.damage, result.killed);
870873 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 });
872878 if (result.damage > 0) hurt(world, victim, from, result.damage);
873879 if (result.killed) onKill(world, p, victim, w.id, result.headshot);
874880 }
@@ -1055,6 +1061,9 @@ function detonate(world: World, g: Grenade): void {
10551061 });
10561062 }
10571063 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;
10581067 for (const victim of world.players) {
10591068 if (!victim.alive) continue;
10601069 if (victim.team === g.team && victim.id !== g.owner) continue;
@@ -1070,6 +1079,11 @@ function detonate(world: World, g: Grenade): void {
10701079 const owner = thrower !== null && thrower !== victim ? thrower : null;
10711080 if (owner !== null) owner.damage += dealt;
10721081 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 });
10731087 hurt(world, victim, g.pos, dealt);
10741088 if (killed) {
10751089 victim.health = 0;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs