Split The code is public; the app answers only to the crew.
A round your own teammate stops vanishes: no blood, no impact, no word that a bot was in your line #236
Check · Review · Land · Live done — Landed and live in production.
vesper/cs:vesper/203-a-round-your-own-teammate-stops-vanishes → main 1 commit fork
8 files
changed +165
−8
README.md +4 −1
| @@ -100,7 +100,10 @@ wants one, but the running server never reads it. | ||
| 100 | 100 | someone — four ticks around the crosshair, red for a headshot, and for the hit |
| 101 | 101 | that **finished** them a heavier mark, held twice as long, closed into an X |
| 102 | 102 | through the middle, so the fifth bullet does not look like the four before it; |
| 103 | your own grenade confirms the same way, once for each enemy the blast caught — | |
| 103 | your own grenade confirms the same way, once for each enemy the blast caught; | |
| 104 | a round a **teammate** stops sparks on their body, marks a plus in your side's | |
| 105 | colour with no beep, and says "Blocked by <name>" once a burst — still no | |
| 106 | friendly fire — | |
| 104 | 107 | and when you are hit a view punch away from the shot, a wedge around |
| 105 | 108 | the crosshair pointing at whoever fired, a vignette that reddens as your health |
| 106 | 109 | falls and a pain sound. A hit also **tags**, for everyone the same way: |
src/client/earshot.test.ts +5
| @@ -85,3 +85,8 @@ test("the hit cue carries whether it was the one that killed, ready for the kill | ||
| 85 | 85 | const fatal: Effect = { kind: "hitmark", at: 1, headshot: false, killed: true }; |
| 86 | 86 | expect(cuesHeard([fatal], 0, HERE, 1)).toEqual([{ kind: "hit", headshot: false, killed: true }]); |
| 87 | 87 | }); |
| 88 | ||
| 89 | test("a round a teammate stopped says so on the cue, so it is never played as a hit", () => { | |
| 90 | const blocked: Effect = { kind: "hitmark", at: 1, headshot: false, killed: false, friendly: true }; | |
| 91 | expect(cuesHeard([blocked], 0, HERE, 1)).toEqual([{ kind: "hit", headshot: false, killed: false, friendly: true }]); | |
| 92 | }); |
src/client/earshot.ts +3 −2
| @@ -38,7 +38,8 @@ export type Cue = | ||
| 38 | 38 | // A hit of your own: where it landed, and whether it was the one that |
| 39 | 39 | // finished them. `audio.ts` plays `hit(headshot)` today and ignores |
| 40 | 40 | // `killed` — the kill tone is #110's, and this is the flag waiting for it. |
| 41 | | { kind: "hit"; headshot: boolean; killed: boolean } | |
| 41 | // `friendly` — a teammate stopped the round; the hit tone is not for it. | |
| 42 | | { kind: "hit"; headshot: boolean; killed: boolean; friendly?: boolean } | |
| 42 | 43 | | { kind: "hurt"; from: Vec3; amount: number } |
| 43 | 44 | | { kind: "step"; pos: Vec3 } |
| 44 | 45 | | { kind: "world"; name: WorldSoundName; pos: Vec3; pitch?: number }; |
| @@ -55,7 +56,7 @@ export function cuesHeard(effects: Effect[], since: number, listener: Vec3, loca | ||
| 55 | 56 | if (effect.at <= since) continue; |
| 56 | 57 | if (effect.kind === "shot") cues.push({ kind: "shot", pos: effect.pos, weapon: effect.weapon }); |
| 57 | 58 | else if (effect.kind === "hitmark") |
| 58 | cues.push({ kind: "hit", headshot: effect.headshot, killed: effect.killed }); | |
| 59 | cues.push({ kind: "hit", headshot: effect.headshot, killed: effect.killed, friendly: effect.friendly }); | |
| 59 | 60 | else if (effect.kind === "hurt") { |
| 60 | 61 | if (effect.victim === localId) cues.push({ kind: "hurt", from: effect.from, amount: effect.amount }); |
| 61 | 62 | } else if (effect.kind === "sound") { |
src/client/hud.test.ts +20
| @@ -730,3 +730,23 @@ test("reduced motion leaves the hit marker, the kill feed and the crosshair exac | ||
| 730 | 730 | // be a setting that changes the game. |
| 731 | 731 | expect(drawAt(world, "reduced")).toEqual(drawAt(world, "full")); |
| 732 | 732 | }); |
| 733 | ||
| 734 | test("a round a teammate stopped is marked in your side's colour, never as a hit", () => { | |
| 735 | const centred = (friendly: boolean): Call[] => { | |
| 736 | const world = createWorld(SHORT, "me", "ct", 5, 83); | |
| 737 | world.effects.push({ kind: "hitmark", at: world.time, headshot: false, killed: false, friendly }); | |
| 738 | const frame = recorder(); | |
| 739 | draw(frame, world); | |
| 740 | return frame.calls.filter((c) => | |
| 741 | c.name === "moveTo" && Math.abs(c.args[0] - W / 2) < 50 && Math.abs(c.args[1] - H / 2) < 50); | |
| 742 | }; | |
| 743 | const blocked = centred(true); | |
| 744 | const hit = centred(false); | |
| 745 | // The enemy mark's white is nowhere in it, and the side's blue is. | |
| 746 | expect(blocked.some((c) => c.stroke === "rgba(255,255,255,0.9)")).toBe(false); | |
| 747 | expect(hit.some((c) => c.stroke === "rgba(255,255,255,0.9)")).toBe(true); | |
| 748 | const own = blocked.filter((c) => c.stroke === "#6fa8e8"); | |
| 749 | expect(own.length).toBe(4); | |
| 750 | // Ticks on the axes — a plus — where a hit's sit on the diagonals. | |
| 751 | expect(own.every((c) => c.args[0] === W / 2 || c.args[1] === H / 2)).toBe(true); | |
| 752 | }); |
src/client/hud.ts +14
| @@ -400,6 +400,20 @@ export function drawHud( | ||
| 400 | 400 | if (effect.kind !== "hitmark") continue; |
| 401 | 401 | const age = world.time - effect.at; |
| 402 | 402 | if (age > (effect.killed ? KILL_MARK_LIFE : HIT_MARK_LIFE)) continue; |
| 403 | // A round a teammate stopped is not a hit, and does not look like one: | |
| 404 | // four ticks on the axes, a plus rather than an X, in your side's colour. | |
| 405 | if (effect.friendly === true) { | |
| 406 | ctx.strokeStyle = world.me.team === "t" ? T_COLOR : CT_COLOR; | |
| 407 | ctx.lineWidth = 2; | |
| 408 | const r = 9 + age * 20; | |
| 409 | ctx.beginPath(); | |
| 410 | for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { | |
| 411 | ctx.moveTo(width / 2 + dx * r * 0.55, height / 2 + dy * r * 0.55); | |
| 412 | ctx.lineTo(width / 2 + dx * r, height / 2 + dy * r); | |
| 413 | } | |
| 414 | ctx.stroke(); | |
| 415 | continue; | |
| 416 | } | |
| 403 | 417 | ctx.strokeStyle = effect.headshot ? "rgba(255,90,90,0.95)" : "rgba(255,255,255,0.9)"; |
| 404 | 418 | ctx.lineWidth = effect.killed ? 3 : 2; |
| 405 | 419 | const r = 9 + age * 20; |
src/client/main.ts +3 −1
| @@ -839,7 +839,9 @@ function boot(): void { | ||
| 839 | 839 | const at = eyePos(ears); |
| 840 | 840 | for (const cue of cuesHeard(current.effects, lastSound, at, current.me.id)) { |
| 841 | 841 | if (cue.kind === "shot") audio.shot(cue.weapon, cue.pos, at, ears.yaw); |
| 842 | else if (cue.kind === "hit") audio.hit(cue.headshot); | |
| 842 | else if (cue.kind === "hit") { | |
| 843 | if (!cue.friendly) audio.hit(cue.headshot); // a teammate's body is not a hit | |
| 844 | } | |
| 843 | 845 | else if (cue.kind === "hurt") { |
| 844 | 846 | flinch(current.me, cue.from, cue.amount); |
| 845 | 847 | audio.pain(cue.amount); |
src/client/world.test.ts +85 −1
| @@ -9,7 +9,7 @@ import { fallDamage, GAIT_CYCLE, STEP_STRIDE } from "./physics.ts"; | ||
| 9 | 9 | import { WEAPONS, type WeaponId } from "./weapons.ts"; |
| 10 | 10 | import { snapshot, type Saved } from "./saved.ts"; |
| 11 | 11 | import { |
| 12 | aimAt, applyInput, bodyCentre, BOMB_BLAST_RADIUS, buy, callout, createWorld, currentWeapon, damageDealt, | |
| 12 | aimAt, applyInput, BLOCKED_QUIET, bodyCentre, BOMB_BLAST_RADIUS, buy, callout, createWorld, currentWeapon, damageDealt, | |
| 13 | 13 | damageTaken, deploying, ENVIRONMENT, eyePos, finishRound, flinch, grenadeCount, grenadeSlot, MAX_GRENADES, newBomb, |
| 14 | 14 | nextGrenade, NO_INPUT, offeredPickup, radioCall, restoreWorld, shoot, startRound, tag, TAG_FLOOR, TAG_PER_DAMAGE, |
| 15 | 15 | TAG_RECOVERY, update, |
| @@ -1868,3 +1868,87 @@ test("a rifle in someone's back does what a rifle did before the knife learned t | ||
| 1868 | 1868 | expect(world.me.alive).toBe(true); |
| 1869 | 1869 | expect(world.me.health).toBeGreaterThan(50); |
| 1870 | 1870 | }); |
| 1871 | ||
| 1872 | /** The person with a rifle up, aimed at a teammate standing 200 units off on an empty map. */ | |
| 1873 | function behindMate(seed: number, shooter?: (w: World) => Player): { world: World; shooter: Player; mate: Player } { | |
| 1874 | const world = createWorld(SHORT, "me", "ct", 5, seed); | |
| 1875 | const from = shooter === undefined ? world.me : shooter(world); | |
| 1876 | const mate = world.players.find((p) => p.team === from.team && p !== from && p !== world.me) as Player; | |
| 1877 | world.boxes = []; | |
| 1878 | world.match.phase = "live"; | |
| 1879 | for (const p of world.players) if (p !== from && p !== mate) p.alive = false; | |
| 1880 | from.pos = [0, 0, 0]; | |
| 1881 | from.vel = [0, 0, 0]; | |
| 1882 | from.onGround = true; | |
| 1883 | mate.pos = [200, 0, 0]; | |
| 1884 | mate.vel = [0, 0, 0]; | |
| 1885 | from.primary = "ak47"; | |
| 1886 | from.weapon = "ak47"; | |
| 1887 | from.ammo.ak47 = { mag: 30, reserve: 90 }; | |
| 1888 | from.nextShotAt = 0; | |
| 1889 | from.reloadUntil = 0; | |
| 1890 | from.shotIndex = 0; | |
| 1891 | const aim = aimAt(from, bodyCentre(mate)); | |
| 1892 | from.yaw = aim.yaw; | |
| 1893 | from.pitch = aim.pitch; | |
| 1894 | world.effects.length = 0; | |
| 1895 | world.messages.length = 0; | |
| 1896 | return { world, shooter: from, mate }; | |
| 1897 | } | |
| 1898 | ||
| 1899 | test("a round your teammate stops lands on them: a spark, a friendly mark, and their name", () => { | |
| 1900 | const { world, mate } = behindMate(601); | |
| 1901 | expect(shoot(world, world.me)).toBe(true); | |
| 1902 | const impacts = world.effects.filter((e) => e.kind === "impact"); | |
| 1903 | expect(impacts.length).toBe(1); | |
| 1904 | // A spark on the body, not a hole left hanging in the air when they move. | |
| 1905 | expect(impacts[0].kind === "impact" && impacts[0].surface).toBe(false); | |
| 1906 | const marks = world.effects.filter((e) => e.kind === "hitmark"); | |
| 1907 | expect(marks.length).toBe(1); | |
| 1908 | expect(marks[0].kind === "hitmark" && marks[0].friendly).toBe(true); | |
| 1909 | expect(world.effects.some((e) => e.kind === "blood")).toBe(false); | |
| 1910 | expect(world.messages.map((m) => m.text)).toEqual([`Blocked by ${mate.name}`]); | |
| 1911 | }); | |
| 1912 | ||
| 1913 | test("a blocked round still does nothing to anyone", () => { | |
| 1914 | const { world, mate } = behindMate(603); | |
| 1915 | const before = { health: mate.health, armor: mate.armor, damage: mate.damage, punch: [mate.punch[0], mate.punch[1]] as number[] }; | |
| 1916 | const log = JSON.stringify(world.damageLog); | |
| 1917 | for (let i = 0; i < 5; i++) { | |
| 1918 | world.me.nextShotAt = 0; | |
| 1919 | expect(shoot(world, world.me)).toBe(true); | |
| 1920 | } | |
| 1921 | expect(mate.health).toBe(before.health); | |
| 1922 | expect(mate.armor).toBe(before.armor); | |
| 1923 | expect(mate.damage).toBe(before.damage); | |
| 1924 | expect([mate.punch[0], mate.punch[1]]).toEqual(before.punch); | |
| 1925 | expect(world.me.damage).toBe(0); | |
| 1926 | expect(JSON.stringify(world.damageLog)).toBe(log); | |
| 1927 | expect(world.effects.some((e) => e.kind === "hurt")).toBe(false); | |
| 1928 | }); | |
| 1929 | ||
| 1930 | test("a spray into a teammate's back is one line, not one per bullet", () => { | |
| 1931 | const { world, mate } = behindMate(607); | |
| 1932 | for (let i = 0; i < 10; i++) { | |
| 1933 | world.me.nextShotAt = 0; | |
| 1934 | world.time += 0.1; | |
| 1935 | shoot(world, world.me); | |
| 1936 | } | |
| 1937 | expect(world.messages.length).toBe(1); | |
| 1938 | expect(world.messages[0].at).toBe(world.time); // held up while the burst lasts | |
| 1939 | // A fresh burst after a pause is news again. | |
| 1940 | world.time += BLOCKED_QUIET + 0.1; | |
| 1941 | world.me.nextShotAt = 0; | |
| 1942 | world.me.shotIndex = 0; // the trigger let go: the spray starts over | |
| 1943 | world.me.punch = [0, 0]; | |
| 1944 | shoot(world, world.me); | |
| 1945 | expect(world.messages.map((m) => m.text)).toEqual([`Blocked by ${mate.name}`, `Blocked by ${mate.name}`]); | |
| 1946 | }); | |
| 1947 | ||
| 1948 | test("a bot blocked by a bot pushes nothing beyond its shot and tracer", () => { | |
| 1949 | const { world, shooter } = behindMate(611, (w) => w.players.find((p) => p.bot && p.team === "t") as Player); | |
| 1950 | expect(shooter).not.toBe(world.me); | |
| 1951 | expect(shoot(world, shooter)).toBe(true); | |
| 1952 | expect(world.effects.map((e) => e.kind)).toEqual(["shot", "tracer"]); | |
| 1953 | expect(world.messages.length).toBe(0); | |
| 1954 | }); |
src/client/world.ts +31 −3
| @@ -125,8 +125,10 @@ export type Effect = | ||
| 125 | 125 | | { kind: "shot"; at: number; pos: Vec3; weapon: WeaponId; mine: boolean; shooter: number } |
| 126 | 126 | // A hit of the person's own, and only theirs: `headshot` is where it landed |
| 127 | 127 | // and `killed` is whether it was the one that finished them, so the mark for |
| 128 | // the fifth bullet reads differently from the four before it. | |
| 129 | | { kind: "hitmark"; at: number; headshot: boolean; killed: boolean } | |
| 128 | // the fifth bullet reads differently from the four before it. `friendly` is a | |
| 129 | // round a teammate's body stopped: it did nothing, and must never be drawn | |
| 130 | // or heard as a hit on an enemy. | |
| 131 | | { kind: "hitmark"; at: number; headshot: boolean; killed: boolean; friendly?: boolean } | |
| 130 | 132 | // Damage the person took: who took it, where it came from in the world, and |
| 131 | 133 | // how much. Only ever pushed for the person — a bot being shot is silent. |
| 132 | 134 | | { kind: "hurt"; at: number; victim: number; from: Vec3; amount: number } |
| @@ -408,6 +410,9 @@ export const alive = (world: World, team: Team): Player[] => | ||
| 408 | 410 | |
| 409 | 411 | export const eyePos = (p: Player): Vec3 => [p.pos[0], p.pos[1], p.pos[2] + eyeHeight(p)]; |
| 410 | 412 | |
| 413 | /** How long a "Blocked by" line holds off saying the same thing again: one line a burst. */ | |
| 414 | export const BLOCKED_QUIET = 1.5; | |
| 415 | ||
| 411 | 416 | function say(world: World, text: string, by?: number): void { |
| 412 | 417 | world.messages.push({ at: world.time, text, by }); |
| 413 | 418 | if (world.messages.length > 6) world.messages.shift(); |
| @@ -927,7 +932,15 @@ export function shoot(world: World, p: Player): boolean { | ||
| 927 | 932 | world.effects.push({ kind: "impact", at: world.time, pos: hit.point, normal: hit.normal, surface: true }); |
| 928 | 933 | } else if (hit.kind === "actor") { |
| 929 | 934 | const victim = hit.actor as Player; |
| 930 | if (victim.team === p.team && victim !== p) return true; // no friendly fire in this match | |
| 935 | // No friendly fire in this match: the teammate stops the round and takes | |
| 936 | // nothing. What the person sees is that it stopped on a body — a spark | |
| 937 | // with no hole left in the air, a mark of its own, one line naming who — | |
| 938 | // and only the person: a bot's blocked shot pushes nothing, which keeps | |
| 939 | // the arena's hot loop where it was. | |
| 940 | if (victim.team === p.team && victim !== p) { | |
| 941 | if (p === world.me) blocked(world, victim, hit.point, [-dir[0], -dir[1], -dir[2]]); | |
| 942 | return true; | |
| 943 | } | |
| 931 | 944 | // `from` is the eye the shot left: it is what decides a knife in the back, |
| 932 | 945 | // and every shooter — bot or person — comes through here with one. |
| 933 | 946 | const result = applyHit(victim, w, hit.group, hit.distance, hit.damageScale, from); |
| @@ -945,6 +958,21 @@ export function shoot(world: World, p: Player): boolean { | ||
| 945 | 958 | return true; |
| 946 | 959 | } |
| 947 | 960 | |
| 961 | /** | |
| 962 | * The person's round stopped on a teammate; the spark faces back along the | |
| 963 | * shot, since a body has no surface normal to give. The line is said once a burst: | |
| 964 | * while the same words are still fresh they are refreshed, not repeated, so a | |
| 965 | * spray into someone's back is one line that stays up until the spray stops. | |
| 966 | */ | |
| 967 | function blocked(world: World, mate: Player, point: Vec3, normal: Vec3): void { | |
| 968 | world.effects.push({ kind: "impact", at: world.time, pos: point, normal, surface: false }); | |
| 969 | world.effects.push({ kind: "hitmark", at: world.time, headshot: false, killed: false, friendly: true }); | |
| 970 | const text = `Blocked by ${mate.name}`; | |
| 971 | const last = world.messages[world.messages.length - 1]; | |
| 972 | if (last !== undefined && last.text === text && world.time - last.at < BLOCKED_QUIET) last.at = world.time; | |
| 973 | else say(world, text); | |
| 974 | } | |
| 975 | ||
| 948 | 976 | function onKill(world: World, killer: Player, victim: Player, weapon: Cause, headshot: boolean): void { |
| 949 | 977 | victim.alive = false; |
| 950 | 978 | victim.deaths += 1; |