Split The code is public; the app answers only to the crew.

A bullet that lands does not slow the player it hits: no tagging, so trading a shot costs the enemy nothing #226

Merged opened this

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

Merged

vesper/cs:vesper/189-a-bullet-that-lands-does-not-slow-the-pl → main 1 commit fork

  • Merged. vesper/189-a-bullet-that-lands-does-not-slow-the-pl was merged into main as 92c74d7.

Review

Sign in to review this change.
6 files changed +155 −7
README.md +6 −1
@@ -94,7 +94,12 @@ wants one, but the running server never reads it.
9494 your own grenade confirms the same way, once for each enemy the blast caught —
9595 and when you are hit a view punch away from the shot, a wedge around
9696 the crosshair pointing at whoever fired, a vignette that reddens as your health
97 falls and a pain sound. The gun says what it is doing, too. Every shot lights
97 falls and a pain sound. A hit also **tags**, for everyone the same way:
98 each point of damage takes 1% of the victim's ground speed, never more
99 than half of it however many land, and it runs back at the full 100% a
100 second — 30 in the chest holds a runner to 175 u/s and is gone in 0.3 s —
101 while a player in the air is not tagged at all, and a new round starts
102 everyone untagged. The gun says what it is doing, too. Every shot lights
98103 a **muzzle flash** — a star of additive light at the end of the barrel, sized
99104 by the weapon and gone in three frames — on the gun in your hands and on
100105 every other gun on the map, so a firefight across the site reads as people
src/client/physics.ts +1 −1
@@ -58,7 +58,7 @@ export type Wish = {
5858 jump: boolean;
5959 duck: boolean;
6060 walk: boolean;
61 maxSpeed: number; // the weapon's carry speed
61 maxSpeed: number; // the weapon's carry speed, less whatever a hit has tagged off it
6262 };
6363
6464 export const eyeHeight = (b: Body): number =>
src/client/world.test.ts +111 −1
@@ -11,7 +11,8 @@ import { snapshot, type Saved } from "./saved.ts";
1111 import {
1212 aimAt, applyInput, bodyCentre, BOMB_BLAST_RADIUS, buy, callout, createWorld, currentWeapon, damageDealt,
1313 damageTaken, deploying, ENVIRONMENT, eyePos, finishRound, flinch, grenadeCount, grenadeSlot, MAX_GRENADES, newBomb,
14 nextGrenade, NO_INPUT, offeredPickup, radioCall, restoreWorld, shoot, startRound, update,
14 nextGrenade, NO_INPUT, offeredPickup, radioCall, restoreWorld, shoot, startRound, tag, TAG_FLOOR, TAG_PER_DAMAGE,
15 TAG_RECOVERY, update,
1516 type Input, type Player, type World,
1617 } from "./world.ts";
1718
@@ -806,6 +807,115 @@ test("a hit flinches the view away from where it came from, and stays bounded",
806807 expect(Math.abs(me.punch[1])).toBeLessThanOrEqual(6);
807808 });
808809
810 /**
811 * Tagging. The runner is the person on an open floor, knife out so nothing
812 * but the tag stands between them and 250 u/s, already at full speed along +y.
813 */
814 function runner(seed: number): { world: World; me: Player } {
815 const world = createWorld(SHORT, "me", "ct", 5, seed);
816 world.match.phase = "live";
817 world.boxes = [{ min: [-4000, -4000, -64], max: [4000, 4000, 0], mat: 0 }];
818 const me = world.me;
819 me.pos = [0, 0, 0];
820 me.vel = [0, 250, 0];
821 me.yaw = 90;
822 me.onGround = true;
823 me.weapon = "knife";
824 return { world, me };
825 }
826
827 const runFor = (world: World, p: Player, seconds: number): void => {
828 for (let t = 0; t < seconds - 1e-9; t += TICK) applyInput(world, p, { ...NO_INPUT, forward: 1 }, TICK);
829 };
830
831 test("a runner hit for 30 covers measurably less ground over the next half second", () => {
832 const clean = runner(601);
833 runFor(clean.world, clean.me, 0.5);
834 const hit = runner(601);
835 tag(hit.me, 30);
836 expect(hit.me.tag).toBeCloseTo(30 * TAG_PER_DAMAGE);
837 runFor(hit.world, hit.me, 0.5);
838 expect(clean.me.pos[1]).toBeCloseTo(125, 0); // 250 u/s for half a second
839 expect(hit.me.pos[1]).toBeLessThan(clean.me.pos[1] - 10);
840 });
841
842 test("the tag runs out: 30 is gone in 0.3 s, the deepest in 0.5 s, and full speed is back", () => {
843 const { world, me } = runner(603);
844 tag(me, 30);
845 runFor(world, me, 0.25);
846 expect(me.tag).toBeGreaterThan(0);
847 runFor(world, me, 0.05 + TICK);
848 expect(me.tag).toBe(0);
849
850 tag(me, 100);
851 expect(me.tag).toBe(1 - TAG_FLOOR);
852 runFor(world, me, (1 - TAG_FLOOR) / TAG_RECOVERY - 0.05);
853 expect(me.tag).toBeGreaterThan(0);
854 runFor(world, me, 0.05 + TICK);
855 expect(me.tag).toBe(0);
856 runFor(world, me, 0.5);
857 expect(Math.hypot(me.vel[0], me.vel[1])).toBeCloseTo(250, 0);
858 });
859
860 test("hits do not stack past the floor: held under fire, a runner keeps half their speed", () => {
861 const { world, me } = runner(605);
862 tag(me, 40);
863 tag(me, 40);
864 expect(me.tag).toBe(1 - TAG_FLOOR);
865 for (let i = 0; i < 64; i++) {
866 tag(me, 100); // a hit every tick for a second
867 runFor(world, me, TICK);
868 }
869 const speed = Math.hypot(me.vel[0], me.vel[1]);
870 expect(speed).toBeGreaterThan(250 * TAG_FLOOR * 0.95);
871 expect(speed).toBeLessThan(250 * TAG_FLOOR * 1.05);
872 });
873
874 test("a player in the air is not tagged", () => {
875 const { me } = runner(607);
876 me.onGround = false;
877 tag(me, 30);
878 expect(me.tag).toBe(0);
879 });
880
881 test("a round start clears the tag", () => {
882 const { world, me } = runner(609);
883 tag(me, 50);
884 const bot = world.players.find((p) => p.bot) as Player;
885 tag(bot, 50);
886 startRound(world);
887 expect(world.players.every((p) => p.tag === 0)).toBe(true);
888 });
889
890 test("tagging is the same rule both ways: a bot's bullet in you, and yours in a bot", () => {
891 const world = createWorld(SHORT, "me", "ct", 5, 611);
892 const bot = world.players.find((p) => p.team === "t") as Player;
893 faceOff(world, bot);
894 world.me.onGround = true;
895 expect(shoot(world, bot)).toBe(true);
896 const taken = damageTaken(world, world.me.id)[0].entry.damage;
897 expect(taken).toBeGreaterThan(0);
898 expect(world.me.tag).toBeCloseTo(Math.min(1 - TAG_FLOOR, taken * TAG_PER_DAMAGE));
899
900 // Turn it round: the person holds the same rifle on the bot.
901 const other = createWorld(SHORT, "me", "ct", 5, 611);
902 const target = other.players.find((p) => p.team === "t") as Player;
903 faceOff(other, target);
904 other.me.pos = [200, 0, 0];
905 other.me.primary = "ak47";
906 other.me.weapon = "ak47";
907 other.me.ammo.ak47 = { mag: 30, reserve: 90 };
908 target.pos = [0, 0, 0];
909 target.onGround = true;
910 const aim = aimAt(other.me, bodyCentre(target));
911 other.me.yaw = aim.yaw;
912 other.me.pitch = aim.pitch;
913 expect(shoot(other, other.me)).toBe(true);
914 const dealt = damageDealt(other, other.me.id)[0].entry.damage;
915 expect(dealt).toBeGreaterThan(0);
916 expect(target.tag).toBeCloseTo(Math.min(1 - TAG_FLOOR, dealt * TAG_PER_DAMAGE));
917 });
918
809919 // applyInput is stepped directly here, the way arena.ts does — a plain flat
810920 // floor and one player, with none of the round's bookkeeping (and none of
811921 // the other nine bots' own footsteps) in the way of counting a stride.
src/client/world.ts +34 −2
@@ -64,6 +64,11 @@ export type Player = Actor & Body & {
6464 firedThisPress: boolean;
6565 emptyThisPress: boolean; // the dry-fire click, latched the same way firedThisPress is
6666 punch: [number, number]; // view kick, decaying
67 /**
68 * Tagging: the share of ground speed the last hits took, 0 when untagged and
69 * never above `1 - TAG_FLOOR`. It runs back to 0 at `TAG_RECOVERY` a second.
70 */
71 tag: number;
6772 nextPickupAt: number; // no swapping guns again until this, so E cannot flip-flop
6873 kills: number;
6974 deaths: number;
@@ -263,7 +268,7 @@ function makePlayer(id: number, team: Team, name: string, bot: boolean): Player
263268 grenades: {},
264269 defuseKit: false, hasBomb: false,
265270 nextShotAt: 0, reloadUntil: 0, shotIndex: 0, lastShotAt: -99, scoped: false, firedThisPress: false, emptyThisPress: false,
266 punch: [0, 0], nextPickupAt: 0,
271 punch: [0, 0], tag: 0, nextPickupAt: 0,
267272 kills: 0, deaths: 0, headshots: 0, damage: 0, spawnAt: 0, stepDistance: 0, prevStep: 0,
268273 prev: pose(),
269274 };
@@ -480,6 +485,7 @@ export function startRound(world: World): void {
480485 p.hasBomb = false;
481486 p.shotIndex = 0;
482487 p.punch = [0, 0];
488 p.tag = 0;
483489 p.scoped = false;
484490 p.stepDistance = 0;
485491 p.prevStep = 0;
@@ -836,6 +842,30 @@ export function flinch(victim: Player, from: Vec3, amount: number): void {
836842 ];
837843 }
838844
845 /**
846 * Tagging, CS's rule that a bullet which lands slows the player it lands on:
847 * each point of damage takes `TAG_PER_DAMAGE` of their ground speed, the
848 * slow never goes past leaving them `TAG_FLOOR` of it, and it runs back out
849 * at `TAG_RECOVERY` of full speed a second — so 30 in the chest is a runner
850 * held to 70% (175 u/s) who has it all back 0.3 s later, and the deepest
851 * tag, half speed, is gone in 0.5 s. A hesitation, not a stun.
852 */
853 export const TAG_PER_DAMAGE = 0.01;
854 export const TAG_FLOOR = 0.5;
855 export const TAG_RECOVERY = 1;
856
857 /**
858 * Tag `victim` for `amount` damage. Unlike `flinch` this is inside the tick,
859 * for everyone the same way: a slow that only reached `world.me` would be a
860 * handicap on the person alone. A player in the air is not tagged — CS does
861 * not, and `move` would not honour it there anyway, since the air's own cap
862 * is far below any speed this leaves.
863 */
864 export function tag(victim: Player, amount: number): void {
865 if (!victim.onGround || amount <= 0) return;
866 victim.tag = Math.min(1 - TAG_FLOOR, victim.tag + amount * TAG_PER_DAMAGE);
867 }
868
839869 /** Fire one round if the weapon is ready. Returns true if a shot went out. */
840870 export function shoot(world: World, p: Player): boolean {
841871 const w = currentWeapon(p);
@@ -900,6 +930,7 @@ export function shoot(world: World, p: Player): boolean {
900930 kind: "hitmark", at: world.time, headshot: result.headshot, killed: result.killed,
901931 });
902932 if (result.damage > 0) hurt(world, victim, from, result.damage);
933 tag(victim, result.damage);
903934 if (result.killed) onKill(world, p, victim, w.id, result.headshot);
904935 }
905936 return true;
@@ -1286,7 +1317,7 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v
12861317 jump: input.jump && !frozen,
12871318 duck: input.duck,
12881319 walk: input.walk || p.scoped,
1289 maxSpeed: Math.min(MOVE.maxSpeed, w.carrySpeed),
1320 maxSpeed: Math.min(MOVE.maxSpeed, w.carrySpeed) * (1 - p.tag),
12901321 };
12911322 const before: Vec3 = [p.pos[0], p.pos[1], p.pos[2]];
12921323 move(p, wish, dt, world.boxes);
@@ -1306,6 +1337,7 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v
13061337 }
13071338 if (world.time - p.lastShotAt > 0.35) p.shotIndex = 0;
13081339 p.punch = [p.punch[0] * Math.pow(0.02, dt), p.punch[1] * Math.pow(0.02, dt)];
1340 p.tag = Math.max(0, p.tag - TAG_RECOVERY * dt);
13091341 updateBomb(world, p, input, dt);
13101342 updatePickups(world, p, input);
13111343 }
src/rl/arena.test.ts +2 −2
@@ -221,8 +221,8 @@ test("the arena's foe never hears anything: there is nothing left to hear", () =
221221 */
222222 test("an episode against the shipped weights produces the same actions it did", () => {
223223 const run = episode(SEEDS);
224 expect(run.actions.length).toBe(1080);
225 expect(Bun.hash(run.actions.join(",")).toString(16)).toBe("3779f480fec06e8f");
224 expect(run.actions.length).toBe(810);
225 expect(Bun.hash(run.actions.join(",")).toString(16)).toBe("4b4b2fc68a8a1af1");
226226 // Twice, from the same seeds, is the same episode: nothing here reads a clock.
227227 expect(episode(SEEDS).actions).toEqual(run.actions);
228228 });
src/rl/arena.ts +1
@@ -142,6 +142,7 @@ const equip = (p: Player, weapon: "ak47" | "m4a4"): void => {
142142 p.reloadUntil = 0;
143143 p.lastShotAt = -99;
144144 p.punch = [0, 0];
145 p.tag = 0;
145146 p.damage = 0;
146147 };
147148

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs