benjivers/val Public Public: anyone can read this; the crew can change it.
Facing decides whether a flash blinds; distance decides how long
Built the way the person playing it put it: if you are looking in the direction of the flash you get flashed, and if you are not, you do not. `facingFlash` is the gate — 1 anywhere inside the screen's 37.5° half angle, falling quickly through a grace to nothing at 55°, 0 beyond — and `flashReach` only scales the duration inside the radius, never to zero while the light carries, so facing it exactly is never survivable because of a distance table and a close flash never blinds a back that was turned. A wall or a smoke between you and the pop still means you were not looking at it, whatever your angle says. Headless, same seeds: 1.16 flashes a round, 0.74% of live player-seconds blind, each flash blinding 0.92 enemies and nobody on its own side. Co-Authored-By: Claude Fable 5.1 <[email protected]>
3 files
changed +113
−42
README.md +6 −4
| @@ -21,10 +21,12 @@ server: | ||
| 21 | 21 | does not past fifteen metres. |
| 22 | 22 | - **Line of sight.** A snapshot only carries the enemies that viewer can |
| 23 | 23 | see. A wall of the map blocks the sight line, a smoke blocks it, a flash |
| 24 | blinds by the angle you were facing — only a pop that was on your screen, | |
| 25 | only inside a radius the size of a room (320–360 units on a 2400 × 1520 | |
| 26 | map), full strength at its centre and a flicker at its edge — and a | |
| 27 | blinded player is sent no enemies at all. A dead player watches through their living team mates' | |
| 24 | blinds you if you were looking at it — facing decides whether: anywhere | |
| 25 | on your 75° screen is the full duration, a quick grace past its edge for | |
| 26 | turning, nothing at all behind your back; distance decides how long, | |
| 27 | inside a radius the size of a room (320–360 units on a 2400 × 1520 map), | |
| 28 | full near the pop and a flicker at its edge — and a blinded player is sent | |
| 29 | no enemies at all. A dead player watches through their living team mates' | |
| 28 | 30 | eyes rather than through walls, so dying is not a wallhack to call out. |
| 29 | 31 | No spawn can see the other spawn: mid is staggered on purpose. A patched |
| 30 | 32 | client learns nothing a plain one does not. |
src/abilities.test.ts +58 −16
| @@ -5,9 +5,12 @@ import { | ||
| 5 | 5 | buyableSlots, |
| 6 | 6 | cast, |
| 7 | 7 | castable, |
| 8 | facingFlash, | |
| 8 | 9 | FLASH_CONE, |
| 9 | 10 | FLASH_EDGE, |
| 11 | FLASH_GRACE, | |
| 10 | 12 | flashDuration, |
| 13 | flashReach, | |
| 11 | 14 | sightBlocked, |
| 12 | 15 | SLOTS, |
| 13 | 16 | type Ability, |
| @@ -45,8 +48,8 @@ test("a flash blinds by the angle you were facing", () => { | ||
| 45 | 48 | const facingAway = 0; |
| 46 | 49 | expect(flashDuration(1.7, staring, at, facingIt, false, 360)).toBeGreaterThan(1.6); |
| 47 | 50 | expect(flashDuration(1.7, staring, at, facingAway, false, 360)).toBe(0); |
| 48 | // Half turned away is a shorter blind than staring straight at it. | |
| 49 | const glance = flashDuration(1.7, staring, at, facingIt - 0.5, false, 360); | |
| 51 | // Turned just past the edge of the screen is a shorter blind than staring. | |
| 52 | const glance = flashDuration(1.7, staring, at, facingIt - 0.8, false, 360); | |
| 50 | 53 | expect(glance).toBeGreaterThan(0); |
| 51 | 54 | expect(glance).toBeLessThan(1.7); |
| 52 | 55 | }); |
| @@ -96,24 +99,63 @@ test("distance matters inside the radius: the centre is the full duration, the e | ||
| 96 | 99 | } |
| 97 | 100 | }); |
| 98 | 101 | |
| 99 | test("the flash cone is the screen: nothing off screen blinds you", () => { | |
| 100 | // The browser draws FOV wide, so a pop more than half of that off your | |
| 101 | // facing was never on your screen, and it does nothing. | |
| 102 | expect(FLASH_CONE).toBeLessThanOrEqual(FOV / 2 + 1e-9); | |
| 102 | const deg = (n: number) => (n * Math.PI) / 180; | |
| 103 | ||
| 104 | test("facing is the gate: on screen is the full duration, behind your back is nothing", () => { | |
| 105 | // The browser draws FOV wide. Anything inside half of that was on your | |
| 106 | // screen: you looked at it, you pay all of it. Past the screen's edge a | |
| 107 | // little grace for turning, gone quickly; beyond that, nothing at all. | |
| 108 | expect(FLASH_CONE).toBeCloseTo(FOV / 2, 9); | |
| 109 | expect(FLASH_GRACE).toBeLessThanOrEqual(deg(56)); | |
| 103 | 110 | const at = { x: 1000, y: 1000 }; |
| 104 | 111 | const near = { x: 1100, y: 1000 }; |
| 105 | 112 | const straight = Math.PI; |
| 106 | const deg = (n: number) => (n * Math.PI) / 180; | |
| 107 | expect(flashDuration(1.7, near, at, straight + deg(15), false, 360)).toBe(1.7); | |
| 108 | expect(flashDuration(1.7, near, at, straight - deg(15), false, 360)).toBe(1.7); | |
| 113 | for (const off of [0, 15, 30, 37]) { | |
| 114 | expect(flashDuration(1.7, near, at, straight + deg(off), false, 360)).toBe(1.7); | |
| 115 | expect(flashDuration(1.7, near, at, straight - deg(off), false, 360)).toBe(1.7); | |
| 116 | } | |
| 117 | // The grace falls away quickly: half way through it, well under half is left. | |
| 118 | const grace = flashDuration(1.7, near, at, straight + deg(46), false, 360); | |
| 119 | expect(grace).toBeGreaterThan(0); | |
| 120 | expect(grace).toBeLessThan(0.5 * 1.7); | |
| 121 | expect(flashDuration(1.7, near, at, straight + deg(53), false, 360)).toBeLessThan(0.1); | |
| 109 | 122 | // Used to blind at full strength out to 45° and taper to 100°. |
| 110 | expect(flashDuration(1.7, near, at, straight + deg(45), false, 360)).toBe(0); | |
| 111 | expect(flashDuration(1.7, near, at, straight + deg(70), false, 360)).toBe(0); | |
| 112 | expect(flashDuration(1.7, near, at, straight + deg(100), false, 360)).toBe(0); | |
| 113 | // Just inside the screen's edge it is a flicker, not the full pop. | |
| 114 | const edge = flashDuration(1.7, near, at, straight + FLASH_CONE - deg(2), false, 360); | |
| 115 | expect(edge).toBeGreaterThan(0); | |
| 116 | expect(edge).toBeLessThan(0.3); | |
| 123 | for (const off of [55, 60, 70, 100, 180]) { | |
| 124 | expect(flashDuration(1.7, near, at, straight + deg(off), false, 360)).toBe(0); | |
| 125 | expect(facingFlash(near, at, straight + deg(off))).toBe(0); | |
| 126 | } | |
| 127 | // Turning further is never a longer blind. | |
| 128 | let last = Infinity; | |
| 129 | for (let off = 0; off <= 180; off += 1) { | |
| 130 | const now = facingFlash(near, at, straight + deg(off)); | |
| 131 | expect(now).toBeLessThanOrEqual(last); | |
| 132 | last = now; | |
| 133 | } | |
| 134 | }); | |
| 135 | ||
| 136 | test("facing decides whether, distance decides how long", () => { | |
| 137 | const at = { x: 1000, y: 1000 }; | |
| 138 | const r = 360; | |
| 139 | const stand = (d: number) => ({ x: 1000 + d, y: 1000 }); | |
| 140 | // Facing it exactly is never survivable because of a distance table: | |
| 141 | // every distance the light carries to blinds, and further is shorter. | |
| 142 | let last = Infinity; | |
| 143 | for (let d = 10; d <= r; d += 10) { | |
| 144 | const blind = flashDuration(1.7, stand(d), at, Math.PI, false, r); | |
| 145 | expect(blind).toBeGreaterThan(0); | |
| 146 | expect(blind).toBeLessThanOrEqual(last); | |
| 147 | last = blind; | |
| 148 | } | |
| 149 | expect(flashReach(r, r)).toBeCloseTo(FLASH_EDGE, 9); | |
| 150 | expect(flashReach(r + 1, r)).toBe(0); | |
| 151 | // And a close flash never blinds a player who had their back to it. | |
| 152 | for (let d = 10; d <= r; d += 10) { | |
| 153 | expect(flashDuration(1.7, stand(d), at, 0, false, r)).toBe(0); | |
| 154 | expect(flashDuration(1.7, stand(d), at, Math.PI / 2, false, r)).toBe(0); | |
| 155 | expect(flashDuration(1.7, stand(d), at, Math.PI - deg(60), false, r)).toBe(0); | |
| 156 | } | |
| 157 | // Behind a wall or a smoke you were not looking at it, whatever your angle says. | |
| 158 | expect(flashDuration(1.7, stand(10), at, Math.PI, true, r)).toBe(0); | |
| 117 | 159 | }); |
| 118 | 160 | |
| 119 | 161 | test("a smoke blocks the sight line for as long as it lives", () => { |
src/abilities.ts +49 −22
| @@ -139,27 +139,60 @@ export const SLOTS: Slot[] = ["c", "q", "e", "x"]; | ||
| 139 | 139 | export const buyableSlots: Slot[] = ["c", "q"]; |
| 140 | 140 | |
| 141 | 141 | /** |
| 142 | * The cone a flash reaches into, either side of where you are facing. | |
| 142 | * The rule, from the person playing it: if you are looking in the direction | |
| 143 | * of the flash you get flashed, and if you are not, you do not. Facing is | |
| 144 | * the gate. Distance only decides how long. | |
| 143 | 145 | * |
| 144 | 146 | * The browser draws a 75° field of view (`FOV` in `static/raycast.js`), so |
| 145 | * the far edge is half of that: a pop you could not have had on screen does | |
| 146 | * not blind you, because being blinded by something off screen reads as | |
| 147 | * unfair — it is. Inside `FLASH_FULL_CONE` it is the whole duration; from | |
| 148 | * there to the edge of the screen it fades to nothing. | |
| 147 | * anything inside half of that was on your screen: you looked at it, you | |
| 148 | * pay the whole duration. Past the edge of the screen there is a little | |
| 149 | * grace for the fact that you were probably turning, falling away quickly | |
| 150 | * to nothing by `FLASH_GRACE`. Beyond that it is behind your back and | |
| 151 | * somebody else's problem — not a short blind, not a flicker, nothing. | |
| 149 | 152 | */ |
| 150 | 153 | export const FLASH_CONE = (37.5 * Math.PI) / 180; |
| 151 | export const FLASH_FULL_CONE = (20 * Math.PI) / 180; | |
| 154 | export const FLASH_GRACE = (55 * Math.PI) / 180; | |
| 152 | 155 | /** Inside this share of the radius a flash is full strength. */ |
| 153 | 156 | export const FLASH_FULL_REACH = 0.4; |
| 154 | /** What is left of the duration at the very edge of the radius: a flicker. */ | |
| 157 | /** | |
| 158 | * What is left of the duration at the very edge of the radius. Never zero: | |
| 159 | * facing a flash exactly is not survivable because of a distance table. | |
| 160 | */ | |
| 155 | 161 | export const FLASH_EDGE = 0.2; |
| 156 | 162 | |
| 157 | 163 | /** |
| 158 | * How long a flash blinds, in seconds. Facing it is the worst case; looking | |
| 159 | * away is free; a wall or a smoke between you and it means nothing at all. | |
| 160 | * Distance matters inside the radius too: the centre is the full duration, | |
| 161 | * the edge is a flicker, and past the radius there is nothing. `blocked` is | |
| 162 | * the caller's line-of-sight answer, so this stays pure. | |
| 164 | * Whether you were looking at it, as a share of the duration: 1 anywhere on | |
| 165 | * screen, falling quickly through the grace past its edge, 0 beyond. | |
| 166 | */ | |
| 167 | export function facingFlash(from: Point, at: Point, facing: number): number { | |
| 168 | const toFlash = Math.atan2(at.y - from.y, at.x - from.x); | |
| 169 | let off = Math.abs(((toFlash - facing + Math.PI * 3) % (Math.PI * 2)) - Math.PI); | |
| 170 | off = Math.min(off, Math.PI); | |
| 171 | if (off <= FLASH_CONE) return 1; | |
| 172 | if (off >= FLASH_GRACE) return 0; | |
| 173 | const past = (off - FLASH_CONE) / (FLASH_GRACE - FLASH_CONE); | |
| 174 | return (1 - past) * (1 - past); | |
| 175 | } | |
| 176 | ||
| 177 | /** | |
| 178 | * How far the light carries, as a share of the duration: full near the pop, | |
| 179 | * a flicker at the edge of the radius, nothing past it. Distance decides | |
| 180 | * how long, never whether. | |
| 181 | */ | |
| 182 | export function flashReach(distance: number, radius: number): number { | |
| 183 | if (distance > radius) return 0; | |
| 184 | const reach = radius * FLASH_FULL_REACH; | |
| 185 | if (distance <= reach) return 1; | |
| 186 | return 1 - ((distance - reach) / (radius - reach)) * (1 - FLASH_EDGE); | |
| 187 | } | |
| 188 | ||
| 189 | /** | |
| 190 | * How long a flash blinds, in seconds. Facing decides whether: on screen is | |
| 191 | * the full duration, behind your back is nothing, and a wall or a smoke | |
| 192 | * between you and the pop means you were not looking at it whatever your | |
| 193 | * angle says. Distance decides how long, inside the radius the light | |
| 194 | * carries. `blocked` is the caller's line-of-sight answer, so this stays | |
| 195 | * pure. | |
| 163 | 196 | */ |
| 164 | 197 | export function flashDuration( |
| 165 | 198 | full: number, |
| @@ -170,16 +203,10 @@ export function flashDuration( | ||
| 170 | 203 | radius: number, |
| 171 | 204 | ): number { |
| 172 | 205 | if (blocked) return 0; |
| 173 | const distance = Math.hypot(from.x - at.x, from.y - at.y); | |
| 174 | if (distance > radius) return 0; | |
| 175 | const toFlash = Math.atan2(at.y - from.y, at.x - from.x); | |
| 176 | let off = Math.abs(((toFlash - facing + Math.PI * 3) % (Math.PI * 2)) - Math.PI); | |
| 177 | off = Math.min(off, Math.PI); | |
| 178 | if (off > FLASH_CONE) return 0; | |
| 179 | const facingFactor = off <= FLASH_FULL_CONE ? 1 : 1 - (off - FLASH_FULL_CONE) / (FLASH_CONE - FLASH_FULL_CONE); | |
| 180 | const reach = radius * FLASH_FULL_REACH; | |
| 181 | const near = distance <= reach ? 1 : 1 - ((distance - reach) / (radius - reach)) * (1 - FLASH_EDGE); | |
| 182 | return Math.round(full * facingFactor * near * 100) / 100; | |
| 206 | const looking = facingFlash(from, at, facing); | |
| 207 | if (looking === 0) return 0; | |
| 208 | const near = flashReach(Math.hypot(from.x - at.x, from.y - at.y), radius); | |
| 209 | return Math.round(full * looking * near * 100) / 100; | |
| 183 | 210 | } |
| 184 | 211 | |
| 185 | 212 | /** Whether a sight line survives the smokes and walls standing on the map. */ |