Split The code is public; the app answers only to the crew.
A drop from anywhere is free: `fallDamage` is written, tested and called by nothing #228
Check · Review · Land · Live done — Landed and live in production.
vesper/cs:vesper/190-a-drop-from-anywhere-is-free-falldamage → main 1 commit fork
10 files
changed +239
−16
README.md +11 −1
| @@ -99,7 +99,17 @@ wants one, but the running server never reads it. | ||
| 99 | 99 | than half of it however many land, and it runs back at the full 100% a |
| 100 | 100 | second — 30 in the chest holds a runner to 175 u/s and is gone in 0.3 s — |
| 101 | 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 | |
| 102 | everyone untagged. A **fall** costs health the way CS's does: nothing | |
| 103 | landing under 580 u/s, then 0.0888 a point for every u/s past it, rounded to | |
| 104 | whole points, on the one tick the fall ends — armour is no help, the ledger | |
| 105 | and the round report put it down to *a fall*, a death to one reads as a fall | |
| 106 | in the kill feed and pays nobody, and you feel it as the same flinch, | |
| 107 | vignette and pain sound a hit gives, with no wedge because it came from | |
| 108 | straight below. The honest part: nothing on `de_forge` or `de_cinder` is | |
| 109 | tall enough to reach it. The highest floor anyone can stand on is a crate | |
| 110 | top at 128 units, which lands at about 452 u/s; a drop needs roughly 210 | |
| 111 | units before it costs anything (320 costs 12). The rule is here so the | |
| 112 | simulation is whole, and so taller geometry gets it for free. The gun says what it is doing, too. Every shot lights | |
| 103 | 113 | a **muzzle flash** — a star of additive light at the end of the barrel, sized |
| 104 | 114 | by the weapon and gone in three frames — on the gun in your hands and on |
| 105 | 115 | every other gun on the map, so a firefight across the site reads as people |
src/client/bodies.test.ts +1 −1
| @@ -103,7 +103,7 @@ test("a body is decoration: nobody collides with it and no bullet stops on it", | ||
| 103 | 103 | // only box in this world is the floor the body is lying on. |
| 104 | 104 | world.boxes = [{ min: [-400, -400, -64], max: [400, 400, 0], mat: 0 }]; |
| 105 | 105 | expect(world.boxes.length).toBe(1); |
| 106 | const walker: Body = { pos: [0, -100, 0], vel: [0, 0, 0], onGround: true, ducking: false, duckAmount: 0 }; | |
| 106 | const walker: Body = { pos: [0, -100, 0], vel: [0, 0, 0], onGround: true, ducking: false, duckAmount: 0, landSpeed: 0 }; | |
| 107 | 107 | const wish: Wish = { forward: 1, side: 0, yaw: 90, jump: false, duck: false, walk: false, maxSpeed: MOVE.maxSpeed }; |
| 108 | 108 | for (let i = 0; i < 128; i++) move(walker, wish, 1 / 64, world.boxes); |
| 109 | 109 | expect(walker.pos[1]).toBeGreaterThan(0); // straight over where the body lies |
src/client/hud.test.ts +20
| @@ -107,6 +107,12 @@ test("a hit from behind puts the wedge below the crosshair, one from the left on | ||
| 107 | 107 | expect(Math.abs(side[0].y - H / 2)).toBeLessThan(20); |
| 108 | 108 | }); |
| 109 | 109 | |
| 110 | test("a fall hurts from straight below: no wedge, since there is no way it came from", () => { | |
| 111 | const frame = recorder(); | |
| 112 | draw(frame, hurtWorld(0, [0, 0, 0], 0)); | |
| 113 | expect(wedgeArcs(frame.calls).length).toBe(0); | |
| 114 | }); | |
| 115 | ||
| 110 | 116 | test("hits stack, and damage to anyone but the player being drawn is not drawn", () => { |
| 111 | 117 | const two = recorder(); |
| 112 | 118 | const world = hurtWorld(0, [-500, 0, 40], 0); |
| @@ -272,6 +278,20 @@ test("dead, the HUD is the watched player's: their numbers, no crosshair, and wh | ||
| 272 | 278 | expect(frame.calls.filter((c) => c.name === "moveTo" && c.args[1] === H / 2).length).toBe(0); |
| 273 | 279 | }); |
| 274 | 280 | |
| 281 | test("a fall that killed you says so, rather than naming you as your own killer", () => { | |
| 282 | const world = spectatedWorld(); | |
| 283 | world.feed.push({ | |
| 284 | at: world.time, killer: world.me.name, killerId: world.me.id, victim: world.me.name, victimId: world.me.id, | |
| 285 | weapon: "fall", headshot: false, killerTeam: "ct", | |
| 286 | }); | |
| 287 | const mate = world.players.find((p) => p.alive && p.team === "ct") as Player; | |
| 288 | const frame = recorder(); | |
| 289 | draw(frame, world, mate); | |
| 290 | const texts = frame.calls.filter((c) => c.name === "fillText").map((c) => c.text).join("|"); | |
| 291 | expect(texts).toContain("You fell too far"); | |
| 292 | expect(texts).toContain(`${world.me.name} Fall ${world.me.name}`); | |
| 293 | }); | |
| 294 | ||
| 275 | 295 | test("a spectator does not inherit the dead player's hit markers", () => { |
| 276 | 296 | const world = spectatedWorld(); |
| 277 | 297 | const mate = world.players.find((p) => p.alive && p.team === "ct") as Player; |
src/client/hud.ts +7 −4
| @@ -21,7 +21,7 @@ import { drawReport } from "./reportPanel.ts"; | ||
| 21 | 21 | import { deathNotice } from "./spectate.ts"; |
| 22 | 22 | import { inaccuracy, WEAPONS } from "./weapons.ts"; |
| 23 | 23 | import { |
| 24 | canSee, currentWeapon, deploying, eyePos, grenadeCount, grenadeKinds, offeredPickup, useProgress, | |
| 24 | canSee, causeName, currentWeapon, deploying, eyePos, grenadeCount, grenadeKinds, offeredPickup, useProgress, | |
| 25 | 25 | type Player, type World, |
| 26 | 26 | } from "./world.ts"; |
| 27 | 27 | |
| @@ -115,6 +115,9 @@ function drawDamage( | ||
| 115 | 115 | if (effect.kind !== "hurt" || effect.victim !== view.id) continue; |
| 116 | 116 | const age = world.time - effect.at; |
| 117 | 117 | if (age < 0 || age > HURT_FADE) continue; |
| 118 | // A fall hurts from straight below: no bearing, so no wedge — the | |
| 119 | // vignette and the flinch are the whole of it. | |
| 120 | if (Math.hypot(effect.from[0] - eye[0], effect.from[1] - eye[1]) < 1) continue; | |
| 118 | 121 | const bearing = (Math.atan2(effect.from[1] - eye[1], effect.from[0] - eye[0]) * 180) / Math.PI; |
| 119 | 122 | // Yaw grows anticlockwise, so a source to the left is a positive angle off |
| 120 | 123 | // the view; the canvas turns clockwise from +x, so the wedge sits at minus |
| @@ -534,7 +537,7 @@ export function drawHud( | ||
| 534 | 537 | const y = pad + Math.round((20 + i * 22) * scale); |
| 535 | 538 | ctx.globalAlpha = clamp(8 - age, 0, 1); |
| 536 | 539 | ctx.fillStyle = entry.killerTeam === "t" ? T_COLOR : CT_COLOR; |
| 537 | const text = `${entry.killer} ${WEAPONS[entry.weapon].name}${entry.headshot ? " ⌖" : ""} ${entry.victim}`; | |
| 540 | const text = `${entry.killer} ${causeName(entry.weapon)}${entry.headshot ? " ⌖" : ""} ${entry.victim}`; | |
| 538 | 541 | ctx.fillText(text, width - pad, y); |
| 539 | 542 | ctx.globalAlpha = 1; |
| 540 | 543 | }); |
| @@ -589,8 +592,8 @@ export function drawHud( | ||
| 589 | 592 | if (notice !== null) { |
| 590 | 593 | ctx.font = mid; |
| 591 | 594 | ctx.fillStyle = "#ff8f86"; |
| 592 | const how = `${notice.killer} killed you with ${WEAPONS[notice.weapon].name}` + | |
| 593 | (notice.headshot ? " — headshot" : ""); | |
| 595 | const how = notice.weapon === "fall" ? "You fell too far" : | |
| 596 | `${notice.killer} killed you with ${causeName(notice.weapon)}` + (notice.headshot ? " — headshot" : ""); | |
| 594 | 597 | ctx.fillText(how, width / 2, top + Math.round(30 * scale)); |
| 595 | 598 | } |
| 596 | 599 | ctx.font = small; |
src/client/physics.test.ts +47
| @@ -11,6 +11,7 @@ const body = (pos: Vec3 = [0, 0, 0]): Body => ({ | ||
| 11 | 11 | onGround: true, |
| 12 | 12 | ducking: false, |
| 13 | 13 | duckAmount: 0, |
| 14 | landSpeed: 0, | |
| 14 | 15 | }); |
| 15 | 16 | |
| 16 | 17 | const wish = (over: Partial<Wish> = {}): Wish => ({ |
| @@ -129,3 +130,49 @@ test("fall damage starts only after a long drop", () => { | ||
| 129 | 130 | expect(fallDamage(400)).toBe(0); |
| 130 | 131 | expect(fallDamage(1000)).toBeGreaterThan(30); |
| 131 | 132 | }); |
| 133 | ||
| 134 | /** Drop a body from `height` above the floor and tick until it lands; every landSpeed it reported, in order. */ | |
| 135 | const drop = (height: number): number[] => { | |
| 136 | const b = body([0, 0, height]); | |
| 137 | b.onGround = false; | |
| 138 | const seen: number[] = []; | |
| 139 | for (let i = 0; i < 64 * 5; i++) { | |
| 140 | move(b, wish(), 1 / 64, [ground]); | |
| 141 | seen.push(b.landSpeed); | |
| 142 | } | |
| 143 | return seen; | |
| 144 | }; | |
| 145 | ||
| 146 | test("a fall reports the speed it landed at on the one tick it ends, and 0 on every other", () => { | |
| 147 | const seen = drop(320); | |
| 148 | const landings = seen.filter((v) => v > 0); | |
| 149 | expect(landings).toHaveLength(1); | |
| 150 | // v² = 2gh: 715.5 u/s from 320 units, give or take one tick of gravity. | |
| 151 | expect(landings[0]).toBeGreaterThan(Math.sqrt(2 * MOVE.gravity * 320) - MOVE.gravity / 64); | |
| 152 | expect(landings[0]).toBeLessThan(Math.sqrt(2 * MOVE.gravity * 320) + MOVE.gravity / 64); | |
| 153 | expect(seen[seen.length - 1]).toBe(0); | |
| 154 | }); | |
| 155 | ||
| 156 | test("the tallest crate on either map, 128 units, lands under the threshold", () => { | |
| 157 | const landed = Math.max(...drop(128)); | |
| 158 | expect(landed).toBeGreaterThan(400); | |
| 159 | expect(fallDamage(landed)).toBe(0); | |
| 160 | }); | |
| 161 | ||
| 162 | test("a jump from flat ground and back down is a landing, and a harmless one", () => { | |
| 163 | const b = body(); | |
| 164 | move(b, wish({ jump: true }), 1 / 64, [ground]); | |
| 165 | expect(b.landSpeed).toBe(0); | |
| 166 | let landed = 0; | |
| 167 | for (let i = 0; i < 128 && landed === 0; i++) { | |
| 168 | move(b, wish(), 1 / 64, [ground]); | |
| 169 | landed = b.landSpeed; | |
| 170 | } | |
| 171 | expect(Math.abs(landed - MOVE.jumpVelocity)).toBeLessThan(MOVE.gravity / 64); | |
| 172 | expect(fallDamage(landed)).toBe(0); | |
| 173 | }); | |
| 174 | ||
| 175 | test("fall damage is exactly its formula past the threshold", () => { | |
| 176 | expect(fallDamage(579.99)).toBe(0); | |
| 177 | expect(fallDamage(680)).toBeCloseTo(8.88, 5); | |
| 178 | }); |
src/client/physics.ts +11
| @@ -49,6 +49,12 @@ export type Body = { | ||
| 49 | 49 | onGround: boolean; |
| 50 | 50 | ducking: boolean; |
| 51 | 51 | duckAmount: number; |
| 52 | /** | |
| 53 | * How fast the body was coming down on the tick a fall ended, in u/s, and 0 | |
| 54 | * on every other tick — `move` writes it each call, so standing still never | |
| 55 | * reads a landing twice. What `fallDamage` is asked about. | |
| 56 | */ | |
| 57 | landSpeed: number; | |
| 52 | 58 | }; |
| 53 | 59 | |
| 54 | 60 | export type Wish = { |
| @@ -166,6 +172,7 @@ export function move(body: Body, wish: Wish, dt: number, boxes: Box[]): void { | ||
| 166 | 172 | if (wish.walk) wishSpeed *= MOVE.walkSpeed / MOVE.maxSpeed; |
| 167 | 173 | if (body.duckAmount > 0.5) wishSpeed *= MOVE.duckSpeed / MOVE.maxSpeed; |
| 168 | 174 | |
| 175 | const wasOnGround = body.onGround; | |
| 169 | 176 | if (body.onGround) { |
| 170 | 177 | applyFriction(body.vel, dt); |
| 171 | 178 | accelerate(body.vel, wishDir, wishSpeed, MOVE.accelerate, dt); |
| @@ -203,6 +210,8 @@ export function move(body: Body, wish: Wish, dt: number, boxes: Box[]): void { | ||
| 203 | 210 | } |
| 204 | 211 | |
| 205 | 212 | if (!body.onGround) body.vel[2] -= MOVE.gravity * dt * 0.5; |
| 213 | // The speed a landing is taken at, read before a floor can zero it. | |
| 214 | const falling = -body.vel[2]; | |
| 206 | 215 | if (slideAxis(body.pos, 2, body.vel[2] * dt, height, boxes)) body.vel[2] = 0; |
| 207 | 216 | |
| 208 | 217 | // On the ground means "a floor within two units of the feet" — and landing |
| @@ -211,8 +220,10 @@ export function move(body: Body, wish: Wish, dt: number, boxes: Box[]): void { | ||
| 211 | 220 | if (support !== null && body.vel[2] <= 0) { |
| 212 | 221 | body.pos[2] = support; |
| 213 | 222 | body.vel[2] = 0; |
| 223 | body.landSpeed = wasOnGround ? 0 : Math.max(0, falling); | |
| 214 | 224 | body.onGround = true; |
| 215 | 225 | } else { |
| 226 | body.landSpeed = 0; | |
| 216 | 227 | body.onGround = false; |
| 217 | 228 | } |
| 218 | 229 | } |
src/client/report.test.ts +8
| @@ -40,12 +40,20 @@ test("a report says nothing about a pair the person was not part of", () => { | ||
| 40 | 40 | |
| 41 | 41 | test("damage nobody threw — the bomb — is named for what it is", () => { |
| 42 | 42 | const world = createWorld(SHORT, "me", "ct", 5, 407); |
| 43 | world.bomb.state = "detonated"; | |
| 43 | 44 | world.damageLog[world.players.length][world.me.id] = { damage: 100, hits: 1, killed: true }; |
| 44 | 45 | |
| 45 | 46 | const rep = report(world, world.me); |
| 46 | 47 | expect(rep.taken).toEqual(["the bomb — 100 dmg, 1 hit (killed)"]); |
| 47 | 48 | }); |
| 48 | 49 | |
| 50 | test("damage nobody threw in a round the bomb never went off in is a fall", () => { | |
| 51 | const world = createWorld(SHORT, "me", "ct", 5, 407); | |
| 52 | world.damageLog[world.players.length][world.me.id] = { damage: 12, hits: 1, killed: false }; | |
| 53 | ||
| 54 | expect(report(world, world.me).taken).toEqual(["a fall — 12 dmg, 1 hit"]); | |
| 55 | }); | |
| 56 | ||
| 49 | 57 | test("a player with nothing dealt or taken gets a report that says so, not a blank one", () => { |
| 50 | 58 | const world = createWorld(SHORT, "me", "ct", 5, 409); |
| 51 | 59 | const rep = report(world, world.me); |
src/client/report.ts +10 −1
| @@ -12,8 +12,17 @@ export type Report = { | ||
| 12 | 12 | total: string; |
| 13 | 13 | }; |
| 14 | 14 | |
| 15 | /** | |
| 16 | * `ENVIRONMENT` is one row for the bomb and for falls. The bomb only hurts | |
| 17 | * anyone the tick it goes off, so a round where it did not is a round whose | |
| 18 | * environmental damage was all falls; one where it did names the bomb, which | |
| 19 | * is the whole of it on any map there is today (no drop on either reaches the | |
| 20 | * fall threshold). | |
| 21 | */ | |
| 15 | 22 | const nameOf = (world: World, id: number): string => |
| 16 | id === ENVIRONMENT ? "the bomb" : world.players.find((p) => p.id === id)?.name ?? "?"; | |
| 23 | id === ENVIRONMENT | |
| 24 | ? (world.bomb.state === "detonated" ? "the bomb" : "a fall") | |
| 25 | : world.players.find((p) => p.id === id)?.name ?? "?"; | |
| 17 | 26 | |
| 18 | 27 | const line = (name: string, damage: number, hits: number, killed: boolean): string => |
| 19 | 28 | `${name} — ${Math.round(damage)} dmg, ${hits} hit${hits === 1 ? "" : "s"}${killed ? " (killed)" : ""}`; |
src/client/world.test.ts +81 −1
| @@ -5,7 +5,7 @@ import { dist } from "./math.ts"; | ||
| 5 | 5 | import { DE_FORGE, MAP_NAMES, MAPS, placeAt, siteAt } from "./map.ts"; |
| 6 | 6 | import { REPEAT_GAP, SIDE_GAP } from "./callout.ts"; |
| 7 | 7 | import { halfTime, SHORT, winReward, type Rules } from "./match.ts"; |
| 8 | import { GAIT_CYCLE, STEP_STRIDE } from "./physics.ts"; | |
| 8 | 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 { |
| @@ -1507,6 +1507,86 @@ test("the planted bomb's damage lands in the ledger with nobody credited", () => | ||
| 1507 | 1507 | expect(taken[0].entry.killed).toBe(true); |
| 1508 | 1508 | }); |
| 1509 | 1509 | |
| 1510 | // --- falling -------------------------------------------------------------- | |
| 1511 | ||
| 1512 | /** | |
| 1513 | * Drop `p` from `height` onto a bare floor and keep them standing on it for | |
| 1514 | * `after` seconds; the speed `move` said they landed at comes back. | |
| 1515 | */ | |
| 1516 | function fall(world: World, p: Player, height: number, after = 2): number { | |
| 1517 | world.boxes = [{ min: [-4000, -4000, -32], max: [4000, 4000, 0], mat: 0 }]; | |
| 1518 | world.match.phase = "live"; | |
| 1519 | p.pos = [0, 0, height]; | |
| 1520 | p.vel = [0, 0, 0]; | |
| 1521 | p.onGround = false; | |
| 1522 | let landed = 0; | |
| 1523 | for (let t = 0; t < 3 + after; t += TICK) { | |
| 1524 | world.time += TICK; | |
| 1525 | applyInput(world, p, NO_INPUT, TICK); | |
| 1526 | if (p.landSpeed > 0) landed = p.landSpeed; | |
| 1527 | } | |
| 1528 | return landed; | |
| 1529 | } | |
| 1530 | ||
| 1531 | const hurts = (world: World): number => world.effects.filter((e) => e.kind === "hurt").length; | |
| 1532 | ||
| 1533 | test("a fall off the tallest crate on either map, 128 units, costs exactly nothing", () => { | |
| 1534 | const world = createWorld(SHORT, "me", "ct", 5, 190); | |
| 1535 | const landed = fall(world, world.me, 128); | |
| 1536 | expect(landed).toBeGreaterThan(400); | |
| 1537 | expect(world.me.health).toBe(100); | |
| 1538 | expect(damageTaken(world, world.me.id)).toEqual([]); | |
| 1539 | expect(hurts(world)).toBe(0); | |
| 1540 | }); | |
| 1541 | ||
| 1542 | test("a 320-unit fall costs what fallDamage says for its impact speed, once, and the person feels it", () => { | |
| 1543 | const world = createWorld(SHORT, "me", "ct", 5, 191); | |
| 1544 | world.me.armor = 100; | |
| 1545 | const landed = fall(world, world.me, 320, 3); // three seconds stood on the floor after | |
| 1546 | const cost = Math.round(fallDamage(landed)); | |
| 1547 | expect(cost).toBe(12); | |
| 1548 | expect(world.me.health).toBe(100 - cost); | |
| 1549 | expect(world.me.armor).toBe(100); // armour is no help against the ground | |
| 1550 | const taken = damageTaken(world, world.me.id); | |
| 1551 | expect(taken).toEqual([{ id: ENVIRONMENT, entry: { damage: cost, hits: 1, killed: false } }]); | |
| 1552 | expect(hurts(world)).toBe(1); | |
| 1553 | }); | |
| 1554 | ||
| 1555 | test("a fall that kills credits the environment, reads as a fall in the feed, and pays nobody", () => { | |
| 1556 | const world = createWorld(SHORT, "me", "ct", 5, 192); | |
| 1557 | world.me.health = 5; | |
| 1558 | const money = world.me.money; | |
| 1559 | fall(world, world.me, 320); | |
| 1560 | expect(world.me.alive).toBe(false); | |
| 1561 | expect(world.me.health).toBe(0); | |
| 1562 | expect(world.me.deaths).toBe(1); | |
| 1563 | expect(world.me.kills).toBe(0); | |
| 1564 | expect(world.me.money).toBe(money); | |
| 1565 | const taken = damageTaken(world, world.me.id); | |
| 1566 | expect(taken).toEqual([{ id: ENVIRONMENT, entry: { damage: 5, hits: 1, killed: true } }]); | |
| 1567 | const line = world.feed[world.feed.length - 1]; | |
| 1568 | expect(line?.weapon).toBe("fall"); | |
| 1569 | expect(line?.victimId).toBe(world.me.id); | |
| 1570 | expect(line?.killerId).toBe(world.me.id); | |
| 1571 | }); | |
| 1572 | ||
| 1573 | test("a bot's fall costs it the same health and pushes no hurt effect nobody reads", () => { | |
| 1574 | const world = createWorld(SHORT, "me", "ct", 5, 193); | |
| 1575 | const bot = world.players.find((p) => p.bot) as Player; | |
| 1576 | fall(world, bot, 320); | |
| 1577 | expect(bot.health).toBe(88); | |
| 1578 | expect(damageTaken(world, bot.id)[0]?.id).toBe(ENVIRONMENT); | |
| 1579 | expect(hurts(world)).toBe(0); | |
| 1580 | }); | |
| 1581 | ||
| 1582 | test("a hurt from straight below flinches up only, never sideways", () => { | |
| 1583 | const world = createWorld(SHORT, "me", "ct", 5, 194); | |
| 1584 | world.me.punch = [0, 0]; | |
| 1585 | flinch(world.me, [world.me.pos[0], world.me.pos[1], world.me.pos[2]], 20); | |
| 1586 | expect(world.me.punch[0]).toBe(0); | |
| 1587 | expect(world.me.punch[1]).toBeGreaterThan(0); | |
| 1588 | }); | |
| 1589 | ||
| 1510 | 1590 | // --- the radio ------------------------------------------------------------ |
| 1511 | 1591 | |
| 1512 | 1592 | test("a call is one line of data on the person's own side, and the last one wins", () => { |
src/client/world.ts +43 −8
| @@ -21,7 +21,7 @@ import { DE_FORGE, MAPS, placeAt, siteAt, type MapDef } from "./map.ts"; | ||
| 21 | 21 | export { DEFAULT_MAP, MAP_NAMES, mapFor } from "./map.ts"; |
| 22 | 22 | import { angleVectors, angleDelta, clamp, dist, dist2d, normalize, rng, sub, type Vec3 } from "./math.ts"; |
| 23 | 23 | import * as rules from "./match.ts"; |
| 24 | import { eyeHeight, GAIT_CYCLE, hullHeight, move, STEP_STRIDE, type Body, type Wish, MOVE } from "./physics.ts"; | |
| 24 | import { eyeHeight, fallDamage, GAIT_CYCLE, hullHeight, move, STEP_STRIDE, type Body, type Wish, MOVE } from "./physics.ts"; | |
| 25 | 25 | import { callLabel, isCall, roundPlan, type Call, type Plan, type RadioCall } from "./plan.ts"; |
| 26 | 26 | import type { Saved, SavedPlayer } from "./saved.ts"; |
| 27 | 27 | import { visible } from "./trace.ts"; |
| @@ -136,6 +136,14 @@ export type Effect = | ||
| 136 | 136 | // the blast that ends it belong to nobody, so everyone may hear them. |
| 137 | 137 | | { kind: "sound"; at: number; pos: Vec3; name: SoundName; pitch?: number; by?: number }; |
| 138 | 138 | |
| 139 | /** | |
| 140 | * What a feed line says did it: a weapon, or a fall. A fall is not a row in | |
| 141 | * `WEAPONS` — it is never held, bought or aimed — so it is named here instead. | |
| 142 | */ | |
| 143 | export type Cause = WeaponId | "fall"; | |
| 144 | ||
| 145 | export const causeName = (cause: Cause): string => (cause === "fall" ? "Fall" : WEAPONS[cause].name); | |
| 146 | ||
| 139 | 147 | // The ids are here as well as the names because the HUD asks the feed who |
| 140 | 148 | // killed the person, and two players may answer to the same name. |
| 141 | 149 | export type FeedEntry = { |
| @@ -144,7 +152,7 @@ export type FeedEntry = { | ||
| 144 | 152 | killerId: number; |
| 145 | 153 | victim: string; |
| 146 | 154 | victimId: number; |
| 147 | weapon: WeaponId; | |
| 155 | weapon: Cause; | |
| 148 | 156 | headshot: boolean; |
| 149 | 157 | killerTeam: Team; |
| 150 | 158 | }; |
| @@ -153,8 +161,8 @@ export type FeedEntry = { | ||
| 153 | 161 | export type DamageEntry = { damage: number; hits: number; killed: boolean }; |
| 154 | 162 | |
| 155 | 163 | /** |
| 156 | * The pseudo-attacker for damage nobody threw: the planted bomb going off. | |
| 157 | * Not a player id — `damageTaken` hands it back so a report can still say | |
| 164 | * The pseudo-attacker for damage nobody threw: the planted bomb going off, or | |
| 165 | * the floor coming up too fast. Not a player id — `damageTaken` hands it back so a report can still say | |
| 158 | 166 | * where the damage came from. |
| 159 | 167 | */ |
| 160 | 168 | export const ENVIRONMENT = -1; |
| @@ -260,7 +268,7 @@ function makePlayer(id: number, team: Team, name: string, bot: boolean): Player | ||
| 260 | 268 | return { |
| 261 | 269 | id, team, name, bot, |
| 262 | 270 | pos: [0, 0, 0], vel: [0, 0, 0], yaw: 0, pitch: 0, |
| 263 | onGround: true, ducking: false, duckAmount: 0, | |
| 271 | onGround: true, ducking: false, duckAmount: 0, landSpeed: 0, | |
| 264 | 272 | health: 100, armor: 0, helmet: false, alive: true, |
| 265 | 273 | money: 800, |
| 266 | 274 | weapon: secondary, primary: null, secondary, |
| @@ -834,7 +842,8 @@ const FLINCH_MAX = 6; | ||
| 834 | 842 | export function flinch(victim: Player, from: Vec3, amount: number): void { |
| 835 | 843 | const to = sub(from, eyePos(victim)); |
| 836 | 844 | const bearing = (Math.atan2(to[1], to[0]) * 180) / Math.PI; |
| 837 | const rel = (angleDelta(victim.yaw, bearing) * Math.PI) / 180; | |
| 845 | // Straight below (a fall) has no side to kick away from: up only. | |
| 846 | const rel = Math.hypot(to[0], to[1]) < 1 ? 0 : (angleDelta(victim.yaw, bearing) * Math.PI) / 180; | |
| 838 | 847 | const kick = clamp(amount, 0, 100) * FLINCH_PER_DAMAGE; |
| 839 | 848 | victim.punch = [ |
| 840 | 849 | clamp(victim.punch[0] + kick * 0.5 * Math.sin(rel), -FLINCH_MAX, FLINCH_MAX), |
| @@ -936,14 +945,14 @@ export function shoot(world: World, p: Player): boolean { | ||
| 936 | 945 | return true; |
| 937 | 946 | } |
| 938 | 947 | |
| 939 | function onKill(world: World, killer: Player, victim: Player, weapon: WeaponId, headshot: boolean): void { | |
| 948 | function onKill(world: World, killer: Player, victim: Player, weapon: Cause, headshot: boolean): void { | |
| 940 | 949 | victim.alive = false; |
| 941 | 950 | victim.deaths += 1; |
| 942 | 951 | victim.scoped = false; |
| 943 | 952 | addBody(world.bodies, victim, world.boxes, world.time); |
| 944 | 953 | // Only a grenade can reach the person who threw it, and killing yourself is |
| 945 | 954 | // not a kill: the death and the feed line stand, the credit does not. |
| 946 | if (killer !== victim) { | |
| 955 | if (killer !== victim && weapon !== "fall") { | |
| 947 | 956 | killer.kills += 1; |
| 948 | 957 | if (headshot) killer.headshots += 1; |
| 949 | 958 | killer.money = rules.capMoney(killer.money + WEAPONS[weapon].killReward, world.match.rules); |
| @@ -1295,6 +1304,30 @@ function footsteps(world: World, p: Player, wish: Wish, before: Vec3): void { | ||
| 1295 | 1304 | |
| 1296 | 1305 | // --- the tick ------------------------------------------------------------- |
| 1297 | 1306 | |
| 1307 | /** | |
| 1308 | * The landing tick of a fall: `fallDamage` of the speed `move` says the body | |
| 1309 | * hit the floor at, taken the way the bomb's is — `ENVIRONMENT` in the ledger, | |
| 1310 | * the victim as their own killer in the feed. Whole points, as every other | |
| 1311 | * damage here is. Armour does nothing for it, as in CS. The tallest thing | |
| 1312 | * anyone can stand on today is a 128-unit crate, which lands at ~452 u/s, so | |
| 1313 | * on the maps there are this never costs anything; it is here so the | |
| 1314 | * simulation is whole, and any taller floor gets it for free. | |
| 1315 | */ | |
| 1316 | function land(world: World, p: Player): void { | |
| 1317 | const amount = Math.round(fallDamage(p.landSpeed)); | |
| 1318 | if (amount <= 0) return; | |
| 1319 | const dealt = Math.min(amount, p.health); | |
| 1320 | p.health -= amount; | |
| 1321 | const killed = p.health <= 0; | |
| 1322 | // From the feet: straight below, so the hurt has no bearing to point at. | |
| 1323 | hurt(world, p, [p.pos[0], p.pos[1], p.pos[2]], dealt); | |
| 1324 | recordDamage(world, ENVIRONMENT, p.id, dealt, killed); | |
| 1325 | if (killed) { | |
| 1326 | p.health = 0; | |
| 1327 | onKill(world, p, p, "fall", false); | |
| 1328 | } | |
| 1329 | } | |
| 1330 | ||
| 1298 | 1331 | /** Is `id` something `p` can put in their hands right now? */ |
| 1299 | 1332 | const holds = (p: Player, id: WeaponId): boolean => |
| 1300 | 1333 | id === "knife" || id === p.primary || id === p.secondary || |
| @@ -1321,6 +1354,8 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v | ||
| 1321 | 1354 | }; |
| 1322 | 1355 | const before: Vec3 = [p.pos[0], p.pos[1], p.pos[2]]; |
| 1323 | 1356 | move(p, wish, dt, world.boxes); |
| 1357 | if (p.landSpeed > 0) land(world, p); | |
| 1358 | if (!p.alive) return; | |
| 1324 | 1359 | footsteps(world, p, wish, before); |
| 1325 | 1360 | finishReload(world, p); |
| 1326 | 1361 | if (input.reload) reload(world, p); |