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
Check · Review · Land · Live done — Landed and live in production.
scout/cs:scout/39-every-round-ends-with-a-damage-report-wh → main 3 commits fork
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 | ||
| 199 | 199 | were facing and tinted their side, until the next round starts — so the rifle |
| 200 | 200 | dropped in a doorway has a body under it and a fight leaves a record of itself. |
| 201 | 201 | 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. | |
| 205 | 209 | |
| 206 | 210 | The counter in the corner of the view is the measured frame time. When a |
| 207 | 211 | 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 | ||
| 220 | 220 | expect(live.calls.some((c) => c.text.includes("defused the bomb"))).toBe(false); |
| 221 | 221 | }); |
| 222 | 222 | |
| 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 | ||
| 223 | 249 | test("the radar sits in the bottom-left corner and labels both sites", () => { |
| 224 | 250 | const world = createWorld(SHORT, "me", "ct", 5, 91); |
| 225 | 251 | const frame = recorder(); |
src/client/hud.ts +5
| @@ -12,6 +12,7 @@ import { angleDelta, clamp, rad } from "./math.ts"; | ||
| 12 | 12 | import { walkable, type MapDef } from "./map.ts"; |
| 13 | 13 | import { MOVE } from "./physics.ts"; |
| 14 | 14 | import { buildRadar } from "./radar.ts"; |
| 15 | import { drawReport } from "./reportPanel.ts"; | |
| 15 | 16 | import { deathNotice } from "./spectate.ts"; |
| 16 | 17 | import { inaccuracy, WEAPONS } from "./weapons.ts"; |
| 17 | 18 | import { currentWeapon, eyePos, offeredPickup, useProgress, type Player, type World } from "./world.ts"; |
| @@ -442,6 +443,10 @@ export function drawHud( | ||
| 442 | 443 | ); |
| 443 | 444 | } |
| 444 | 445 | |
| 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 | ||
| 445 | 450 | if (showScoreboard) drawScoreboard(ctx, world, width, height, scale); |
| 446 | 451 | } |
| 447 | 452 |
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 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { boxes as mapBoxes, sites, spawns } from "./map.ts"; | |
| 2 | import { DE_FORGE } from "./map.ts"; | |
| 3 | 3 | import type { Vec3 } from "./math.ts"; |
| 4 | 4 | import { rayBox, traceWorld, visible } from "./trace.ts"; |
| 5 | 5 | |
| @@ -72,16 +72,16 @@ test("visible is false through a wall and true down an open corridor, on the rea | ||
| 72 | 72 | // A and B are on opposite sides of the map with walls and doors between |
| 73 | 73 | // them: no straight line between the two sites' centres is ever clear. |
| 74 | 74 | 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, | |
| 78 | 78 | )).toBe(false); |
| 79 | 79 | // CT spawn and A site sit at opposite ends of the same open run (ct mid, |
| 80 | 80 | // 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; | |
| 82 | 82 | expect(visible( |
| 83 | 83 | [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, | |
| 86 | 86 | )).toBe(true); |
| 87 | 87 | }); |
src/client/world.test.ts +76 −2
| @@ -4,8 +4,9 @@ import { DE_FORGE, siteAt } from "./map.ts"; | ||
| 4 | 4 | import { SHORT, winReward } from "./match.ts"; |
| 5 | 5 | import { WEAPONS } from "./weapons.ts"; |
| 6 | 6 | 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, | |
| 9 | 10 | } from "./world.ts"; |
| 10 | 11 | |
| 11 | 12 | const TICK = 1 / 64; |
| @@ -808,3 +809,76 @@ test("a grenade off a wall says so: one bounce effect, where it hit", () => { | ||
| 808 | 809 | update(world, TICK, NO_INPUT); |
| 809 | 810 | expect(soundsNow(world, "bounce")).toBe(0); |
| 810 | 811 | }); |
| 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 = { | ||
| 107 | 107 | killerTeam: Team; |
| 108 | 108 | }; |
| 109 | 109 | |
| 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 | ||
| 110 | 120 | export type Input = { |
| 111 | 121 | forward: number; |
| 112 | 122 | side: number; |
| @@ -137,6 +147,13 @@ export type World = { | ||
| 137 | 147 | pickups: Pickup[]; |
| 138 | 148 | grenades: Grenade[]; // the ones in the air, or on the floor with a fuse left |
| 139 | 149 | 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[][]; | |
| 140 | 157 | effects: Effect[]; |
| 141 | 158 | feed: FeedEntry[]; |
| 142 | 159 | messages: { at: number; text: string }[]; |
| @@ -207,6 +224,8 @@ export function createWorld( | ||
| 207 | 224 | plan: roundPlan(seed, match.round), |
| 208 | 225 | bomb: { state: "carried", pos: [0, 0, 0], site: null, plantProgress: 0, defuseProgress: 0, defuser: null, beepAt: 0 }, |
| 209 | 226 | pickups: [], grenades: [], bodies: [], |
| 227 | damageLog: Array.from({ length: players.length + 1 }, () => | |
| 228 | Array.from({ length: players.length }, () => ({ damage: 0, hits: 0, killed: false }))), | |
| 210 | 229 | effects: [], feed: [], messages: [], |
| 211 | 230 | random, map, boxes: map.boxes, bombPlanted: false, pendingBuy: false, |
| 212 | 231 | difficulty, pendingDifficulty: null, |
| @@ -289,6 +308,11 @@ export function startRound(world: World): void { | ||
| 289 | 308 | // which is the rule the guns above already follow. |
| 290 | 309 | world.grenades.length = 0; |
| 291 | 310 | 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 | } | |
| 292 | 316 | world.feed = []; |
| 293 | 317 | for (const p of world.players) if (p.bot) botBuy(world, p); |
| 294 | 318 | } |
| @@ -423,6 +447,39 @@ function hurt(world: World, victim: Player, from: Vec3, amount: number): void { | ||
| 423 | 447 | world.effects.push({ kind: "hurt", at: world.time, victim: victim.id, from: [...from] as Vec3, amount }); |
| 424 | 448 | } |
| 425 | 449 | |
| 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 | ||
| 426 | 483 | /** How much view punch a point of damage is worth, and the most it can add up to. */ |
| 427 | 484 | const FLINCH_PER_DAMAGE = 0.06; |
| 428 | 485 | const FLINCH_MAX = 6; |
| @@ -494,6 +551,7 @@ export function shoot(world: World, p: Player): boolean { | ||
| 494 | 551 | if (victim.team === p.team && victim !== p) return true; // no friendly fire in this match |
| 495 | 552 | const result = applyHit(victim, w, hit.group, hit.distance); |
| 496 | 553 | p.damage += result.damage; |
| 554 | recordDamage(world, p.id, victim.id, result.damage, result.killed); | |
| 497 | 555 | world.effects.push({ kind: "blood", at: world.time, pos: hit.point }); |
| 498 | 556 | if (p === world.me) world.effects.push({ kind: "hitmark", at: world.time, headshot: result.headshot }); |
| 499 | 557 | if (result.damage > 0) hurt(world, victim, from, result.damage); |
| @@ -686,9 +744,12 @@ function detonate(world: World, g: Grenade): void { | ||
| 686 | 744 | const dealt = Math.min(health, victim.health); |
| 687 | 745 | victim.armor = Math.max(0, victim.armor - armor); |
| 688 | 746 | 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); | |
| 690 | 751 | hurt(world, victim, g.pos, dealt); |
| 691 | if (victim.health <= 0) { | |
| 752 | if (killed) { | |
| 692 | 753 | victim.health = 0; |
| 693 | 754 | onKill(world, thrower ?? victim, victim, "he", false); |
| 694 | 755 | } |
| @@ -893,11 +954,13 @@ export function update(world: World, dt: number, input: Input): void { | ||
| 893 | 954 | const dealt = Math.min(health, p.health); |
| 894 | 955 | p.armor = Math.max(0, p.armor - armor); |
| 895 | 956 | p.health -= health; |
| 957 | const killed = p.health <= 0; | |
| 896 | 958 | hurt(world, p, world.bomb.pos, dealt); |
| 959 | recordDamage(world, ENVIRONMENT, p.id, dealt, killed); | |
| 897 | 960 | // Nobody planted this for a kill: the bomb credits nobody, the same |
| 898 | 961 | // way killing yourself with your own grenade does not — `onKill` |
| 899 | 962 | // with the victim standing in for the killer. |
| 900 | if (p.health <= 0) { | |
| 963 | if (killed) { | |
| 901 | 964 | p.health = 0; |
| 902 | 965 | onKill(world, p, p, "bomb", false); |
| 903 | 966 | } |