fable/val Public Public: anyone can read this; nobody can change it.

forked from benjivers/val

workbenchthis machine

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]>
Fable committed d8171ac Browse files
3 files changed +113 −42
README.md +6 −4
@@ -21,10 +21,12 @@ server:
2121 does not past fifteen metres.
2222 - **Line of sight.** A snapshot only carries the enemies that viewer can
2323 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'
2830 eyes rather than through walls, so dying is not a wallhack to call out.
2931 No spawn can see the other spawn: mid is staggered on purpose. A patched
3032 client learns nothing a plain one does not.
src/abilities.test.ts +58 −16
@@ -5,9 +5,12 @@ import {
55 buyableSlots,
66 cast,
77 castable,
8 facingFlash,
89 FLASH_CONE,
910 FLASH_EDGE,
11 FLASH_GRACE,
1012 flashDuration,
13 flashReach,
1114 sightBlocked,
1215 SLOTS,
1316 type Ability,
@@ -45,8 +48,8 @@ test("a flash blinds by the angle you were facing", () => {
4548 const facingAway = 0;
4649 expect(flashDuration(1.7, staring, at, facingIt, false, 360)).toBeGreaterThan(1.6);
4750 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);
5053 expect(glance).toBeGreaterThan(0);
5154 expect(glance).toBeLessThan(1.7);
5255 });
@@ -96,24 +99,63 @@ test("distance matters inside the radius: the centre is the full duration, the e
9699 }
97100 });
98101
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));
103110 const at = { x: 1000, y: 1000 };
104111 const near = { x: 1100, y: 1000 };
105112 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);
109122 // 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);
117159 });
118160
119161 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"];
139139 export const buyableSlots: Slot[] = ["c", "q"];
140140
141141 /**
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.
143145 *
144146 * 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.
149152 */
150153 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;
152155 /** Inside this share of the radius a flash is full strength. */
153156 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 */
155161 export const FLASH_EDGE = 0.2;
156162
157163 /**
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.
163196 */
164197 export function flashDuration(
165198 full: number,
@@ -170,16 +203,10 @@ export function flashDuration(
170203 radius: number,
171204 ): number {
172205 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;
183210 }
184211
185212 /** Whether a sight line survives the smokes and walls standing on the map. */
↑↓ navigate ↵ open esc close

Keyboard shortcuts