Public: anyone can read this; the crew can change it.

forked from trevato/cs

You can tell where the shot came from: a directional damage indicator and a pain flash #271

Merged opened this

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

Merged

vesper/cs-engine:vesper/137-you-can-tell-where-the-shot-came-from-a → main 1 commit fork

  • Merged. vesper/137-you-can-tell-where-the-shot-came-from-a was merged into main as d696f28.

Review

Sign in to review this change.
5 files changed +411 −15
README.md +22 −2
@@ -406,6 +406,23 @@ while a match is running. Beside it is the same map in words — the size of the
406406 grid, and where the sites and the spawns are by the compass — so the canvas is
407407 hidden from the accessibility tree instead of being announced as nothing.
408408
409 **You can tell where the shot came from.** Take a hit and two things happen at
410 once. A red arc is stroked on a ring around the middle of the view, in the
411 direction of whoever hit you *relative to where you are facing now* — turn
412 ninety degrees and it walks ninety degrees round the ring, so it is a bearing
413 you can turn by rather than a picture of where you were standing. It fades over
414 1.25 seconds and every live hit draws its own, so two people shooting you from
415 two sides is two arcs. And the screen flashes red as hard as the hit was: the
416 damage over fifty, capped, which is 0.16 of red for an AK round through armour
417 and the full 0.3 for an AWP body shot, gone a quarter of a second later. It is
418 drawn *under* the crosshair on purpose — an M4 fires every 0.09 s, and a wash
419 that tinted your sights would be the game charging you for being shot at twice.
420 A flashbang is the deliberate other way round: `whiteout` goes over everything,
421 because taking the round off you is what a flash is for. The world records where
422 the damage came from — the shooter's feet, or the middle of the blast — and the
423 HUD reads it back; neither is a new number the simulation has to keep, so no
424 pinned replay moved.
425
409426 Killed, you watch the rest of the round out of a teammate's eyes: fire moves
410427 to the next player on your side and aim moves back one, the camera is *placed*
411428 on whoever you picked — asking for someone else is a cut, never a flight across
@@ -751,7 +768,8 @@ never checked itself.
751768 the end of every tick by `EFFECT_LIFE` without allocating),
752769 `bots`, `observe`, `action`, `weights`, `models`, `materials`,
753770 `binding`, `scene`, `hud` (the crosshair, the health, the ammo, the round
754 clock, the kill feed and the scoreboard — every one of them drawn through the
771 clock, the kill feed, the scoreboard and the red arc and flash that say you
772 were hit and from where — every one of them drawn through the
755773 engine's `overlay` since #18, so what is left in the file is this game's
756774 layout, which phases have a clock and its words, and the
757775 scale, the scrim, the fonts, the fade, the bar, the columns and the `m:ss` are
@@ -835,7 +853,9 @@ never checked itself.
835853 what is pinned is what a player reads: the health that goes red, the ammo, the
836854 clock that stops between rounds and reddens once the bomb is down, the
837855 crosshair that opens as far as the gun is inaccurate, the plant bar, the kill
838 feed fading out over eight seconds, the scoreboard's order, and the whole
856 feed fading out over eight seconds, the scoreboard's order, the damage arc
857 that sits behind you when you are shot in the back and walks ninety degrees
858 when you turn ninety, and the whole
839859 readout fitting a 360px phone and a 1920px desk alike. That recorder is
840860 `paint.test-helper.ts` now, because `minimap.test.ts` reads the radar the same
841861 way: on a map the colour *is* the meaning, so the brush is half the assertion
src/client/hud.test.ts +152 −7
@@ -20,7 +20,7 @@
2020 */
2121
2222 import { expect, test } from "bun:test";
23 import { drawHud, whiteout } from "./hud.ts";
23 import { drawHud, HURT_LIFE, painAlpha, whiteout } from "./hud.ts";
2424 import { recorder, type Call, type Recorder } from "./paint.test-helper.ts";
2525 import { DE_FORGE } from "./map.ts";
2626 import { SHORT } from "./match.ts";
@@ -33,13 +33,16 @@ import {
3333 import type { Ammo } from "./economy.ts";
3434 import { createEffects, pushEffect } from "../engine/index.ts";
3535
36 /** The one effect this frame is about, in a buffer of its own. */
37 function only(effect: Effect): ReturnType<typeof createEffects<Effect>> {
36 /** A buffer holding exactly these effects, in this order, and nothing else. */
37 function these(...list: Effect[]): ReturnType<typeof createEffects<Effect>> {
3838 const effects = createEffects<Effect>(EFFECT_CAPACITY);
39 pushEffect(effects, effect, effect.at);
39 for (const effect of list) pushEffect(effects, effect, effect.at);
4040 return effects;
4141 }
4242
43 /** The one effect this frame is about, in a buffer of its own. */
44 const only = (effect: Effect): ReturnType<typeof createEffects<Effect>> => these(effect);
45
4346 // --- the world this suite draws --------------------------------------------
4447
4548 /**
@@ -508,7 +511,11 @@ const busyWorld = (): World => scene((w) => {
508511 w.bomb.state = "planted";
509512 w.bomb.defuser = w.me;
510513 w.bomb.defuseProgress = 1;
511 w.effects = only({ kind: "hitmark", at: 99.95, headshot: true });
514 w.effects = these(
515 { kind: "hitmark", at: 99.95, headshot: true },
516 // Somebody is shooting back, which is a wash and an arc on the same frame.
517 { kind: "hurt", at: 99.9, from: [w.me.pos[0] - 400, w.me.pos[1] + 250, 0], damage: 27 },
518 );
512519 w.feed = [kill(99), kill(97, { killerTeam: "ct" }), kill(94, { headshot: true })];
513520 w.messages = [{ at: 99.5, text: "The bomb has been planted at B." }];
514521 for (const p of w.players) p.kills = p.id;
@@ -576,8 +583,10 @@ test("the same canvas turned sideways is drawn at the new size, not the one befo
576583 expect([...new Set(after.filter((c) => c.op === "fillText").map((c) => c.font))].sort()).toEqual([
577584 "11px system-ui, sans-serif", "13px system-ui, sans-serif", "21px system-ui, sans-serif",
578585 ]);
579 // The clock bar is 240 units wide at the scale the *new* width says.
580 const bar = after.find((c) => c.op === "fillRect") as Call;
586 // The clock bar is 240 units wide at the scale the *new* width says. It is
587 // the first filled rectangle that is not a full-screen wash — this world has
588 // somebody shooting back in it, and the pain flash goes on before anything.
589 const bar = after.find((c) => c.op === "fillRect" && c.args[2] !== 360) as Call;
581590 expect(bar.args[2]).toBe(Math.round(240 * 0.7));
582591 });
583592
@@ -623,6 +632,142 @@ test("a flashed player gets white over the world, and the crosshair back before
623632 expect(health).toBeGreaterThan(flashed.calls.indexOf(washes(flashed)[0] as Call));
624633 });
625634
635 // --- hurt: where it came from, and how hard ---------------------------------
636
637 /**
638 * Every damage arc this frame drew, as the angle each sits at *on the screen*:
639 * zero is straight ahead, ninety is off to your right and a hundred and eighty
640 * is behind you. The recorded call is a canvas angle — zero to the right of the
641 * middle, growing clockwise — so this is that, turned a quarter turn.
642 *
643 * Only the arcs: the scope's sight and the radar's dots go through `arc` too
644 * and are *filled*, so what is taken here is an arc in the hurt red that was
645 * stroked — the brush and the call that closed the path, both of which the
646 * recorder wrote down.
647 */
648 const arcs = (paint: Recorder): { at: number; alpha: number; radius: number }[] =>
649 paint.calls
650 .filter((c, i) =>
651 c.op === "arc" && c.stroke.startsWith("rgba(255,70,60") && paint.calls[i + 1]?.op === "stroke")
652 .map((c) => ({
653 at: turn(((Number(c.args[3]) + Number(c.args[4])) / 2) * (180 / Math.PI) + 90),
654 alpha: Number(/,([^,)]+)\)$/.exec(c.stroke)?.[1] ?? 0),
655 radius: Number(c.args[2]),
656 }));
657
658 /** An angle in degrees, folded into -180 .. 180. */
659 const turn = (degrees: number): number => ((((degrees + 180) % 360) + 360) % 360) - 180;
660
661 /** How far from straight ahead an arc is, whichever way round it went. */
662 const behindness = (at: number): number => Math.abs(at);
663
664 /** A world in which somebody at `from` has just shot you, while you face `yaw`. */
665 const shotAt = (from: [number, number], yaw: number, damage = 27, ago = 0.1): World =>
666 scene((w) => {
667 w.time = 10;
668 w.me.pos = [0, 0, 0];
669 w.me.yaw = yaw;
670 w.effects = only({ kind: "hurt", at: 10 - ago, from: [from[0], from[1], 0], damage });
671 });
672
673 test("the arc points at whoever hit you, and turning takes it with you", () => {
674 // Facing east. North is your left, so an attacker due north is drawn left.
675 const left = arcs(draw(shotAt([0, 500], 0)));
676 expect(left.length).toBe(1);
677 expect(left[0]?.at).toBeCloseTo(-90, 6);
678
679 // The same attacker, you turned ninety degrees to face them: dead ahead.
680 expect(arcs(draw(shotAt([0, 500], 90)))[0]?.at).toBeCloseTo(0, 6);
681 // And ninety the other way puts them behind you, which is the same ninety
682 // degrees of arc travel in the other direction.
683 expect(behindness(arcs(draw(shotAt([0, 500], -90)))[0]?.at ?? 0)).toBeCloseTo(180, 6);
684
685 // Shot in the back, facing east: an attacker due west is at the bottom.
686 expect(behindness(arcs(draw(shotAt([-500, 0], 0)))[0]?.at ?? 0)).toBeCloseTo(180, 6);
687 // Turn ninety degrees to your left and the arc walks exactly ninety degrees
688 // round the ring the other way: what was behind you is now off your left.
689 const turned = arcs(draw(shotAt([-500, 0], 90)))[0]?.at ?? 0;
690 expect(turned).toBeCloseTo(-90, 6);
691 expect(behindness(turn(180 - turned))).toBeCloseTo(90, 6);
692
693 // To your right, and nowhere near the crosshair: the ring is 28% of the
694 // shorter side out, which on this canvas is 201 pixels from the middle.
695 const right = arcs(draw(shotAt([0, -500], 0)));
696 expect(right[0]?.at).toBeCloseTo(90, 6);
697 expect(right[0]?.radius).toBeCloseTo(HEIGHT * 0.28, 6);
698 });
699
700 test("an arc fades over its life and is gone at the end of it, and two attackers are two arcs", () => {
701 const fresh = arcs(draw(shotAt([-500, 0], 0, 27, 0)));
702 expect(fresh[0]?.alpha).toBeCloseTo(0.85, 3);
703
704 // Half way through its life it is half there.
705 const half = arcs(draw(shotAt([-500, 0], 0, 27, HURT_LIFE / 2)));
706 expect(half[0]?.alpha).toBeCloseTo(0.425, 3);
707
708 // A hair inside its life is still drawn; the life itself is the end of it.
709 expect(arcs(draw(shotAt([-500, 0], 0, 27, HURT_LIFE - 0.01))).length).toBe(1);
710 expect(arcs(draw(shotAt([-500, 0], 0, 27, HURT_LIFE)))).toEqual([]);
711 expect(arcs(draw(shotAt([-500, 0], 0, 27, HURT_LIFE + 1)))).toEqual([]);
712
713 // Two people shooting you from two sides is two arcs, each at its own bearing.
714 const crossfire = arcs(draw(scene((w) => {
715 w.time = 10;
716 w.me.pos = [0, 0, 0];
717 w.me.yaw = 0;
718 w.effects = these(
719 { kind: "hurt", at: 9.9, from: [0, 500, 0], damage: 27 },
720 { kind: "hurt", at: 9.9, from: [0, -500, 0], damage: 27 },
721 );
722 })));
723 expect(crossfire.length).toBe(2);
724 expect(crossfire.map((a) => Math.round(a.at)).sort((a, b) => a - b)).toEqual([-90, 90]);
725
726 // Untouched, there is no arc at all: this costs a quiet frame nothing.
727 expect(arcs(draw(scene()))).toEqual([]);
728 });
729
730 test("the pain flash is as hard as the hit was, is gone in a quarter second, and is under the crosshair", () => {
731 // Nothing taken, nothing washed.
732 expect(washes(draw(scene()))).toEqual([]);
733
734 // An AK round to an armoured chest: a sixth of a red screen at its worst.
735 const round = draw(shotAt([-500, 0], 0, 27, 0));
736 expect(opacity(washes(round)[0]?.fill ?? "")).toBeCloseTo(painAlpha(27, 0), 3);
737 expect(painAlpha(27, 0)).toBeCloseTo(0.162, 3);
738
739 // A body-shot AWP is the whole of it, and half a body is already the cap: a
740 // hit that takes more than fifty does not wash the screen out any further.
741 expect(painAlpha(100, 0)).toBeCloseTo(0.3, 6);
742 expect(painAlpha(50, 0)).toBe(painAlpha(100, 0));
743 // It never comes near what a flashbang is allowed to do.
744 expect(painAlpha(100, 0)).toBeLessThan(whiteout(1.6) / 3);
745
746 // It fades, and a third of a second is the end of it.
747 expect(painAlpha(100, 0.125)).toBeCloseTo(0.15, 6);
748 expect(painAlpha(100, 0.25)).toBe(0);
749 expect(washes(draw(shotAt([-500, 0], 0, 100, 0.25)))).toEqual([]);
750 // The arc outlives it by a long way: the wash says *ouch* and the arc says
751 // where, and only one of those is worth having over your sights.
752 expect(arcs(draw(shotAt([-500, 0], 0, 100, 0.25))).length).toBe(1);
753
754 // And the crosshair goes on *after* it, so the shot you take back is aimed
755 // at exactly what it was aimed at before — the opposite way round from the
756 // white-out, which is meant to blind you.
757 const wash = washes(round)[0] as Call;
758 expect(round.calls.indexOf(wash)).toBeLessThan(round.calls.findIndex((c) => c.op === "stroke"));
759
760 // A spectator is not the one being shot at: a dead player watching a mate
761 // gets neither the wash nor the arc, whatever your corpse last took.
762 const { world, mate } = spectating((w) => {
763 w.time = 10;
764 w.effects = only({ kind: "hurt", at: 9.95, from: [-500, 0, 0], damage: 100 });
765 });
766 const watching = draw(world, { view: mate });
767 expect(washes(watching)).toEqual([]);
768 expect(arcs(watching)).toEqual([]);
769 });
770
626771 test("the pocket line names each grenade you are carrying, and both at once", () => {
627772 const none = draw(scene());
628773 expect(words(none).some((t) => t.includes("HE"))).toBe(false);
src/client/hud.ts +110 −3
@@ -1,7 +1,9 @@
11 /**
22 * The HUD, drawn on a 2D canvas over the scene: crosshair, health, ammo, the
3 * round clock, the kill feed, the scoreboard and the radar. It reads the world
4 * and draws; it never changes anything.
3 * round clock, the kill feed, the scoreboard, the radar, and — the one thing on
4 * it about damage coming the other way — the flash and the arcs that say you
5 * were hit and from where. It reads the world and draws; it never changes
6 * anything.
57 *
68 * The radar is `src/client/minimap.ts` — the level from above is a picture of
79 * its own and everything about drawing one is in there. What stays here is what
@@ -23,7 +25,10 @@
2325 * `src/client/palette.ts`, because the radar draws the same ten players.
2426 */
2527
26 import { clamp, effectAt, overlay, type Overlay, type Paint as Surface } from "../engine/index.ts";
28 import {
29 angleDelta, clamp, effectAt, overlay,
30 type Overlay, type Paint as Surface, type Vec3,
31 } from "../engine/index.ts";
2732 import { drawMinimap } from "./minimap.ts";
2833 import { CS_MOVE } from "./movement.ts";
2934 import { BLUE, CROSSHAIR, CT_COLOR, GREY, RED, T_COLOR, teamColor, WHITE } from "./palette.ts";
@@ -100,6 +105,68 @@ export const whiteout = (blind: number): number => PEAK * Math.min(1, blind / WH
100105 const PEAK = 0.92;
101106 const WHITEOUT_FADE = 0.9;
102107
108 /**
109 * Taking damage, on the screen, is two things with two clocks.
110 *
111 * **The arc** says where it came from: a stroke on a ring around the middle of
112 * the view, in the direction of whoever hit you *relative to where you are
113 * facing now*, so it swings as you turn and sits still if you do not. It lives
114 * `HURT_LIFE` seconds and fades linearly to nothing over them — long enough to
115 * read while you are still turning, short enough that it is never telling you
116 * about a fight that is over. Every live hit draws its own, so two people
117 * shooting you from two sides is two arcs and not an average of them.
118 *
119 * **The flash** says how hard: a red wash whose opacity is the damage over
120 * `PAIN_FULL` — half a body — capped, and gone `PAIN_LIFE` seconds later.
121 *
122 * The numbers are meant to be argued with, so here they are out loud. An AK
123 * round to an armoured chest at 500 units is 27 damage, which peaks at
124 * `0.3 · 27/50 ≈ 0.16` of red; an AWP body shot is 111 and peaks at the full
125 * 0.3. A quarter of a second at a third of a colour is a hit you cannot miss
126 * and nothing you have to wait out: an M4 fires every 0.09 s and an AWP every
127 * 1.46, so the flash is over before your next shot either way, and the worst of
128 * it is a third of what `whiteout` above is allowed to do — a flashbang is
129 * meant to take the round off you and a bullet is not.
130 *
131 * And it draws *under* the crosshair, which is the one rule a full-screen wash
132 * on an aiming game has to keep: the crosshair goes on after it, at its own
133 * colour, so the shot you take back is aimed at exactly what it was aimed at
134 * before. `whiteout` is the deliberate other way round.
135 */
136 export const HURT_LIFE = 1.25;
137 const PAIN_LIFE = 0.25;
138 const PAIN_FULL = 50;
139 const PAIN_PEAK = 0.3;
140
141 /** How much red is over the world from one hit of `damage`, `age` seconds on. */
142 export const painAlpha = (damage: number, age: number): number =>
143 age < 0 || age >= PAIN_LIFE ? 0 : PAIN_PEAK * Math.min(1, damage / PAIN_FULL) * (1 - age / PAIN_LIFE);
144
145 /** The red it washes in, and the red the arcs are stroked in. */
146 const PAIN = "190,30,26";
147 const HURT = "255,70,60";
148
149 /** How far out the arcs sit as a fraction of the shorter side, and how wide each is. */
150 const HURT_RADIUS = 0.28;
151 const HURT_HALF = 0.34; // radians: an arc about 39° across
152 const HURT_ALPHA = 0.85;
153
154 /**
155 * Where a hit at `from` sits on that ring, for a body at `at` facing `yaw`, as
156 * the angle `ctx.arc` wants: zero to the right of the middle of the screen,
157 * growing clockwise. Straight ahead is the top of the ring and behind you is
158 * the bottom, so turning ninety degrees walks the arc ninety degrees round it.
159 *
160 * Height is dropped on purpose: this is a bearing you turn by, and a shot from
161 * a floor above is still a shot from over there.
162 */
163 export function hurtAngle(yaw: number, from: Vec3, at: Vec3): number {
164 const bearing = (Math.atan2(from[1] - at[1], from[0] - at[0]) * 180) / Math.PI;
165 // `angleDelta` counts counter-clockwise, which on the ground is to your left;
166 // the screen's angles run the other way, and that is the minus sign.
167 return -Math.PI / 2 - (angleDelta(yaw, bearing) * Math.PI) / 180;
168 }
169
103170 /**
104171 * How much grey is over the world when the eye is inside a cloud of smoke.
105172 *
@@ -138,6 +205,27 @@ export function drawHud(
138205 ctx.textBaseline = "alphabetic";
139206 const weapon = currentWeapon(view);
140207 const pad = ui.pad;
208 // Damage is something a body takes, so the indicator is drawn only from the
209 // eyes of the body that took it: yours, while you are standing. A spectator
210 // is not being shot at, and a corpse is not turning to face anything.
211 const hurting = view === world.me && view.alive;
212
213 // --- the pain flash, first of everything and under the crosshair. The
214 // strongest hit still on the clock decides it rather than the sum of them:
215 // two rifle rounds in one tick is one hit's worth of red and not two, which
216 // is what keeps a burst from washing the screen out.
217 if (hurting) {
218 let pain = 0;
219 for (let i = 0; i < world.effects.count; i++) {
220 const effect = effectAt(world.effects, i);
221 if (effect === undefined || effect.kind !== "hurt") continue;
222 pain = Math.max(pain, painAlpha(effect.damage, world.time - effect.at));
223 }
224 if (pain > 0) {
225 ctx.fillStyle = `rgba(${PAIN},${pain.toFixed(3)})`;
226 ctx.fillRect(0, 0, width, height);
227 }
228 }
141229
142230 // --- crosshair, opening up exactly as much as the gun is inaccurate
143231 if (view.alive) {
@@ -197,6 +285,25 @@ export function drawHud(
197285 ctx.fillRect(0, 0, width, height);
198286 }
199287
288 // --- and where it came from: one arc per hit, over the washes for the same
289 // reason your health is drawn over them — being blinded or standing in smoke
290 // is exactly when knowing which way to turn is worth most.
291 if (hurting) {
292 const radius = Math.min(width, height) * HURT_RADIUS;
293 ctx.lineWidth = Math.max(3, Math.round(5 * ui.scale));
294 for (let i = 0; i < world.effects.count; i++) {
295 const effect = effectAt(world.effects, i);
296 if (effect === undefined || effect.kind !== "hurt") continue;
297 const age = world.time - effect.at;
298 if (age < 0 || age >= HURT_LIFE) continue;
299 const mid = hurtAngle(view.yaw, effect.from, view.pos);
300 ctx.strokeStyle = `rgba(${HURT},${(HURT_ALPHA * (1 - age / HURT_LIFE)).toFixed(3)})`;
301 ctx.beginPath();
302 ctx.arc(width / 2, height / 2, radius, mid - HURT_HALF, mid + HURT_HALF);
303 ctx.stroke();
304 }
305 }
306
200307 const scale = ui.scale;
201308 const big = ui.font(30);
202309 const mid = ui.font(19);
src/client/world.test.ts +97 −3
@@ -7,12 +7,12 @@ import { SHORT } from "./match.ts";
77 import { WEAPONS } from "./weapons.ts";
88 import { buy } from "./economy.ts";
99 import {
10 applyInput, canSee, createWorld, currentWeapon, deployTicks, EFFECT_CAPACITY, eyePos, insideSmoke,
11 NO_INPUT, shoot, startRound, throwOne, update,
10 aimAt, applyInput, bodyCentre, canSee, createWorld, currentWeapon, deployTicks, EFFECT_CAPACITY,
11 eyePos, insideSmoke, NO_INPUT, shoot, startRound, throwOne, update,
1212 type Input, type Player, type Sound, type World,
1313 } from "./world.ts";
1414 import { cloudRadius, type Cloud } from "./grenade.ts";
15 import { SMOKE } from "./weapons.ts";
15 import { HE, SMOKE } from "./weapons.ts";
1616 import { CS_HULL } from "./movement.ts";
1717 import {
1818 effectAt, eyeHeight, pushEffect, type Burst, type SoundDevice, type Tone, type Vec3,
@@ -561,3 +561,97 @@ test("a smoke grenade waits to come to rest, then stands its stated life", () =>
561561 for (let i = 0; i < 64 * (SMOKE.smoke.life + 1); i++) update(world, 1 / 64, NO_INPUT);
562562 expect(world.clouds.length).toBe(0);
563563 });
564
565 // --- taking damage ---------------------------------------------------------
566
567 /** Every hit the person has taken that is still in the ring, oldest first. */
568 function taken(world: World): { from: Vec3; damage: number; at: number }[] {
569 const hits: { from: Vec3; damage: number; at: number }[] = [];
570 for (let i = 0; i < world.effects.count; i++) {
571 const e = effectAt(world.effects, i);
572 if (e === undefined || e.kind !== "hurt") continue;
573 hits.push({ from: e.from, damage: e.damage, at: e.at });
574 }
575 return hits;
576 }
577
578 /** Two bodies on open ground, two hundred units apart, the enemy aiming at you. */
579 function facingOff(seed: number): { world: World; enemy: Player } {
580 const world = alone(seed);
581 const enemy = world.players.find((p) => p.team === "t") as Player;
582 const at = runway(4);
583 world.me.pos = [at[0], at[1], at[2]];
584 enemy.pos = [at[0] + 200, at[1], at[2]];
585 const aim = aimAt(enemy, bodyCentre(world.me));
586 enemy.yaw = aim.yaw;
587 enemy.pitch = aim.pitch;
588 return { world, enemy };
589 }
590
591 test("a shot you take is remembered with where the shooter was standing", () => {
592 const { world, enemy } = facingOff(211);
593 expect(taken(world)).toEqual([]);
594 expect(shoot(world, enemy)).toBe(true);
595
596 const hits = taken(world);
597 expect(hits.length).toBe(1);
598 const hit = hits[0] as { from: Vec3; damage: number; at: number };
599 expect(hit.at).toBe(world.time);
600 expect(hit.damage).toBe(100 - world.me.health);
601 expect(hit.damage).toBeGreaterThan(0);
602 // Where it came from is where that body was standing on the tick it fired.
603 expect(hit.from[0]).toBeCloseTo(enemy.pos[0], 6);
604 expect(hit.from[1]).toBeCloseTo(enemy.pos[1], 6);
605
606 // And it is a copy of that position and not the array the body walks around
607 // with: a bearing to where the shot came from is not a bearing to wherever
608 // the shooter has got to since.
609 enemy.pos = [enemy.pos[0] + 5000, enemy.pos[1] + 5000, enemy.pos[2]];
610 expect(taken(world)[0]?.from[0]).toBeCloseTo(hit.from[0], 6);
611 expect(taken(world)[0]?.from[1]).toBeCloseTo(hit.from[1], 6);
612 });
613
614 test("damage a bot takes is nobody's screen: only the person's hits are remembered", () => {
615 const { world, enemy } = facingOff(212);
616 const aim = aimAt(world.me, bodyCentre(enemy));
617 world.me.yaw = aim.yaw;
618 world.me.pitch = aim.pitch;
619 expect(shoot(world, world.me)).toBe(true);
620 expect(enemy.health).toBeLessThan(100);
621 // A hit marker for the hit you landed, and nothing at all about taking one.
622 expect(taken(world)).toEqual([]);
623 let marks = 0;
624 for (let i = 0; i < world.effects.count; i++)
625 if (effectAt(world.effects, i)?.kind === "hitmark") marks += 1;
626 expect(marks).toBe(1);
627 });
628
629 test("a blast you are caught in came from the blast, and not from whoever threw it", () => {
630 const { world, enemy } = facingOff(213);
631 enemy.pocket.he = 1;
632 // Lobbed at your feet from two hundred units away: `throwOne` tilts it up
633 // the way CS tilts a throw, so aiming at the ground under you lands it there.
634 const aim = aimAt(enemy, world.me.pos);
635 enemy.yaw = aim.yaw;
636 enemy.pitch = aim.pitch;
637 expect(throwOne(world, enemy, "he")).toBe(true);
638 // The fuse is a second and a half; this is that and a moment either side.
639 for (let i = 0; i < 64 * 3 && world.me.health === 100; i++) update(world, TICK, NO_INPUT);
640
641 expect(world.me.health).toBeLessThan(100);
642 const hits = taken(world);
643 expect(hits.length).toBe(1);
644 const hit = hits[0] as { from: Vec3; damage: number; at: number };
645 expect(hit.damage).toBe(100 - world.me.health);
646 // It came from where the grenade went off — inside its own blast of you —
647 // and not from the thrower, who is two hundred units away and, in a round
648 // anybody is playing, running somewhere else entirely by now.
649 const blast = world.effects.events.find((e) => e?.kind === "blast");
650 expect(blast?.kind).toBe("blast");
651 if (blast?.kind === "blast") {
652 expect(hit.from[0]).toBeCloseTo(blast.pos[0], 6);
653 expect(hit.from[1]).toBeCloseTo(blast.pos[1], 6);
654 }
655 expect(Math.hypot(hit.from[0] - world.me.pos[0], hit.from[1] - world.me.pos[1]))
656 .toBeLessThan(HE.blast.radius);
657 });
src/client/world.ts +30
@@ -114,6 +114,14 @@ export type Effect =
114114 | { kind: "blood"; at: number; pos: Vec3 }
115115 | { kind: "shot"; at: number; pos: Vec3; weapon: WeaponId; mine: boolean }
116116 | { kind: "hitmark"; at: number; headshot: boolean }
117 /**
118 * Damage *you* took, and where it came from: the shooter's feet, or the
119 * middle of the blast, at the moment it landed. The mirror image of a hit
120 * marker — that one is recorded when you deal damage and this one when you
121 * take it — and like it, it is only ever recorded for `world.me`, because it
122 * is the one body whose screen this is.
123 */
124 | { kind: "hurt"; at: number; from: Vec3; damage: number }
117125 /** A grenade going off, of whichever kind: the frame draws each its own way. */
118126 | { kind: "blast"; at: number; pos: Vec3; weapon: ThrownId }
119127 /** Something heard, where it happened, and the id of whoever made it (-1: nobody). */
@@ -308,6 +316,21 @@ const heard = (world: World, pos: Vec3, name: Sound, by: number): void => {
308316 record(world, { kind: "sound", at: world.time, pos: [pos[0], pos[1], pos[2]], name, by });
309317 };
310318
319 /**
320 * Damage the person just took, from `from`. Nothing is recorded for a bot: a
321 * bot has no screen to put an arrow on, and an effect per bullet in a ten-body
322 * firefight is a ring full of events nobody will ever read.
323 *
324 * The position is copied for the reason `heard` copies one — the caller's is a
325 * body's own array and that body is somewhere else a tick later — and the
326 * direction a frame draws has to be where the shot came from and not where the
327 * shooter has run to since.
328 */
329 const hurt = (world: World, victim: Player, from: Vec3, damage: number): void => {
330 if (victim !== world.me || damage <= 0) return;
331 record(world, { kind: "hurt", at: world.time, from: [from[0], from[1], from[2]], damage });
332 };
333
311334 /**
312335 * Source units between one foot landing and the next. A stride is about 1.5 m,
313336 * which is 85 Source units at 1 unit ≈ 1.9 cm, so a body at the 250 u/s run cap
@@ -540,6 +563,10 @@ export function shoot(world: World, p: Player): boolean {
540563 const result = applyHit(victim, w, hit.group, hit.distance);
541564 p.damage += result.damage;
542565 record(world, { kind: "blood", at: world.time, pos: hit.point });
566 // Where it came from is where the body that fired is standing, and not the
567 // muzzle or the point on your chest the bullet met: an arrow on the screen
568 // is a bearing to a person, so it is read off the person.
569 hurt(world, victim, p.pos, result.damage);
543570 if (p === world.me) record(world, { kind: "hitmark", at: world.time, headshot: result.headshot });
544571 if (result.killed) onKill(world, p, victim, w.id, result.headshot);
545572 }
@@ -609,6 +636,9 @@ function explode(world: World, g: Grenade, centre: Vec3): void {
609636 const victim = caught.actor as Player;
610637 const result = applyDamage(victim, caught.health, caught.armor);
611638 record(world, { kind: "blood", at: world.time, pos: [victim.pos[0], victim.pos[1], victim.pos[2] + 40] });
639 // A blast came from the blast and not from whoever threw it, who is by now
640 // somewhere else entirely and is not the thing you want to turn towards.
641 hurt(world, victim, centre, result.damage);
612642 if (thrower === undefined) continue;
613643 thrower.damage += result.damage;
614644 if (thrower === world.me) record(world, { kind: "hitmark", at: world.time, headshot: false });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs