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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs:vesper/189-a-bullet-that-lands-does-not-slow-the-pl → main 1 commit fork
6 files
changed +155
−7
README.md +6 −1
| @@ -94,7 +94,12 @@ wants one, but the running server never reads it. | ||
| 94 | 94 | your own grenade confirms the same way, once for each enemy the blast caught — |
| 95 | 95 | and when you are hit a view punch away from the shot, a wedge around |
| 96 | 96 | 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 | |
| 98 | 103 | a **muzzle flash** — a star of additive light at the end of the barrel, sized |
| 99 | 104 | by the weapon and gone in three frames — on the gun in your hands and on |
| 100 | 105 | 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 = { | ||
| 58 | 58 | jump: boolean; |
| 59 | 59 | duck: boolean; |
| 60 | 60 | 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 | |
| 62 | 62 | }; |
| 63 | 63 | |
| 64 | 64 | export const eyeHeight = (b: Body): number => |
src/client/world.test.ts +111 −1
| @@ -11,7 +11,8 @@ import { snapshot, type Saved } from "./saved.ts"; | ||
| 11 | 11 | import { |
| 12 | 12 | aimAt, applyInput, bodyCentre, BOMB_BLAST_RADIUS, buy, callout, createWorld, currentWeapon, damageDealt, |
| 13 | 13 | 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, | |
| 15 | 16 | type Input, type Player, type World, |
| 16 | 17 | } from "./world.ts"; |
| 17 | 18 | |
| @@ -806,6 +807,115 @@ test("a hit flinches the view away from where it came from, and stays bounded", | ||
| 806 | 807 | expect(Math.abs(me.punch[1])).toBeLessThanOrEqual(6); |
| 807 | 808 | }); |
| 808 | 809 | |
| 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 | ||
| 809 | 919 | // applyInput is stepped directly here, the way arena.ts does — a plain flat |
| 810 | 920 | // floor and one player, with none of the round's bookkeeping (and none of |
| 811 | 921 | // 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 & { | ||
| 64 | 64 | firedThisPress: boolean; |
| 65 | 65 | emptyThisPress: boolean; // the dry-fire click, latched the same way firedThisPress is |
| 66 | 66 | 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; | |
| 67 | 72 | nextPickupAt: number; // no swapping guns again until this, so E cannot flip-flop |
| 68 | 73 | kills: number; |
| 69 | 74 | deaths: number; |
| @@ -263,7 +268,7 @@ function makePlayer(id: number, team: Team, name: string, bot: boolean): Player | ||
| 263 | 268 | grenades: {}, |
| 264 | 269 | defuseKit: false, hasBomb: false, |
| 265 | 270 | 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, | |
| 267 | 272 | kills: 0, deaths: 0, headshots: 0, damage: 0, spawnAt: 0, stepDistance: 0, prevStep: 0, |
| 268 | 273 | prev: pose(), |
| 269 | 274 | }; |
| @@ -480,6 +485,7 @@ export function startRound(world: World): void { | ||
| 480 | 485 | p.hasBomb = false; |
| 481 | 486 | p.shotIndex = 0; |
| 482 | 487 | p.punch = [0, 0]; |
| 488 | p.tag = 0; | |
| 483 | 489 | p.scoped = false; |
| 484 | 490 | p.stepDistance = 0; |
| 485 | 491 | p.prevStep = 0; |
| @@ -836,6 +842,30 @@ export function flinch(victim: Player, from: Vec3, amount: number): void { | ||
| 836 | 842 | ]; |
| 837 | 843 | } |
| 838 | 844 | |
| 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 | ||
| 839 | 869 | /** Fire one round if the weapon is ready. Returns true if a shot went out. */ |
| 840 | 870 | export function shoot(world: World, p: Player): boolean { |
| 841 | 871 | const w = currentWeapon(p); |
| @@ -900,6 +930,7 @@ export function shoot(world: World, p: Player): boolean { | ||
| 900 | 930 | kind: "hitmark", at: world.time, headshot: result.headshot, killed: result.killed, |
| 901 | 931 | }); |
| 902 | 932 | if (result.damage > 0) hurt(world, victim, from, result.damage); |
| 933 | tag(victim, result.damage); | |
| 903 | 934 | if (result.killed) onKill(world, p, victim, w.id, result.headshot); |
| 904 | 935 | } |
| 905 | 936 | return true; |
| @@ -1286,7 +1317,7 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v | ||
| 1286 | 1317 | jump: input.jump && !frozen, |
| 1287 | 1318 | duck: input.duck, |
| 1288 | 1319 | walk: input.walk || p.scoped, |
| 1289 | maxSpeed: Math.min(MOVE.maxSpeed, w.carrySpeed), | |
| 1320 | maxSpeed: Math.min(MOVE.maxSpeed, w.carrySpeed) * (1 - p.tag), | |
| 1290 | 1321 | }; |
| 1291 | 1322 | const before: Vec3 = [p.pos[0], p.pos[1], p.pos[2]]; |
| 1292 | 1323 | move(p, wish, dt, world.boxes); |
| @@ -1306,6 +1337,7 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v | ||
| 1306 | 1337 | } |
| 1307 | 1338 | if (world.time - p.lastShotAt > 0.35) p.shotIndex = 0; |
| 1308 | 1339 | 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); | |
| 1309 | 1341 | updateBomb(world, p, input, dt); |
| 1310 | 1342 | updatePickups(world, p, input); |
| 1311 | 1343 | } |
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", () = | ||
| 221 | 221 | */ |
| 222 | 222 | test("an episode against the shipped weights produces the same actions it did", () => { |
| 223 | 223 | 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"); | |
| 226 | 226 | // Twice, from the same seeds, is the same episode: nothing here reads a clock. |
| 227 | 227 | expect(episode(SEEDS).actions).toEqual(run.actions); |
| 228 | 228 | }); |
src/rl/arena.ts +1
| @@ -142,6 +142,7 @@ const equip = (p: Player, weapon: "ak47" | "m4a4"): void => { | ||
| 142 | 142 | p.reloadUntil = 0; |
| 143 | 143 | p.lastShotAt = -99; |
| 144 | 144 | p.punch = [0, 0]; |
| 145 | p.tag = 0; | |
| 145 | 146 | p.damage = 0; |
| 146 | 147 | }; |
| 147 | 148 |