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

Merged opened this

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

Merged

vesper/cs:vesper/203-a-round-your-own-teammate-stops-vanishes → main 1 commit fork

  • Merged. vesper/203-a-round-your-own-teammate-stops-vanishes was merged into main as 979784f.

Review

Sign in to review this change.
8 files changed +165 −8
README.md +4 −1
@@ -100,7 +100,10 @@ wants one, but the running server never reads it.
100100 someone — four ticks around the crosshair, red for a headshot, and for the hit
101101 that **finished** them a heavier mark, held twice as long, closed into an X
102102 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 —
104107 and when you are hit a view punch away from the shot, a wedge around
105108 the crosshair pointing at whoever fired, a vignette that reddens as your health
106109 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
8585 const fatal: Effect = { kind: "hitmark", at: 1, headshot: false, killed: true };
8686 expect(cuesHeard([fatal], 0, HERE, 1)).toEqual([{ kind: "hit", headshot: false, killed: true }]);
8787 });
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 =
3838 // A hit of your own: where it landed, and whether it was the one that
3939 // finished them. `audio.ts` plays `hit(headshot)` today and ignores
4040 // `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 }
4243 | { kind: "hurt"; from: Vec3; amount: number }
4344 | { kind: "step"; pos: Vec3 }
4445 | { kind: "world"; name: WorldSoundName; pos: Vec3; pitch?: number };
@@ -55,7 +56,7 @@ export function cuesHeard(effects: Effect[], since: number, listener: Vec3, loca
5556 if (effect.at <= since) continue;
5657 if (effect.kind === "shot") cues.push({ kind: "shot", pos: effect.pos, weapon: effect.weapon });
5758 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 });
5960 else if (effect.kind === "hurt") {
6061 if (effect.victim === localId) cues.push({ kind: "hurt", from: effect.from, amount: effect.amount });
6162 } 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
730730 // be a setting that changes the game.
731731 expect(drawAt(world, "reduced")).toEqual(drawAt(world, "full"));
732732 });
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(
400400 if (effect.kind !== "hitmark") continue;
401401 const age = world.time - effect.at;
402402 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 }
403417 ctx.strokeStyle = effect.headshot ? "rgba(255,90,90,0.95)" : "rgba(255,255,255,0.9)";
404418 ctx.lineWidth = effect.killed ? 3 : 2;
405419 const r = 9 + age * 20;
src/client/main.ts +3 −1
@@ -839,7 +839,9 @@ function boot(): void {
839839 const at = eyePos(ears);
840840 for (const cue of cuesHeard(current.effects, lastSound, at, current.me.id)) {
841841 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 }
843845 else if (cue.kind === "hurt") {
844846 flinch(current.me, cue.from, cue.amount);
845847 audio.pain(cue.amount);
src/client/world.test.ts +85 −1
@@ -9,7 +9,7 @@ import { fallDamage, GAIT_CYCLE, STEP_STRIDE } from "./physics.ts";
99 import { WEAPONS, type WeaponId } from "./weapons.ts";
1010 import { snapshot, type Saved } from "./saved.ts";
1111 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,
1313 damageTaken, deploying, ENVIRONMENT, eyePos, finishRound, flinch, grenadeCount, grenadeSlot, MAX_GRENADES, newBomb,
1414 nextGrenade, NO_INPUT, offeredPickup, radioCall, restoreWorld, shoot, startRound, tag, TAG_FLOOR, TAG_PER_DAMAGE,
1515 TAG_RECOVERY, update,
@@ -1868,3 +1868,87 @@ test("a rifle in someone's back does what a rifle did before the knife learned t
18681868 expect(world.me.alive).toBe(true);
18691869 expect(world.me.health).toBeGreaterThan(50);
18701870 });
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 =
125125 | { kind: "shot"; at: number; pos: Vec3; weapon: WeaponId; mine: boolean; shooter: number }
126126 // A hit of the person's own, and only theirs: `headshot` is where it landed
127127 // 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 }
130132 // Damage the person took: who took it, where it came from in the world, and
131133 // how much. Only ever pushed for the person — a bot being shot is silent.
132134 | { kind: "hurt"; at: number; victim: number; from: Vec3; amount: number }
@@ -408,6 +410,9 @@ export const alive = (world: World, team: Team): Player[] =>
408410
409411 export const eyePos = (p: Player): Vec3 => [p.pos[0], p.pos[1], p.pos[2] + eyeHeight(p)];
410412
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
411416 function say(world: World, text: string, by?: number): void {
412417 world.messages.push({ at: world.time, text, by });
413418 if (world.messages.length > 6) world.messages.shift();
@@ -927,7 +932,15 @@ export function shoot(world: World, p: Player): boolean {
927932 world.effects.push({ kind: "impact", at: world.time, pos: hit.point, normal: hit.normal, surface: true });
928933 } else if (hit.kind === "actor") {
929934 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 }
931944 // `from` is the eye the shot left: it is what decides a knife in the back,
932945 // and every shooter — bot or person — comes through here with one.
933946 const result = applyHit(victim, w, hit.group, hit.distance, hit.damageScale, from);
@@ -945,6 +958,21 @@ export function shoot(world: World, p: Player): boolean {
945958 return true;
946959 }
947960
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
948976 function onKill(world: World, killer: Player, victim: Player, weapon: Cause, headshot: boolean): void {
949977 victim.alive = false;
950978 victim.deaths += 1;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs