Split The code is public; the app answers only to the crew.

Every round ends with a damage report: what you did to them, and they to you #67

Merged opened this

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

Merged

scout/cs:scout/39-every-round-ends-with-a-damage-report-wh → main 3 commits fork

  • Merged. scout/39-every-round-ends-with-a-damage-report-wh was merged into main as e973e0b.

Review

Sign in to review this change.
9 files changed +341 −15
README.md +7 −3
@@ -199,9 +199,13 @@ teammate. Whoever dies stays on the floor where they fell, lying the way they
199199 were facing and tinted their side, until the next round starts — so the rifle
200200 dropped in a doorway has a body under it and a fight leaves a record of itself.
201201 A body is decoration and nothing more: bullets go through it, it blocks nobody,
202 and no bot has ever seen one. The round's result stays on the screen through the freeze time, and
203 when the match ends a card says who won, the final score from your side and
204 what you did, with a button to play again and one back to the menu.
202 and no bot has ever seen one. The round's result stays on the screen through the freeze time.
203 For the few seconds right after the round ends, before the buy menu's freeze
204 time takes the middle of the screen, a damage report sits under the result:
205 everyone you hurt and everyone who hurt you this round, with the hits and the
206 kills, and one line for the totals either way. When the match ends a card says
207 who won, the final score from your side and what you did, with a button to
208 play again and one back to the menu.
205209
206210 The counter in the corner of the view is the measured frame time. When a
207211 machine cannot hold 60 fps the scene is drawn into a smaller buffer — half the
src/client/hud.test.ts +26
@@ -220,6 +220,32 @@ test("the round result stays up through the end delay and the freeze that follow
220220 expect(live.calls.some((c) => c.text.includes("defused the bomb"))).toBe(false);
221221 });
222222
223 test("the damage report is drawn while the round is over, and not while it is live", () => {
224 const world = createWorld(SHORT, "me", "ct", 5, 103);
225 const enemy = world.players.find((p) => p.team === "t") as Player;
226 world.damageLog[world.me.id][enemy.id] = { damage: 42, hits: 2, killed: true };
227
228 world.match.phase = "over";
229 const over = recorder();
230 draw(over, world);
231 const overTexts = over.calls.filter((c) => c.name === "fillText").map((c) => c.text);
232 expect(overTexts.some((t) => t.includes(`${enemy.name} — 42 dmg`))).toBe(true);
233 expect(overTexts.some((t) => t.startsWith("Dealt"))).toBe(true);
234
235 world.match.phase = "live";
236 const live = recorder();
237 draw(live, world);
238 const liveTexts = live.calls.filter((c) => c.name === "fillText").map((c) => c.text);
239 expect(liveTexts.some((t) => t.includes(`${enemy.name} — 42 dmg`))).toBe(false);
240
241 // The scoreboard covers the same pixels, so a held Tab wins over the report.
242 world.match.phase = "over";
243 const withScoreboard = recorder();
244 draw(withScoreboard, world, world.me, true);
245 const scoreboardTexts = withScoreboard.calls.filter((c) => c.name === "fillText").map((c) => c.text);
246 expect(scoreboardTexts.some((t) => t.startsWith("Dealt"))).toBe(false);
247 });
248
223249 test("the radar sits in the bottom-left corner and labels both sites", () => {
224250 const world = createWorld(SHORT, "me", "ct", 5, 91);
225251 const frame = recorder();
src/client/hud.ts +5
@@ -12,6 +12,7 @@ import { angleDelta, clamp, rad } from "./math.ts";
1212 import { walkable, type MapDef } from "./map.ts";
1313 import { MOVE } from "./physics.ts";
1414 import { buildRadar } from "./radar.ts";
15 import { drawReport } from "./reportPanel.ts";
1516 import { deathNotice } from "./spectate.ts";
1617 import { inaccuracy, WEAPONS } from "./weapons.ts";
1718 import { currentWeapon, eyePos, offeredPickup, useProgress, type Player, type World } from "./world.ts";
@@ -442,6 +443,10 @@ export function drawHud(
442443 );
443444 }
444445
446 // --- the damage report: only during the round-over delay, and never under
447 // the scoreboard, which a held Tab can ask for at the same moment.
448 if (!showScoreboard) drawReport(ctx, world, view, width, height, scale);
449
445450 if (showScoreboard) drawScoreboard(ctx, world, width, height, scale);
446451 }
447452
src/client/report.test.ts added +59
@@ -0,0 +1,59 @@
1 /**
2 * `report.ts` against a known world: no shooting, just a ledger set by hand,
3 * so the lines it produces are checked without a round having to be played.
4 */
5
6 import { expect, test } from "bun:test";
7 import { SHORT } from "./match.ts";
8 import { report } from "./report.ts";
9 import { createWorld, ENVIRONMENT, type Player } from "./world.ts";
10
11 test("a report lists what the person dealt and took, sorted by damage, with a kill marked", () => {
12 const world = createWorld(SHORT, "me", "ct", 5, 401);
13 const enemy = world.players.find((p) => p.team === "t") as Player;
14 const otherEnemy = world.players.find((p) => p.team === "t" && p !== enemy) as Player;
15
16 // Two opponents hit; the smaller one first, so the sort has something to do.
17 world.damageLog[world.me.id][enemy.id] = { damage: 40, hits: 2, killed: false };
18 world.damageLog[world.me.id][otherEnemy.id] = { damage: 90, hits: 1, killed: true };
19 world.damageLog[enemy.id][world.me.id] = { damage: 25, hits: 1, killed: false };
20
21 const rep = report(world, world.me);
22 expect(rep.dealt).toEqual([
23 `${otherEnemy.name} — 90 dmg, 1 hit (killed)`,
24 `${enemy.name} — 40 dmg, 2 hits`,
25 ]);
26 expect(rep.taken).toEqual([`${enemy.name} — 25 dmg, 1 hit`]);
27 expect(rep.total).toBe("Dealt 130 — took 25");
28 });
29
30 test("a report says nothing about a pair the person was not part of", () => {
31 const world = createWorld(SHORT, "me", "ct", 5, 403);
32 const [a, b] = world.players.filter((p) => p.team === "ct" && p !== world.me) as [Player, Player];
33 world.damageLog[a.id][b.id] = { damage: 99, hits: 5, killed: false };
34
35 const rep = report(world, world.me);
36 expect(rep.dealt).toEqual([]);
37 expect(rep.taken).toEqual([]);
38 expect(rep.total).toBe("Dealt 0 — took 0");
39 });
40
41 test("damage nobody threw — the bomb — is named for what it is", () => {
42 const world = createWorld(SHORT, "me", "ct", 5, 407);
43 world.damageLog[world.players.length][world.me.id] = { damage: 100, hits: 1, killed: true };
44
45 const rep = report(world, world.me);
46 expect(rep.taken).toEqual(["the bomb — 100 dmg, 1 hit (killed)"]);
47 });
48
49 test("a player with nothing dealt or taken gets a report that says so, not a blank one", () => {
50 const world = createWorld(SHORT, "me", "ct", 5, 409);
51 const rep = report(world, world.me);
52 expect(rep.dealt).toEqual([]);
53 expect(rep.taken).toEqual([]);
54 expect(rep.total).toBe("Dealt 0 — took 0");
55 });
56
57 test("ENVIRONMENT is not a real player id", () => {
58 expect(ENVIRONMENT).toBeLessThan(0);
59 });
src/client/report.ts added +31
@@ -0,0 +1,31 @@
1 /**
2 * The round's damage report, in plain lines: what `view` dealt, what they
3 * took, and a one-line total. It reads `world.damageLog` and `world.players`
4 * and knows nothing about drawing — the HUD turns these strings into pixels.
5 */
6
7 import { damageDealt, damageTaken, ENVIRONMENT, type Player, type World } from "./world.ts";
8
9 export type Report = {
10 dealt: string[]; // one line per opponent hit, most damage first
11 taken: string[]; // one line per source of damage taken, most damage first
12 total: string;
13 };
14
15 const nameOf = (world: World, id: number): string =>
16 id === ENVIRONMENT ? "the bomb" : world.players.find((p) => p.id === id)?.name ?? "?";
17
18 const line = (name: string, damage: number, hits: number, killed: boolean): string =>
19 `${name} — ${Math.round(damage)} dmg, ${hits} hit${hits === 1 ? "" : "s"}${killed ? " (killed)" : ""}`;
20
21 export function report(world: World, view: Player): Report {
22 const dealt = damageDealt(world, view.id).sort((a, b) => b.entry.damage - a.entry.damage);
23 const taken = damageTaken(world, view.id).sort((a, b) => b.entry.damage - a.entry.damage);
24 const totalDealt = dealt.reduce((sum, d) => sum + d.entry.damage, 0);
25 const totalTaken = taken.reduce((sum, d) => sum + d.entry.damage, 0);
26 return {
27 dealt: dealt.map(({ id, entry }) => line(nameOf(world, id), entry.damage, entry.hits, entry.killed)),
28 taken: taken.map(({ id, entry }) => line(nameOf(world, id), entry.damage, entry.hits, entry.killed)),
29 total: `Dealt ${Math.round(totalDealt)} — took ${Math.round(totalTaken)}`,
30 };
31 }
src/client/reportPanel.ts added +64
@@ -0,0 +1,64 @@
1 /**
2 * Draws the round's damage report on the HUD canvas, during the round-over
3 * delay only — the one moment nothing else needs the middle of the screen.
4 * Split out of hud.ts so the report is its own function rather than another
5 * branch inside `drawHud`.
6 */
7
8 import { report } from "./report.ts";
9 import type { Player, World } from "./world.ts";
10
11 /** More than a 5v5 round ever needs: one line per living opponent, at most. */
12 const MAX_LINES = 5;
13
14 export function drawReport(
15 ctx: CanvasRenderingContext2D,
16 world: World,
17 view: Player,
18 width: number,
19 height: number,
20 scale: number,
21 ): void {
22 if (world.match.phase !== "over") return;
23
24 const rep = report(world, view);
25 const lineH = Math.round(19 * scale);
26 const rows = 3 + Math.min(rep.dealt.length, MAX_LINES) + Math.min(rep.taken.length, MAX_LINES) +
27 (rep.dealt.length === 0 ? 1 : 0) + (rep.taken.length === 0 ? 1 : 0);
28 const w = Math.min(width - 24, Math.round(360 * scale));
29 const h = rows * lineH + Math.round(12 * scale);
30 const x = width / 2 - w / 2;
31 const y = height * 0.32;
32
33 ctx.fillStyle = "rgba(10, 12, 16, 0.72)";
34 ctx.fillRect(x, y, w, h);
35
36 let ty = y + lineH;
37 ctx.textAlign = "center";
38 ctx.font = `${Math.round(15 * scale)}px system-ui, sans-serif`;
39 ctx.fillStyle = "#f3f5f8";
40 ctx.fillText(rep.total, width / 2, ty);
41 ty += lineH;
42
43 const small = `${Math.round(14 * scale)}px system-ui, sans-serif`;
44 ctx.textAlign = "left";
45 ctx.font = small;
46
47 const section = (title: string, color: string, lines: string[]): void => {
48 ctx.fillStyle = color;
49 ctx.fillText(title, x + 12, ty);
50 ty += lineH;
51 ctx.fillStyle = "#c8ccd4";
52 if (lines.length === 0) {
53 ctx.fillText("nothing", x + 12, ty);
54 ty += lineH;
55 } else {
56 for (const line of lines.slice(0, MAX_LINES)) {
57 ctx.fillText(line, x + 12, ty);
58 ty += lineH;
59 }
60 }
61 };
62 section("Dealt", "#e2b04a", rep.dealt);
63 section("Took", "#6fa8e8", rep.taken);
64 }
src/client/trace.test.ts +7 −7
@@ -1,5 +1,5 @@
11 import { expect, test } from "bun:test";
2 import { boxes as mapBoxes, sites, spawns } from "./map.ts";
2 import { DE_FORGE } from "./map.ts";
33 import type { Vec3 } from "./math.ts";
44 import { rayBox, traceWorld, visible } from "./trace.ts";
55
@@ -72,16 +72,16 @@ test("visible is false through a wall and true down an open corridor, on the rea
7272 // A and B are on opposite sides of the map with walls and doors between
7373 // them: no straight line between the two sites' centres is ever clear.
7474 expect(visible(
75 [sites.A[0], sites.A[1], eye],
76 [sites.B[0], sites.B[1], eye],
77 mapBoxes,
75 [DE_FORGE.sites.A[0], DE_FORGE.sites.A[1], eye],
76 [DE_FORGE.sites.B[0], DE_FORGE.sites.B[1], eye],
77 DE_FORGE.boxes,
7878 )).toBe(false);
7979 // CT spawn and A site sit at opposite ends of the same open run (ct mid,
8080 // then the link into the site), with nothing solid staged between them.
81 const ctSpawn = spawns.ct[12] as Vec3;
81 const ctSpawn = DE_FORGE.spawns.ct[12] as Vec3;
8282 expect(visible(
8383 [ctSpawn[0], ctSpawn[1], eye],
84 [sites.A[0], sites.A[1], eye],
85 mapBoxes,
84 [DE_FORGE.sites.A[0], DE_FORGE.sites.A[1], eye],
85 DE_FORGE.boxes,
8686 )).toBe(true);
8787 });
src/client/world.test.ts +76 −2
@@ -4,8 +4,9 @@ import { DE_FORGE, siteAt } from "./map.ts";
44 import { SHORT, winReward } from "./match.ts";
55 import { WEAPONS } from "./weapons.ts";
66 import {
7 aimAt, applyInput, bodyCentre, BOMB_BLAST_RADIUS, buy, createWorld, currentWeapon, eyePos, flinch,
8 NO_INPUT, offeredPickup, shoot, startRound, update, type Input, type Player, type World,
7 aimAt, applyInput, bodyCentre, BOMB_BLAST_RADIUS, buy, createWorld, currentWeapon, damageDealt,
8 damageTaken, ENVIRONMENT, eyePos, flinch, NO_INPUT, offeredPickup, shoot, startRound, update,
9 type Input, type Player, type World,
910 } from "./world.ts";
1011
1112 const TICK = 1 / 64;
@@ -808,3 +809,76 @@ test("a grenade off a wall says so: one bounce effect, where it hit", () => {
808809 update(world, TICK, NO_INPUT);
809810 expect(soundsNow(world, "bounce")).toBe(0);
810811 });
812
813 // --- the damage ledger -----------------------------------------------------
814
815 test("the damage ledger sums repeated hits and clears at the next round", () => {
816 const world = createWorld(SHORT, "me", "ct", 5, 301);
817 const shooter = world.players.find((p) => p.team === "t") as Player;
818 faceOff(world, shooter);
819 expect(shoot(world, shooter)).toBe(true);
820 shooter.nextShotAt = 0; // fire again right away rather than wait out the rpm
821 expect(shoot(world, shooter)).toBe(true);
822
823 const dealt = damageDealt(world, shooter.id);
824 expect(dealt.length).toBe(1); // one opponent, however many hits
825 expect(dealt[0].id).toBe(world.me.id);
826 expect(dealt[0].entry.hits).toBe(2);
827 expect(dealt[0].entry.damage).toBeGreaterThan(0);
828
829 const taken = damageTaken(world, world.me.id);
830 expect(taken).toEqual([{ id: shooter.id, entry: dealt[0].entry }]);
831
832 startRound(world);
833 expect(damageDealt(world, shooter.id).length).toBe(0);
834 expect(damageTaken(world, world.me.id).length).toBe(0);
835 });
836
837 test("a player who both dealt and took appears in both lists", () => {
838 const world = createWorld(SHORT, "me", "ct", 5, 233);
839 armWithGrenade(world);
840 const mate = world.players.find((p) => p.team === "ct" && p !== world.me) as Player;
841 const enemy = world.players.find((p) => p.team === "t") as Player;
842 for (const p of world.players) if (p !== world.me && p !== mate && p !== enemy) p.alive = false;
843 mate.pos = [40, 0, 0];
844 enemy.pos = [0, 40, 0];
845 enemy.armor = 0;
846 world.me.armor = 0;
847 enemy.health = 80;
848 world.me.health = 80;
849 blastAt(world, world.me, [20, 20, 40]); // close enough to finish both
850
851 const dealt = damageDealt(world, world.me.id);
852 expect(dealt.length).toBe(1);
853 expect(dealt[0].id).toBe(enemy.id);
854 expect(dealt[0].entry.killed).toBe(true);
855
856 // The blast that killed the thrower is their own — nobody else's credit.
857 const taken = damageTaken(world, world.me.id);
858 expect(taken.length).toBe(1);
859 expect(taken[0].id).toBe(ENVIRONMENT);
860 expect(taken[0].entry.killed).toBe(true);
861 });
862
863 test("the planted bomb's damage lands in the ledger with nobody credited", () => {
864 const world = createWorld(SHORT, "me", "ct", 5, 41);
865 world.bomb = {
866 state: "planted", pos: [DE_FORGE.sites.A[0], DE_FORGE.sites.A[1], 2], site: "A",
867 plantProgress: 0, defuseProgress: 0, defuser: null, beepAt: 0,
868 };
869 world.bombPlanted = true;
870 world.match.phase = "planted";
871 world.match.timer = TICK / 2;
872 for (const p of world.players) {
873 p.pos = [DE_FORGE.sites.A[0], DE_FORGE.sites.A[1], 2];
874 p.alive = true;
875 p.health = 100;
876 }
877 update(world, TICK, NO_INPUT);
878
879 const taken = damageTaken(world, world.me.id);
880 expect(taken.length).toBe(1);
881 expect(taken[0].id).toBe(ENVIRONMENT);
882 expect(taken[0].entry.damage).toBe(100);
883 expect(taken[0].entry.killed).toBe(true);
884 });
src/client/world.ts +66 −3
@@ -107,6 +107,16 @@ export type FeedEntry = {
107107 killerTeam: Team;
108108 };
109109
110 /** One pairing in the round's damage ledger: what one player did to another. */
111 export type DamageEntry = { damage: number; hits: number; killed: boolean };
112
113 /**
114 * The pseudo-attacker for damage nobody threw: the planted bomb going off.
115 * Not a player id — `damageTaken` hands it back so a report can still say
116 * where the damage came from.
117 */
118 export const ENVIRONMENT = -1;
119
110120 export type Input = {
111121 forward: number;
112122 side: number;
@@ -137,6 +147,13 @@ export type World = {
137147 pickups: Pickup[];
138148 grenades: Grenade[]; // the ones in the air, or on the floor with a fuse left
139149 bodies: Corpse[]; // the dead of this round, cleared when the next one starts
150 /**
151 * This round's damage, attacker-major: `damageLog[attackerId][victimId]`.
152 * One extra row past the players holds `ENVIRONMENT`'s damage. Sized once
153 * in `createWorld` and zeroed in place by `startRound`, so a bullet never
154 * allocates to be counted.
155 */
156 damageLog: DamageEntry[][];
140157 effects: Effect[];
141158 feed: FeedEntry[];
142159 messages: { at: number; text: string }[];
@@ -207,6 +224,8 @@ export function createWorld(
207224 plan: roundPlan(seed, match.round),
208225 bomb: { state: "carried", pos: [0, 0, 0], site: null, plantProgress: 0, defuseProgress: 0, defuser: null, beepAt: 0 },
209226 pickups: [], grenades: [], bodies: [],
227 damageLog: Array.from({ length: players.length + 1 }, () =>
228 Array.from({ length: players.length }, () => ({ damage: 0, hits: 0, killed: false }))),
210229 effects: [], feed: [], messages: [],
211230 random, map, boxes: map.boxes, bombPlanted: false, pendingBuy: false,
212231 difficulty, pendingDifficulty: null,
@@ -289,6 +308,11 @@ export function startRound(world: World): void {
289308 // which is the rule the guns above already follow.
290309 world.grenades.length = 0;
291310 world.bodies.length = 0;
311 for (const row of world.damageLog) for (const entry of row) {
312 entry.damage = 0;
313 entry.hits = 0;
314 entry.killed = false;
315 }
292316 world.feed = [];
293317 for (const p of world.players) if (p.bot) botBuy(world, p);
294318 }
@@ -423,6 +447,39 @@ function hurt(world: World, victim: Player, from: Vec3, amount: number): void {
423447 world.effects.push({ kind: "hurt", at: world.time, victim: victim.id, from: [...from] as Vec3, amount });
424448 }
425449
450 /**
451 * Add one hit to the round's ledger. `attackerId` is `ENVIRONMENT` for damage
452 * nobody threw. Only ever touches fields already sitting in `damageLog` — no
453 * push, no object literal — so a bullet in the RL arena's hot loop costs it
454 * nothing more than the field writes it already made.
455 */
456 function recordDamage(world: World, attackerId: number, victimId: number, amount: number, killed: boolean): void {
457 if (amount <= 0) return;
458 const row = world.damageLog[attackerId === ENVIRONMENT ? world.players.length : attackerId];
459 const entry = row?.[victimId];
460 if (entry === undefined) return;
461 entry.damage += amount;
462 entry.hits += 1;
463 if (killed) entry.killed = true;
464 }
465
466 /** What `attackerId` did to each opponent this round, real hits only. */
467 export function damageDealt(world: World, attackerId: number): { id: number; entry: DamageEntry }[] {
468 const row = world.damageLog[attackerId] ?? [];
469 return row.map((entry, id) => ({ id, entry })).filter(({ entry }) => entry.hits > 0);
470 }
471
472 /** What `victimId` took this round, from each attacker (including `ENVIRONMENT`). */
473 export function damageTaken(world: World, victimId: number): { id: number; entry: DamageEntry }[] {
474 const n = world.players.length;
475 const out: { id: number; entry: DamageEntry }[] = [];
476 for (let slot = 0; slot <= n; slot++) {
477 const entry = world.damageLog[slot]?.[victimId];
478 if (entry !== undefined && entry.hits > 0) out.push({ id: slot === n ? ENVIRONMENT : slot, entry });
479 }
480 return out;
481 }
482
426483 /** How much view punch a point of damage is worth, and the most it can add up to. */
427484 const FLINCH_PER_DAMAGE = 0.06;
428485 const FLINCH_MAX = 6;
@@ -494,6 +551,7 @@ export function shoot(world: World, p: Player): boolean {
494551 if (victim.team === p.team && victim !== p) return true; // no friendly fire in this match
495552 const result = applyHit(victim, w, hit.group, hit.distance);
496553 p.damage += result.damage;
554 recordDamage(world, p.id, victim.id, result.damage, result.killed);
497555 world.effects.push({ kind: "blood", at: world.time, pos: hit.point });
498556 if (p === world.me) world.effects.push({ kind: "hitmark", at: world.time, headshot: result.headshot });
499557 if (result.damage > 0) hurt(world, victim, from, result.damage);
@@ -686,9 +744,12 @@ function detonate(world: World, g: Grenade): void {
686744 const dealt = Math.min(health, victim.health);
687745 victim.armor = Math.max(0, victim.armor - armor);
688746 victim.health -= health;
689 if (thrower !== null && thrower !== victim) thrower.damage += dealt;
747 const killed = victim.health <= 0;
748 const owner = thrower !== null && thrower !== victim ? thrower : null;
749 if (owner !== null) owner.damage += dealt;
750 recordDamage(world, owner?.id ?? ENVIRONMENT, victim.id, dealt, killed);
690751 hurt(world, victim, g.pos, dealt);
691 if (victim.health <= 0) {
752 if (killed) {
692753 victim.health = 0;
693754 onKill(world, thrower ?? victim, victim, "he", false);
694755 }
@@ -893,11 +954,13 @@ export function update(world: World, dt: number, input: Input): void {
893954 const dealt = Math.min(health, p.health);
894955 p.armor = Math.max(0, p.armor - armor);
895956 p.health -= health;
957 const killed = p.health <= 0;
896958 hurt(world, p, world.bomb.pos, dealt);
959 recordDamage(world, ENVIRONMENT, p.id, dealt, killed);
897960 // Nobody planted this for a kill: the bomb credits nobody, the same
898961 // way killing yourself with your own grenade does not — `onKill`
899962 // with the victim standing in for the killer.
900 if (p.health <= 0) {
963 if (killed) {
901964 p.health = 0;
902965 onKill(world, p, p, "bomb", false);
903966 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs