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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/137-you-can-tell-where-the-shot-came-from-a → main 1 commit fork
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 | ||
| 406 | 406 | grid, and where the sites and the spawns are by the compass — so the canvas is |
| 407 | 407 | hidden from the accessibility tree instead of being announced as nothing. |
| 408 | 408 | |
| 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 | ||
| 409 | 426 | Killed, you watch the rest of the round out of a teammate's eyes: fire moves |
| 410 | 427 | to the next player on your side and aim moves back one, the camera is *placed* |
| 411 | 428 | on whoever you picked — asking for someone else is a cut, never a flight across |
| @@ -751,7 +768,8 @@ never checked itself. | ||
| 751 | 768 | the end of every tick by `EFFECT_LIFE` without allocating), |
| 752 | 769 | `bots`, `observe`, `action`, `weights`, `models`, `materials`, |
| 753 | 770 | `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 | |
| 755 | 773 | engine's `overlay` since #18, so what is left in the file is this game's |
| 756 | 774 | layout, which phases have a clock and its words, and the |
| 757 | 775 | scale, the scrim, the fonts, the fade, the bar, the columns and the `m:ss` are |
| @@ -835,7 +853,9 @@ never checked itself. | ||
| 835 | 853 | what is pinned is what a player reads: the health that goes red, the ammo, the |
| 836 | 854 | clock that stops between rounds and reddens once the bomb is down, the |
| 837 | 855 | 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 | |
| 839 | 859 | readout fitting a 360px phone and a 1920px desk alike. That recorder is |
| 840 | 860 | `paint.test-helper.ts` now, because `minimap.test.ts` reads the radar the same |
| 841 | 861 | 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 @@ | ||
| 20 | 20 | */ |
| 21 | 21 | |
| 22 | 22 | import { expect, test } from "bun:test"; |
| 23 | import { drawHud, whiteout } from "./hud.ts"; | |
| 23 | import { drawHud, HURT_LIFE, painAlpha, whiteout } from "./hud.ts"; | |
| 24 | 24 | import { recorder, type Call, type Recorder } from "./paint.test-helper.ts"; |
| 25 | 25 | import { DE_FORGE } from "./map.ts"; |
| 26 | 26 | import { SHORT } from "./match.ts"; |
| @@ -33,13 +33,16 @@ import { | ||
| 33 | 33 | import type { Ammo } from "./economy.ts"; |
| 34 | 34 | import { createEffects, pushEffect } from "../engine/index.ts"; |
| 35 | 35 | |
| 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>> { | |
| 38 | 38 | const effects = createEffects<Effect>(EFFECT_CAPACITY); |
| 39 | pushEffect(effects, effect, effect.at); | |
| 39 | for (const effect of list) pushEffect(effects, effect, effect.at); | |
| 40 | 40 | return effects; |
| 41 | 41 | } |
| 42 | 42 | |
| 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 | ||
| 43 | 46 | // --- the world this suite draws -------------------------------------------- |
| 44 | 47 | |
| 45 | 48 | /** |
| @@ -508,7 +511,11 @@ const busyWorld = (): World => scene((w) => { | ||
| 508 | 511 | w.bomb.state = "planted"; |
| 509 | 512 | w.bomb.defuser = w.me; |
| 510 | 513 | 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 | ); | |
| 512 | 519 | w.feed = [kill(99), kill(97, { killerTeam: "ct" }), kill(94, { headshot: true })]; |
| 513 | 520 | w.messages = [{ at: 99.5, text: "The bomb has been planted at B." }]; |
| 514 | 521 | 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 | ||
| 576 | 583 | expect([...new Set(after.filter((c) => c.op === "fillText").map((c) => c.font))].sort()).toEqual([ |
| 577 | 584 | "11px system-ui, sans-serif", "13px system-ui, sans-serif", "21px system-ui, sans-serif", |
| 578 | 585 | ]); |
| 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; | |
| 581 | 590 | expect(bar.args[2]).toBe(Math.round(240 * 0.7)); |
| 582 | 591 | }); |
| 583 | 592 | |
| @@ -623,6 +632,142 @@ test("a flashed player gets white over the world, and the crosshair back before | ||
| 623 | 632 | expect(health).toBeGreaterThan(flashed.calls.indexOf(washes(flashed)[0] as Call)); |
| 624 | 633 | }); |
| 625 | 634 | |
| 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 | ||
| 626 | 771 | test("the pocket line names each grenade you are carrying, and both at once", () => { |
| 627 | 772 | const none = draw(scene()); |
| 628 | 773 | expect(words(none).some((t) => t.includes("HE"))).toBe(false); |
src/client/hud.ts +110 −3
| @@ -1,7 +1,9 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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. | |
| 5 | 7 | * |
| 6 | 8 | * The radar is `src/client/minimap.ts` — the level from above is a picture of |
| 7 | 9 | * its own and everything about drawing one is in there. What stays here is what |
| @@ -23,7 +25,10 @@ | ||
| 23 | 25 | * `src/client/palette.ts`, because the radar draws the same ten players. |
| 24 | 26 | */ |
| 25 | 27 | |
| 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"; | |
| 27 | 32 | import { drawMinimap } from "./minimap.ts"; |
| 28 | 33 | import { CS_MOVE } from "./movement.ts"; |
| 29 | 34 | 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 | ||
| 100 | 105 | const PEAK = 0.92; |
| 101 | 106 | const WHITEOUT_FADE = 0.9; |
| 102 | 107 | |
| 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 | ||
| 103 | 170 | /** |
| 104 | 171 | * How much grey is over the world when the eye is inside a cloud of smoke. |
| 105 | 172 | * |
| @@ -138,6 +205,27 @@ export function drawHud( | ||
| 138 | 205 | ctx.textBaseline = "alphabetic"; |
| 139 | 206 | const weapon = currentWeapon(view); |
| 140 | 207 | 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 | } | |
| 141 | 229 | |
| 142 | 230 | // --- crosshair, opening up exactly as much as the gun is inaccurate |
| 143 | 231 | if (view.alive) { |
| @@ -197,6 +285,25 @@ export function drawHud( | ||
| 197 | 285 | ctx.fillRect(0, 0, width, height); |
| 198 | 286 | } |
| 199 | 287 | |
| 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 | ||
| 200 | 307 | const scale = ui.scale; |
| 201 | 308 | const big = ui.font(30); |
| 202 | 309 | const mid = ui.font(19); |
src/client/world.test.ts +97 −3
| @@ -7,12 +7,12 @@ import { SHORT } from "./match.ts"; | ||
| 7 | 7 | import { WEAPONS } from "./weapons.ts"; |
| 8 | 8 | import { buy } from "./economy.ts"; |
| 9 | 9 | 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, | |
| 12 | 12 | type Input, type Player, type Sound, type World, |
| 13 | 13 | } from "./world.ts"; |
| 14 | 14 | import { cloudRadius, type Cloud } from "./grenade.ts"; |
| 15 | import { SMOKE } from "./weapons.ts"; | |
| 15 | import { HE, SMOKE } from "./weapons.ts"; | |
| 16 | 16 | import { CS_HULL } from "./movement.ts"; |
| 17 | 17 | import { |
| 18 | 18 | 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", () => | ||
| 561 | 561 | for (let i = 0; i < 64 * (SMOKE.smoke.life + 1); i++) update(world, 1 / 64, NO_INPUT); |
| 562 | 562 | expect(world.clouds.length).toBe(0); |
| 563 | 563 | }); |
| 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 = | ||
| 114 | 114 | | { kind: "blood"; at: number; pos: Vec3 } |
| 115 | 115 | | { kind: "shot"; at: number; pos: Vec3; weapon: WeaponId; mine: boolean } |
| 116 | 116 | | { 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 } | |
| 117 | 125 | /** A grenade going off, of whichever kind: the frame draws each its own way. */ |
| 118 | 126 | | { kind: "blast"; at: number; pos: Vec3; weapon: ThrownId } |
| 119 | 127 | /** 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 => { | ||
| 308 | 316 | record(world, { kind: "sound", at: world.time, pos: [pos[0], pos[1], pos[2]], name, by }); |
| 309 | 317 | }; |
| 310 | 318 | |
| 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 | ||
| 311 | 334 | /** |
| 312 | 335 | * Source units between one foot landing and the next. A stride is about 1.5 m, |
| 313 | 336 | * 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 { | ||
| 540 | 563 | const result = applyHit(victim, w, hit.group, hit.distance); |
| 541 | 564 | p.damage += result.damage; |
| 542 | 565 | 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); | |
| 543 | 570 | if (p === world.me) record(world, { kind: "hitmark", at: world.time, headshot: result.headshot }); |
| 544 | 571 | if (result.killed) onKill(world, p, victim, w.id, result.headshot); |
| 545 | 572 | } |
| @@ -609,6 +636,9 @@ function explode(world: World, g: Grenade, centre: Vec3): void { | ||
| 609 | 636 | const victim = caught.actor as Player; |
| 610 | 637 | const result = applyDamage(victim, caught.health, caught.armor); |
| 611 | 638 | 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); | |
| 612 | 642 | if (thrower === undefined) continue; |
| 613 | 643 | thrower.damage += result.damage; |
| 614 | 644 | if (thrower === world.me) record(world, { kind: "hitmark", at: world.time, headshot: false }); |
new above
offline