benjivers/val Public Public: anyone can read this; the crew can change it.
Draw the match from inside the player's head
Nobody could tell what they were looking at: a board of dots does not read as a site you are holding. This puts the camera in the player's eyes with a raycaster over the tile grid the map was already built for — one ray a column, a wall slice inversely tall, shaded by distance and by which face was struck so a corner reads as a corner. The client is split into modules named for one concept so the maths can be tested without a browser: raycast.js (columns, slices, z-clip, smoke along a ray), scene.js (the world), topdown.js (the old view, now the minimap and the phone view), gun.js, beep.js, steer.js, prompt.js. Bun bundles them at start-up into the one content-named file the page has always loaded, so the CSP and the year-long cache are untouched and no dependency is added. Bodies are billboards z-clipped against the wall depth, team mates named and enemies never; smoke fogs a ray from where it enters it; the spike is on the ground with a beep that quickens; there is a crosshair, a weapon that kicks and a hit marker. Aiming is pointer lock and the angle sent is the same angle as before — match.ts and arena.ts are untouched. V falls back to the top-down view, Tab holds the map open, and a coarse pointer stays top-down. Instructions come with it: a first-run card over the canvas (dismissed per browser, never over a live round, H or ? reopens), one contextual prompt at a time, and a buy panel that prices every button and says what each ability does. /agents.json is new — the client cannot label a kit it does not know. Co-Authored-By: Claude Opus 5 <[email protected]>
17 files
changed +1934
−227
src/beep.test.ts added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { beepInterval, beepPitch, makeBeeper } from "../static/beep.js"; | |
| 3 | ||
| 4 | test("the spike beeps faster as the fuse burns down", () => { | |
| 5 | expect(beepInterval(45)).toBeGreaterThan(beepInterval(20)); | |
| 6 | expect(beepInterval(20)).toBeGreaterThan(beepInterval(5)); | |
| 7 | expect(beepInterval(1)).toBeGreaterThanOrEqual(0.12); | |
| 8 | expect(beepInterval(45)).toBeLessThanOrEqual(1); | |
| 9 | }); | |
| 10 | ||
| 11 | test("a spent fuse does not beep at all", () => { | |
| 12 | expect(beepInterval(0)).toBe(0); | |
| 13 | }); | |
| 14 | ||
| 15 | test("and it rises in pitch on the way", () => { | |
| 16 | expect(beepPitch(2)).toBeGreaterThan(beepPitch(40)); | |
| 17 | }); | |
| 18 | ||
| 19 | test("nothing is played before the browser has had a gesture", () => { | |
| 20 | const beeper = makeBeeper(); | |
| 21 | // No AudioContext in this runtime: update must be a no-op, not a throw. | |
| 22 | expect(() => beeper.update(true, 30, 1)).not.toThrow(); | |
| 23 | expect(() => beeper.wake()).not.toThrow(); | |
| 24 | }); |
src/gun.test.ts added +49
| @@ -0,0 +1,49 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { fade, firedNow, gunSnapshot, gunTick, hitMarked, makeGun } from "../static/gun.js"; | |
| 3 | ||
| 4 | const snap = (over: Record<string, unknown> = {}, shots: unknown[] = []) => ({ | |
| 5 | me: { x: 100, y: 100, ammo: 25, reloading: false, alive: true, ...over }, | |
| 6 | shots, | |
| 7 | }); | |
| 8 | ||
| 9 | test("the magazine going down is a shot; a reload is not", () => { | |
| 10 | expect(firedNow(25, 24, false)).toBe(true); | |
| 11 | expect(firedNow(25, 25, false)).toBe(false); | |
| 12 | expect(firedNow(1, 25, true)).toBe(false); | |
| 13 | expect(firedNow(null, 25, false)).toBe(false); | |
| 14 | }); | |
| 15 | ||
| 16 | test("a hit marker comes from a shot of mine the server says landed", () => { | |
| 17 | const mine = [{ x1: 102, y1: 99, x2: 400, y2: 100, hit: true }]; | |
| 18 | expect(hitMarked(mine, { x: 100, y: 100 })).toBe(true); | |
| 19 | const missed = [{ x1: 102, y1: 99, x2: 400, y2: 100, hit: false }]; | |
| 20 | expect(hitMarked(missed, { x: 100, y: 100 })).toBe(false); | |
| 21 | const somebody_else = [{ x1: 900, y1: 900, x2: 400, y2: 100, hit: true }]; | |
| 22 | expect(hitMarked(somebody_else, { x: 100, y: 100 })).toBe(false); | |
| 23 | }); | |
| 24 | ||
| 25 | test("a snapshot with a shot in it kicks the gun, and the kick fades", () => { | |
| 26 | const gun = makeGun(); | |
| 27 | gunSnapshot(gun, snap()); | |
| 28 | expect(gun.kick).toBe(0); | |
| 29 | gunSnapshot(gun, snap({ ammo: 24 })); | |
| 30 | expect(gun.kick).toBe(1); | |
| 31 | gunTick(gun, 0.06); | |
| 32 | expect(gun.kick).toBeLessThan(1); | |
| 33 | gunTick(gun, 1); | |
| 34 | expect(gun.kick).toBe(0); | |
| 35 | }); | |
| 36 | ||
| 37 | test("a landed shot marks, and the mark fades on its own clock", () => { | |
| 38 | const gun = makeGun(); | |
| 39 | gunSnapshot(gun, snap({ ammo: 24 }, [{ x1: 100, y1: 100, x2: 300, y2: 100, hit: true }])); | |
| 40 | expect(gun.mark).toBe(1); | |
| 41 | gunTick(gun, 0.2); | |
| 42 | expect(gun.mark).toBeGreaterThan(0); | |
| 43 | gunTick(gun, 0.2); | |
| 44 | expect(gun.mark).toBe(0); | |
| 45 | }); | |
| 46 | ||
| 47 | test("fade never goes below nothing", () => { | |
| 48 | expect(fade(0.1, 10, 0.2)).toBe(0); | |
| 49 | }); |
src/pages/game.ts +54 −8
| @@ -70,6 +70,22 @@ export function gamePage(opts: { | ||
| 70 | 70 | #flash { position: absolute; inset: 0; background: #fff; opacity: 0; pointer-events: none; } |
| 71 | 71 | #dead { position: absolute; inset: 0; display: none; place-items: center; background: #0008; |
| 72 | 72 | font-size: 1.1rem; letter-spacing: .1em; text-transform: uppercase; } |
| 73 | #prompt { position: absolute; left: 50%; transform: translateX(-50%); bottom: .6rem; | |
| 74 | max-width: 92%; padding: .35rem .7rem; border-radius: .4rem; background: #0b0d11cc; | |
| 75 | border: 1px solid #2f3541; font-size: .9rem; text-align: center; pointer-events: none; } | |
| 76 | #aimhint { position: absolute; inset: auto 0 45% 0; display: none; place-items: center; | |
| 77 | font-size: .85rem; letter-spacing: .08em; text-transform: uppercase; | |
| 78 | text-shadow: 0 1px 4px #000; pointer-events: none; } | |
| 79 | #help { position: fixed; inset: 0; z-index: 10; display: none; place-items: center; | |
| 80 | background: #080a0ef2; padding: .8rem; overflow: auto; } | |
| 81 | #help > div { max-width: 40rem; } | |
| 82 | #help h2 { font-size: 1rem; margin: 0 0 .4rem; letter-spacing: .1em; text-transform: uppercase; } | |
| 83 | #help p, #help ul { margin: .4rem 0; font-size: .88rem; } | |
| 84 | #help ul { padding-left: 1.1rem; } | |
| 85 | #help kbd { background: #222732; border: 1px solid #39404e; border-radius: .25rem; | |
| 86 | padding: 0 .3rem; font: inherit; font-size: .82rem; } | |
| 87 | #helpclose { min-height: 44px; padding: 0 1.1rem; margin-top: .5rem; background: #3a4356; | |
| 88 | color: var(--ink); border: 0; border-radius: .45rem; font: inherit; cursor: pointer; } | |
| 73 | 89 | #hud { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; margin: .5rem 0; |
| 74 | 90 | font-variant-numeric: tabular-nums; } |
| 75 | 91 | .chip { background: #1b1e25; border: 1px solid #2a2f3a; border-radius: .4rem; padding: .3rem .55rem; |
| @@ -118,7 +134,30 @@ export function gamePage(opts: { | ||
| 118 | 134 | <div id="stage"> |
| 119 | 135 | <canvas id="view" width="900" height="570"></canvas> |
| 120 | 136 | <div id="flash"></div> |
| 137 | <div id="aimhint">click to aim · esc releases</div> | |
| 121 | 138 | <div id="dead">waiting for the next round</div> |
| 139 | <div id="prompt">Click the map to aim, then press <b>H</b> for how to play.</div> | |
| 140 | <div id="help" role="dialog" aria-label="How to play val"><div> | |
| 141 | <h2>How to play</h2> | |
| 142 | <p><strong>The round.</strong> Attackers carry the spike to site A or B and plant it; | |
| 143 | defenders stop them, or defuse it once it is down. Plant and it runs a 45s fuse. | |
| 144 | First team to 13 rounds wins, and the sides swap at 12.</p> | |
| 145 | <p><strong>The teams.</strong> You are <span class="red">red</span> or | |
| 146 | <span class="blue">blue</span>; each round one team attacks and the other defends. | |
| 147 | Team mates carry a name tag over their head, enemies never do.</p> | |
| 148 | <p><strong>Controls.</strong> <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> move · | |
| 149 | click the view to capture the mouse and look, <kbd>Esc</kbd> releases · | |
| 150 | click to fire · <kbd>R</kbd> reload · hold <kbd>F</kbd> to plant, defuse or pick up · | |
| 151 | <kbd>Tab</kbd> holds the big map open · <kbd>V</kbd> swaps to the top-down view · | |
| 152 | <kbd>B</kbd> opens the buy · <kbd>H</kbd> brings this card back.</p> | |
| 153 | <p><strong>Abilities.</strong> <kbd>C</kbd> and <kbd>Q</kbd> are bought in the buy phase and | |
| 154 | last the round, <kbd>E</kbd> is free and comes back on a cooldown, and <kbd>X</kbd> is | |
| 155 | the ultimate, paid for in ult points you earn by playing.</p> | |
| 156 | <p><strong>On a phone</strong> the view stays top-down: the left thumb walks, the right | |
| 157 | thumb aims and fires, and the buttons under the map buy and cast.</p> | |
| 158 | <ul id="helpagents"></ul> | |
| 159 | <button id="helpclose" type="button">Got it — play</button> | |
| 160 | </div></div> | |
| 122 | 161 | </div> |
| 123 | 162 | |
| 124 | 163 | <div id="hud"> |
| @@ -128,6 +167,8 @@ export function gamePage(opts: { | ||
| 128 | 167 | <span class="chip" id="ultmeter">ult 0/6</span> |
| 129 | 168 | <span class="kit" id="kit"></span> |
| 130 | 169 | <button id="buytoggle" class="chip" type="button">buy (B)</button> |
| 170 | <button id="viewtoggle" class="chip" type="button">view (V)</button> | |
| 171 | <button id="helptoggle" class="chip" type="button">how to play (H)</button> | |
| 131 | 172 | </div> |
| 132 | 173 | |
| 133 | 174 | <div id="buy"> |
| @@ -136,13 +177,16 @@ export function gamePage(opts: { | ||
| 136 | 177 | (a) => `<button type="button" data-agent="${escape(a.id)}" title="${escape(a.blurb)}">${escape(a.name)} <small>${escape(a.role)}</small></button>`, |
| 137 | 178 | ).join("")}</div> |
| 138 | 179 | <div class="buys" id="guns">${weaponIds() |
| 139 | .map((id) => `<button type="button" data-buy="${id}">${escape(WEAPONS[id].name)} <b>${WEAPONS[id].cost}</b></button>`) | |
| 180 | .map( | |
| 181 | (id) => | |
| 182 | `<button type="button" data-buy="${id}" data-cost="${WEAPONS[id].cost}" title="${escape(WEAPONS[id].name)} — ${WEAPONS[id].cost} credits, ${WEAPONS[id].magazine} rounds, ${WEAPONS[id].auto ? "automatic" : "semi-automatic"}">${escape(WEAPONS[id].name)} <b>${WEAPONS[id].cost}</b></button>`, | |
| 183 | ) | |
| 140 | 184 | .join("")}</div> |
| 141 | 185 | <div class="buys" id="gear"> |
| 142 | <button type="button" data-buy="light">Light shield <b>400</b></button> | |
| 143 | <button type="button" data-buy="heavy">Heavy shield <b>1000</b></button> | |
| 144 | <button type="button" data-buy="c">Ability C</button> | |
| 145 | <button type="button" data-buy="q">Ability Q</button> | |
| 186 | <button type="button" data-buy="light" data-cost="400" title="25 armour that soaks a quarter of what hits you">Light shield <b>400</b></button> | |
| 187 | <button type="button" data-buy="heavy" data-cost="1000" title="50 armour that soaks half of what hits you">Heavy shield <b>1000</b></button> | |
| 188 | <button type="button" data-buy="c" data-cost="0">C ability <b>—</b></button> | |
| 189 | <button type="button" data-buy="q" data-cost="0">Q ability <b>—</b></button> | |
| 146 | 190 | </div> |
| 147 | 191 | </div> |
| 148 | 192 | |
| @@ -162,9 +206,11 @@ export function gamePage(opts: { | ||
| 162 | 206 | </div> |
| 163 | 207 | |
| 164 | 208 | <footer> |
| 165 | <p><strong>Controls.</strong> WASD move · mouse aims · click fires · R reloads · | |
| 166 | <strong>F</strong> plants and defuses (hold it) · C / Q / E / X cast · B opens the buy. | |
| 167 | On a phone: left thumb walks, right thumb aims and fires, the buttons under the map do the rest.</p> | |
| 209 | <p><strong>Controls.</strong> WASD move · click the view to capture the mouse, move to look, | |
| 210 | Esc releases · click fires · R reloads · <strong>F</strong> plants and defuses (hold it) · | |
| 211 | C / Q / E / X cast · Tab holds the map open · V swaps to the top-down view · | |
| 212 | B opens the buy · H shows how to play. | |
| 213 | On a phone it stays top-down: left thumb walks, right thumb aims and fires.</p> | |
| 168 | 214 | <p>Five attackers carry the spike, five defenders stop them. 100s round, 4s plant, 45s fuse, |
| 169 | 215 | 7s defuse with a checkpoint at half. First to 13, sides swap at 12. Bots fill every seat |
| 170 | 216 | nobody is in, and they use their kit.</p> |
src/prompt.test.ts added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { choosePrompt } from "../static/prompt.js"; | |
| 3 | ||
| 4 | type Snap = ReturnType<typeof snapshot>; | |
| 5 | ||
| 6 | const snapshot = (over: Record<string, unknown> = {}, me: Record<string, unknown> = {}, spike: Record<string, unknown> = {}) => ({ | |
| 7 | phase: "live", | |
| 8 | clock: 40, | |
| 9 | result: null, | |
| 10 | me: { side: "atk", alive: true, blind: 0, planting: 0, spike: false, ...me }, | |
| 11 | spike: { planted: false, carried: false, fuse: 0, defuse: 0, x: 100, y: 100, ...spike }, | |
| 12 | ...over, | |
| 13 | }); | |
| 14 | ||
| 15 | describe("the one prompt on screen", () => { | |
| 16 | test("the buy phase says what to do and how long there is", () => { | |
| 17 | const text = choosePrompt(snapshot({ phase: "buy", clock: 14.2 })); | |
| 18 | expect(text).toBe("Buy phase — 15s left. Click a gun you can afford."); | |
| 19 | }); | |
| 20 | ||
| 21 | test("carrying the spike off a site sends you to one", () => { | |
| 22 | expect(choosePrompt(snapshot({}, { spike: true }), { onSite: false })).toBe( | |
| 23 | "You have the spike — get to site A or B", | |
| 24 | ); | |
| 25 | }); | |
| 26 | ||
| 27 | test("carrying it onto a site says how to plant", () => { | |
| 28 | expect(choosePrompt(snapshot({}, { spike: true }), { onSite: true })).toBe("Hold F to plant"); | |
| 29 | }); | |
| 30 | ||
| 31 | test("a defender near a planted spike is told to defuse, with the fuse", () => { | |
| 32 | const text = choosePrompt(snapshot({}, { side: "def" }, { planted: true, fuse: 31.4 }), { nearSpike: true }); | |
| 33 | expect(text).toBe("Spike down, 32s — hold F to defuse"); | |
| 34 | }); | |
| 35 | ||
| 36 | test("a defender across the map is told to get to it", () => { | |
| 37 | const text = choosePrompt(snapshot({}, { side: "def" }, { planted: true, fuse: 31.4 }), { nearSpike: false }); | |
| 38 | expect(text).toBe("Spike down, 32s — get to it and hold F"); | |
| 39 | }); | |
| 40 | ||
| 41 | test("an attacker with it planted is told to hold the site", () => { | |
| 42 | expect(choosePrompt(snapshot({}, {}, { planted: true, fuse: 10 }))).toBe( | |
| 43 | "Spike down, 10s — keep them off it", | |
| 44 | ); | |
| 45 | }); | |
| 46 | ||
| 47 | test("blind beats everything else on screen", () => { | |
| 48 | const text = choosePrompt(snapshot({}, { blind: 1.4, spike: true }), { onSite: true }); | |
| 49 | expect(text).toBe("You are blind — back off until it clears"); | |
| 50 | }); | |
| 51 | ||
| 52 | test("dead in a live round says you are still in the match", () => { | |
| 53 | expect(choosePrompt(snapshot({}, { alive: false }))).toBe("Waiting for the next round — you're in it"); | |
| 54 | }); | |
| 55 | ||
| 56 | test("the end of a round shows the result and the countdown", () => { | |
| 57 | const text = choosePrompt(snapshot({ phase: "end", clock: 4.1, result: "Spike defused" })); | |
| 58 | expect(text).toBe("Spike defused — next round in 5s"); | |
| 59 | }); | |
| 60 | ||
| 61 | test("a live defender is told what defending is", () => { | |
| 62 | expect(choosePrompt(snapshot({}, { side: "def" }))).toBe("Defending — hold A and B, 40s"); | |
| 63 | }); | |
| 64 | ||
| 65 | test("an attacker beside a loose spike is told to walk onto it", () => { | |
| 66 | expect(choosePrompt(snapshot(), { nearSpike: true })).toBe("Walk onto the spike to pick it up"); | |
| 67 | }); | |
| 68 | ||
| 69 | test("defusing counts down the defuse, not the fuse", () => { | |
| 70 | const text = choosePrompt(snapshot({}, { side: "def" }, { planted: true, fuse: 20, defuse: 3.5 }), { | |
| 71 | nearSpike: true, | |
| 72 | }); | |
| 73 | expect(text).toBe("Defusing — keep holding F, 4s"); | |
| 74 | }); | |
| 75 | ||
| 76 | test("no snapshot yet is no prompt", () => { | |
| 77 | expect(choosePrompt(null as unknown as Snap)).toBe(""); | |
| 78 | }); | |
| 79 | }); |
src/raycast.test.ts added +185
| @@ -0,0 +1,185 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { | |
| 3 | angleColumn, | |
| 4 | bandLight, | |
| 5 | castRay, | |
| 6 | columnAngle, | |
| 7 | columnOffset, | |
| 8 | FOV, | |
| 9 | hitResult, | |
| 10 | project, | |
| 11 | projection, | |
| 12 | projectResult, | |
| 13 | sliceHeight, | |
| 14 | smokeAlong, | |
| 15 | spriteColumnVisible, | |
| 16 | wallLight, | |
| 17 | } from "../static/raycast.js"; | |
| 18 | ||
| 19 | const W = 900; | |
| 20 | ||
| 21 | describe("columns and angles", () => { | |
| 22 | test("the middle column looks straight ahead", () => { | |
| 23 | expect(columnOffset(W / 2 - 0.5, W)).toBeCloseTo(0, 6); | |
| 24 | expect(columnAngle(W / 2 - 0.5, W, 1.2)).toBeCloseTo(1.2, 6); | |
| 25 | }); | |
| 26 | ||
| 27 | test("the outer columns sit at the edges of the field of view", () => { | |
| 28 | expect(columnOffset(0, W)).toBeCloseTo(-FOV / 2, 2); | |
| 29 | expect(columnOffset(W - 1, W)).toBeCloseTo(FOV / 2, 2); | |
| 30 | }); | |
| 31 | ||
| 32 | test("a column maps to an angle and back again", () => { | |
| 33 | for (const col of [0, 123, 450, 899]) { | |
| 34 | expect(angleColumn(columnOffset(col, W), W)).toBeCloseTo(col + 0.5, 4); | |
| 35 | } | |
| 36 | }); | |
| 37 | ||
| 38 | test("columns are not spread evenly — that is what keeps walls straight", () => { | |
| 39 | const middle = columnOffset(451, W) - columnOffset(450, W); | |
| 40 | const edge = columnOffset(1, W) - columnOffset(0, W); | |
| 41 | expect(edge).toBeLessThan(middle); | |
| 42 | }); | |
| 43 | }); | |
| 44 | ||
| 45 | describe("wall slices", () => { | |
| 46 | test("a wall twice as far is half as tall", () => { | |
| 47 | const proj = projection(W); | |
| 48 | expect(sliceHeight(160, proj, 80)).toBeCloseTo(sliceHeight(80, proj, 80) / 2, 6); | |
| 49 | }); | |
| 50 | ||
| 51 | test("a north–south face is darker than an east–west one at the same range", () => { | |
| 52 | expect(wallLight(200, 1, 80)).toBeLessThan(wallLight(200, 0, 80)); | |
| 53 | }); | |
| 54 | ||
| 55 | test("further is dimmer, and never fully black", () => { | |
| 56 | expect(wallLight(900, 0, 80)).toBeLessThan(wallLight(120, 0, 80)); | |
| 57 | expect(wallLight(5000, 1, 80)).toBeGreaterThan(0); | |
| 58 | }); | |
| 59 | ||
| 60 | test("the floor is dimmest at the horizon", () => { | |
| 61 | expect(bandLight(2, 200)).toBeLessThan(bandLight(180, 200)); | |
| 62 | }); | |
| 63 | }); | |
| 64 | ||
| 65 | // A four-by-four room: solid border, open inside. | |
| 66 | const room = (col: number, row: number): boolean => col <= 0 || row <= 0 || col >= 3 || row >= 3; | |
| 67 | ||
| 68 | describe("castRay", () => { | |
| 69 | const out = hitResult(); | |
| 70 | ||
| 71 | test("finds the wall straight ahead and calls it an east–west face", () => { | |
| 72 | castRay(room, 120, 120, 0, 80, 2000, out); | |
| 73 | expect(out.wall).toBe(true); | |
| 74 | expect(out.dist).toBeCloseTo(120, 6); // from x=120 to the face at x=240 | |
| 75 | expect(out.side).toBe(0); | |
| 76 | expect(out.col).toBe(3); | |
| 77 | }); | |
| 78 | ||
| 79 | test("a ray upward strikes a north–south face", () => { | |
| 80 | castRay(room, 120, 120, -Math.PI / 2, 80, 2000, out); | |
| 81 | expect(out.wall).toBe(true); | |
| 82 | expect(out.dist).toBeCloseTo(40, 6); | |
| 83 | expect(out.side).toBe(1); | |
| 84 | expect(out.row).toBe(0); | |
| 85 | }); | |
| 86 | ||
| 87 | test("a ray that runs out of range reports no wall", () => { | |
| 88 | castRay(room, 120, 120, 0, 80, 50, out); | |
| 89 | expect(out.wall).toBe(false); | |
| 90 | expect(out.dist).toBe(50); | |
| 91 | }); | |
| 92 | ||
| 93 | test("standing inside a wall is zero distance, not an infinite loop", () => { | |
| 94 | castRay(room, 10, 10, 0.3, 80, 2000, out); | |
| 95 | expect(out.dist).toBe(0); | |
| 96 | expect(out.wall).toBe(true); | |
| 97 | }); | |
| 98 | ||
| 99 | test("a diagonal ray agrees with walking the same line by hand", () => { | |
| 100 | castRay(room, 120, 120, Math.PI / 4, 80, 2000, out); | |
| 101 | let step = 0; | |
| 102 | while (step < 2000 && !room(Math.floor((120 + Math.cos(Math.PI / 4) * step) / 80), Math.floor((120 + Math.sin(Math.PI / 4) * step) / 80))) | |
| 103 | step += 0.25; | |
| 104 | expect(out.dist).toBeCloseTo(step, 0); | |
| 105 | }); | |
| 106 | ||
| 107 | test("it fills the caller's object and allocates nothing", () => { | |
| 108 | const same = castRay(room, 120, 120, 0, 80, 2000, out); | |
| 109 | expect(same).toBe(out); | |
| 110 | }); | |
| 111 | }); | |
| 112 | ||
| 113 | describe("projecting a body onto the screen", () => { | |
| 114 | const proj = projection(W); | |
| 115 | const out = projectResult(); | |
| 116 | ||
| 117 | test("something dead ahead lands in the middle of the screen", () => { | |
| 118 | project(100, 100, 0, 500, 100, W, proj, 80, out); | |
| 119 | expect(out.behind).toBe(false); | |
| 120 | expect(out.column).toBeCloseTo(W / 2, 4); | |
| 121 | expect(out.perp).toBeCloseTo(400, 6); | |
| 122 | }); | |
| 123 | ||
| 124 | test("something to the right lands right of centre", () => { | |
| 125 | project(100, 100, 0, 500, 260, W, proj, 80, out); | |
| 126 | expect(out.column).toBeGreaterThan(W / 2); | |
| 127 | }); | |
| 128 | ||
| 129 | test("something behind the camera is not drawn at all", () => { | |
| 130 | project(100, 100, 0, -400, 100, W, proj, 80, out); | |
| 131 | expect(out.behind).toBe(true); | |
| 132 | expect(out.size).toBe(0); | |
| 133 | }); | |
| 134 | ||
| 135 | test("the further body is the smaller one", () => { | |
| 136 | project(100, 100, 0, 300, 100, W, proj, 80, out); | |
| 137 | const near = out.size; | |
| 138 | project(100, 100, 0, 900, 100, W, proj, 80, out); | |
| 139 | expect(out.size).toBeLessThan(near); | |
| 140 | }); | |
| 141 | ||
| 142 | test("perpendicular depth, not straight-line distance, so the edges do not bulge", () => { | |
| 143 | project(100, 100, 0, 500, 300, W, proj, 80, out); | |
| 144 | expect(out.perp).toBeLessThan(out.dist); | |
| 145 | }); | |
| 146 | }); | |
| 147 | ||
| 148 | describe("z-clip", () => { | |
| 149 | test("a body nearer than the wall behind it is drawn", () => { | |
| 150 | expect(spriteColumnVisible(300, 900)).toBe(true); | |
| 151 | }); | |
| 152 | ||
| 153 | test("a body behind the corner is hidden", () => { | |
| 154 | expect(spriteColumnVisible(900, 300)).toBe(false); | |
| 155 | }); | |
| 156 | ||
| 157 | test("a body flush against the wall does not bleed through it", () => { | |
| 158 | expect(spriteColumnVisible(299, 300)).toBe(false); | |
| 159 | }); | |
| 160 | }); | |
| 161 | ||
| 162 | describe("smoke as a volume", () => { | |
| 163 | test("a ray through the middle of a smoke is fully fogged", () => { | |
| 164 | expect(smokeAlong(0, 0, 1, 0, 400, 0, 110, 1000)).toBe(1); | |
| 165 | }); | |
| 166 | ||
| 167 | test("a ray that misses it is not fogged at all", () => { | |
| 168 | expect(smokeAlong(0, 0, 1, 0, 400, 400, 110, 1000)).toBe(0); | |
| 169 | }); | |
| 170 | ||
| 171 | test("a wall in front of the smoke is not fogged by it", () => { | |
| 172 | expect(smokeAlong(0, 0, 1, 0, 400, 0, 110, 200)).toBe(0); | |
| 173 | }); | |
| 174 | ||
| 175 | test("clipping the edge fogs less than going through the middle", () => { | |
| 176 | const edge = smokeAlong(0, 0, 1, 0, 400, 100, 110, 1000); | |
| 177 | expect(edge).toBeGreaterThan(0); | |
| 178 | expect(edge).toBeLessThan(1); | |
| 179 | }); | |
| 180 | ||
| 181 | test("standing in the middle of one is nearly opaque in every direction", () => { | |
| 182 | expect(smokeAlong(400, 0, 1, 0, 400, 0, 110, 1000)).toBeGreaterThan(0.9); | |
| 183 | expect(smokeAlong(400, 0, 0, -1, 400, 0, 110, 1000)).toBeGreaterThan(0.9); | |
| 184 | }); | |
| 185 | }); |
src/scene.test.ts added +174
| @@ -0,0 +1,174 @@ | ||
| 1 | /** | |
| 2 | * The renderer, run for real against a canvas that writes down what it was | |
| 3 | * asked to draw. No browser, but every line of the draw path is executed — | |
| 4 | * which is what catches a typo in a frame nobody can see from a unit test. | |
| 5 | */ | |
| 6 | ||
| 7 | import { expect, test } from "bun:test"; | |
| 8 | import { drawGun, makeGun } from "../static/gun.js"; | |
| 9 | import { drawScene, makeScene } from "../static/scene.js"; | |
| 10 | import { drawTopdown } from "../static/topdown.js"; | |
| 11 | import { gridRows, TILE } from "./map.ts"; | |
| 12 | ||
| 13 | type Op = { op: string; args: unknown[] }; | |
| 14 | ||
| 15 | /** A canvas that records. Every call the renderer makes has to exist here. */ | |
| 16 | function fakeContext() { | |
| 17 | const ops: Op[] = []; | |
| 18 | const note = | |
| 19 | (op: string) => | |
| 20 | (...args: unknown[]) => { | |
| 21 | ops.push({ op, args }); | |
| 22 | }; | |
| 23 | const ctx = { | |
| 24 | ops, | |
| 25 | fillStyle: "", | |
| 26 | strokeStyle: "", | |
| 27 | lineWidth: 1, | |
| 28 | font: "", | |
| 29 | textAlign: "left", | |
| 30 | globalAlpha: 1, | |
| 31 | clearRect: note("clearRect"), | |
| 32 | fillRect: note("fillRect"), | |
| 33 | strokeRect: note("strokeRect"), | |
| 34 | beginPath: note("beginPath"), | |
| 35 | closePath: note("closePath"), | |
| 36 | moveTo: note("moveTo"), | |
| 37 | lineTo: note("lineTo"), | |
| 38 | arc: note("arc"), | |
| 39 | ellipse: note("ellipse"), | |
| 40 | fill: note("fill"), | |
| 41 | stroke: note("stroke"), | |
| 42 | clip: note("clip"), | |
| 43 | rect: note("rect"), | |
| 44 | save: note("save"), | |
| 45 | restore: note("restore"), | |
| 46 | translate: note("translate"), | |
| 47 | rotate: note("rotate"), | |
| 48 | fillText: note("fillText"), | |
| 49 | measureText: (text: string) => ({ width: text.length * 6 }), | |
| 50 | createLinearGradient: () => ({ addColorStop() {} }), | |
| 51 | }; | |
| 52 | return ctx; | |
| 53 | } | |
| 54 | ||
| 55 | // A room three tiles deep running east, with a pillar standing in it at | |
| 56 | // column 5 — enough geometry for a body to hide behind. | |
| 57 | const room = (col: number, row: number): boolean => | |
| 58 | col < 1 || col > 12 || row < 1 || row > 3 || col === 5; | |
| 59 | const world = { solid: room, tile: TILE }; | |
| 60 | ||
| 61 | const snapshot = (players: unknown[], zones: unknown[] = []) => ({ | |
| 62 | phase: "live", | |
| 63 | clock: 40, | |
| 64 | result: null, | |
| 65 | me: { id: "me", x: 120, y: 200, angle: 0, alive: true, side: "atk", hp: 100, blind: 0, ammo: 25, reloading: false, planting: 0, spike: false, agent: "ember" }, | |
| 66 | players, | |
| 67 | zones, | |
| 68 | orbs: [], | |
| 69 | shots: [], | |
| 70 | spike: { planted: false, carried: false, fuse: 0, defuse: 0, x: null, y: null }, | |
| 71 | }); | |
| 72 | ||
| 73 | const body = (over: Record<string, unknown> = {}) => ({ | |
| 74 | id: "them", | |
| 75 | name: "Sable", | |
| 76 | agent: "sable", | |
| 77 | x: 280, | |
| 78 | y: 200, | |
| 79 | angle: 3.1, | |
| 80 | hp: 100, | |
| 81 | alive: true, | |
| 82 | mate: false, | |
| 83 | spike: false, | |
| 84 | blind: false, | |
| 85 | ...over, | |
| 86 | }); | |
| 87 | ||
| 88 | const view = { width: 900, height: 570, look: (p: { x: number; y: number; angle: number }) => p }; | |
| 89 | const eye = { x: 120, y: 200, angle: 0 }; | |
| 90 | ||
| 91 | test("a frame draws walls: one filled column per screen column, and the horizon", () => { | |
| 92 | const ctx = fakeContext(); | |
| 93 | drawScene(ctx as never, makeScene(), world, eye, snapshot([]), view); | |
| 94 | const rects = ctx.ops.filter((o) => o.op === "fillRect"); | |
| 95 | // The two bands and the horizon, then a slice for every column of the wall. | |
| 96 | expect(rects.length).toBeGreaterThan(800); | |
| 97 | }); | |
| 98 | ||
| 99 | test("a body in the open is drawn; the same body behind the pillar is not", () => { | |
| 100 | const open = fakeContext(); | |
| 101 | drawScene(open as never, makeScene(), world, eye, snapshot([body()]), view); | |
| 102 | // The head is the only arc in this scene, so it stands for the body. | |
| 103 | expect(open.ops.some((o) => o.op === "arc")).toBe(true); | |
| 104 | ||
| 105 | const hidden = fakeContext(); | |
| 106 | drawScene(hidden as never, makeScene(), world, eye, snapshot([body({ x: 700 })]), view); | |
| 107 | expect(hidden.ops.some((o) => o.op === "arc")).toBe(false); | |
| 108 | }); | |
| 109 | ||
| 110 | test("a team mate gets a name tag and an enemy never does", () => { | |
| 111 | const mate = fakeContext(); | |
| 112 | drawScene(mate as never, makeScene(), world, eye, snapshot([body({ mate: true })]), view); | |
| 113 | expect(mate.ops.some((o) => o.op === "fillText" && o.args[0] === "Sable")).toBe(true); | |
| 114 | ||
| 115 | const enemy = fakeContext(); | |
| 116 | drawScene(enemy as never, makeScene(), world, eye, snapshot([body()]), view); | |
| 117 | expect(enemy.ops.some((o) => o.op === "fillText")).toBe(false); | |
| 118 | }); | |
| 119 | ||
| 120 | test("a smoke between the eye and a body hides it", () => { | |
| 121 | const smoke = [{ k: "smoke", x: 200, y: 200, r: 90, f: 0, l: 8 }]; | |
| 122 | const ctx = fakeContext(); | |
| 123 | drawScene(ctx as never, makeScene(), world, eye, snapshot([body()], smoke), view); | |
| 124 | expect(ctx.ops.some((o) => o.op === "arc")).toBe(false); | |
| 125 | ||
| 126 | // A smoke that is not on the line does not hide anything. | |
| 127 | const aside = [{ k: "smoke", x: 200, y: 900, r: 90, f: 0, l: 8 }]; | |
| 128 | const clear = fakeContext(); | |
| 129 | drawScene(clear as never, makeScene(), world, eye, snapshot([body()], aside), view); | |
| 130 | expect(clear.ops.some((o) => o.op === "arc")).toBe(true); | |
| 131 | }); | |
| 132 | ||
| 133 | test("every kind of zone, the spike and a tracer draw without falling over", () => { | |
| 134 | const zones = ["smoke", "molly", "wall", "trip", "dart", "flash", "heal"].map((k, i) => ({ | |
| 135 | k, | |
| 136 | x: 300 + i * 20, | |
| 137 | y: 200, | |
| 138 | r: 90, | |
| 139 | f: k === "flash" ? 0.3 : 0, | |
| 140 | l: 5, | |
| 141 | })); | |
| 142 | const snap = snapshot([body(), body({ id: "mate", mate: true, alive: false, hp: 0 })], zones); | |
| 143 | snap.orbs = [{ x: 360, y: 200 }] as never; | |
| 144 | snap.shots = [{ x1: 130, y1: 200, x2: 380, y2: 200, hit: true, age: 0 }] as never; | |
| 145 | snap.spike = { planted: true, carried: false, fuse: 12, defuse: 0, x: 330, y: 200 } as never; | |
| 146 | const ctx = fakeContext(); | |
| 147 | drawScene(ctx as never, makeScene(), world, eye, snap, view); | |
| 148 | expect(ctx.ops.length).toBeGreaterThan(100); | |
| 149 | expect(ctx.globalAlpha).toBe(1); | |
| 150 | }); | |
| 151 | ||
| 152 | test("the top-down view draws the real map, small or large", () => { | |
| 153 | const map = { rows: gridRows(), tile: TILE }; | |
| 154 | const snap = snapshot([body({ mate: true })]); | |
| 155 | const big = fakeContext(); | |
| 156 | drawTopdown(big as never, map, snap, (p: never) => p, { x: 0, y: 0, w: 900, h: 570 }); | |
| 157 | const small = fakeContext(); | |
| 158 | drawTopdown(small as never, map, snap, (p: never) => p, { x: 8, y: 8, w: 260, h: 165 }, { labels: false }); | |
| 159 | expect(big.ops.some((o) => o.op === "fillText")).toBe(true); | |
| 160 | expect(small.ops.some((o) => o.op === "fillText")).toBe(false); | |
| 161 | expect(small.ops.some((o) => o.op === "translate")).toBe(true); | |
| 162 | }); | |
| 163 | ||
| 164 | test("the gun and the crosshair draw, and the hit marker only when there is one", () => { | |
| 165 | const gun = makeGun(); | |
| 166 | const ctx = fakeContext(); | |
| 167 | drawGun(ctx as never, gun, 900, 570, snapshot([])); | |
| 168 | const quiet = ctx.ops.length; | |
| 169 | gun.mark = 1; | |
| 170 | gun.kick = 1; | |
| 171 | const loud = fakeContext(); | |
| 172 | drawGun(loud as never, gun, 900, 570, snapshot([])); | |
| 173 | expect(loud.ops.length).toBeGreaterThan(quiet); | |
| 174 | }); |
src/server.test.ts +25
| @@ -176,3 +176,28 @@ test("the match keeps running and is never parked", () => { | ||
| 176 | 176 | expect(state.now).toBeGreaterThan(before); |
| 177 | 177 | expect(["buy", "live", "end", "over"]).toContain(state.phase); |
| 178 | 178 | }); |
| 179 | ||
| 180 | test("the kit is public: every ability, its price and what it does", async () => { | |
| 181 | const response = await fetch(base + "/agents.json"); | |
| 182 | expect(response.status).toBe(200); | |
| 183 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); | |
| 184 | const body = (await response.json()) as { agents: { id: string; abilities: Record<string, { name: string; cost: number; text: string }> }[] }; | |
| 185 | expect(body.agents.length).toBe(5); | |
| 186 | const veil = body.agents.find((a) => a.id === "veil")!; | |
| 187 | expect(veil.abilities.c.name).toBe("Cloudburst"); | |
| 188 | expect(veil.abilities.c.cost).toBe(100); | |
| 189 | expect(veil.abilities.e.text).toContain("smoke"); | |
| 190 | // The ultimate is never for sale, and no server-only state leaks out. | |
| 191 | expect(veil.abilities.x.cost).toBe(0); | |
| 192 | expect(JSON.stringify(body)).not.toContain("kind"); | |
| 193 | }); | |
| 194 | ||
| 195 | test("the page carries the first-run card and the prompt line", async () => { | |
| 196 | const body = await (await fetch(base + "/")).text(); | |
| 197 | expect(body).toContain('id="help"'); | |
| 198 | expect(body).toContain("How to play"); | |
| 199 | expect(body).toContain('id="prompt"'); | |
| 200 | // Every buy button says what it costs, so the panel is readable at a glance. | |
| 201 | expect(body).toContain('data-buy="vandal" data-cost="2900"'); | |
| 202 | expect(body).toContain('id="viewtoggle"'); | |
| 203 | }); |
src/server.ts +44 −4
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | |
| 10 | 10 | import { createHash, randomUUID } from "node:crypto"; |
| 11 | 11 | import { join } from "node:path"; |
| 12 | import { AGENTS, SLOTS } from "./abilities.ts"; | |
| 12 | 13 | import { attach, command, findPlayer, match, release, snapshot, startArena, takeSeat, type Command } from "./arena.ts"; |
| 13 | 14 | import { recentResults } from "./db.ts"; |
| 14 | 15 | import { gridRows, TILE } from "./map.ts"; |
| @@ -103,10 +104,17 @@ async function readForm(request: Request): Promise<FormData | undefined> { | ||
| 103 | 104 | |
| 104 | 105 | // ------------------------------------------------------------- the client |
| 105 | 106 | |
| 106 | // The client script is named by its content: a change is a new URL, so a | |
| 107 | // year-long cache is never stale. | |
| 108 | const scriptPath = join(import.meta.dir, "..", "static", "val.js"); | |
| 109 | const scriptBody = await Bun.file(scriptPath).text(); | |
| 107 | // The client is a handful of small modules under static/ — the renderer, the | |
| 108 | // map, the crosshair, the ray maths — bundled into one file at start-up so a | |
| 109 | // browser makes one request and the maths can still be imported by the tests. | |
| 110 | // It is named by its content: a change is a new URL, so a year-long cache is | |
| 111 | // never stale. | |
| 112 | const built = await Bun.build({ | |
| 113 | entrypoints: [join(import.meta.dir, "..", "static", "val.js")], | |
| 114 | target: "browser", | |
| 115 | }); | |
| 116 | if (!built.success) throw new AggregateError(built.logs, "the client would not build"); | |
| 117 | const scriptBody = await built.outputs[0]!.text(); | |
| 110 | 118 | const scriptHash = createHash("sha256").update(scriptBody).digest("hex").slice(0, 10); |
| 111 | 119 | const scriptUrl = `/static/val.${scriptHash}.js`; |
| 112 | 120 | |
| @@ -192,6 +200,38 @@ export const server = Bun.serve<SocketData, Record<string, never>>({ | ||
| 192 | 200 | : response; |
| 193 | 201 | } |
| 194 | 202 | |
| 203 | // The agents and their kits: what every ability does, its price and its | |
| 204 | // charges, so the buy panel can label itself. The same table the server | |
| 205 | // plays by, and no secret in it. | |
| 206 | if (url.pathname === "/agents.json" && (request.method === "GET" || request.method === "HEAD")) | |
| 207 | return Response.json( | |
| 208 | { | |
| 209 | agents: AGENTS.map((a) => ({ | |
| 210 | id: a.id, | |
| 211 | name: a.name, | |
| 212 | role: a.role, | |
| 213 | blurb: a.blurb, | |
| 214 | abilities: Object.fromEntries( | |
| 215 | SLOTS.map((slot) => { | |
| 216 | const ability = a.abilities[slot]; | |
| 217 | return [ | |
| 218 | slot, | |
| 219 | { | |
| 220 | name: ability.name, | |
| 221 | cost: ability.cost, | |
| 222 | charges: ability.charges, | |
| 223 | cooldown: ability.cooldown, | |
| 224 | points: ability.points, | |
| 225 | text: ability.text, | |
| 226 | }, | |
| 227 | ]; | |
| 228 | }), | |
| 229 | ), | |
| 230 | })), | |
| 231 | }, | |
| 232 | { headers: { "cache-control": "public, max-age=300", ...SECURITY } }, | |
| 233 | ); | |
| 234 | ||
| 195 | 235 | // The map is the same for everybody and changes only when the code does. |
| 196 | 236 | if (url.pathname === "/map.json" && (request.method === "GET" || request.method === "HEAD")) |
| 197 | 237 | return Response.json( |
src/steer.test.ts added +35
| @@ -0,0 +1,35 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { moveVector } from "../static/steer.js"; | |
| 3 | ||
| 4 | test("W walks the way you are looking, whichever way that is", () => { | |
| 5 | const east = moveVector(0, -1, 0); | |
| 6 | expect(east.x).toBeCloseTo(1, 6); | |
| 7 | expect(east.y).toBeCloseTo(0, 6); | |
| 8 | const south = moveVector(0, -1, Math.PI / 2); | |
| 9 | expect(south.x).toBeCloseTo(0, 6); | |
| 10 | expect(south.y).toBeCloseTo(1, 6); | |
| 11 | }); | |
| 12 | ||
| 13 | test("D strafes across the facing, not along it", () => { | |
| 14 | const right = moveVector(1, 0, 0); | |
| 15 | expect(right.x).toBeCloseTo(0, 6); | |
| 16 | expect(right.y).toBeCloseTo(1, 6); | |
| 17 | const other = moveVector(-1, 0, 0); | |
| 18 | expect(other.y).toBeCloseTo(-1, 6); | |
| 19 | }); | |
| 20 | ||
| 21 | test("forward and strafe together keep their length", () => { | |
| 22 | const both = moveVector(1, -1, 0.7); | |
| 23 | expect(Math.hypot(both.x, both.y)).toBeCloseTo(Math.SQRT2, 6); | |
| 24 | }); | |
| 25 | ||
| 26 | test("standing still stays still", () => { | |
| 27 | const still = moveVector(0, 0, 2.2); | |
| 28 | expect(still.x).toBeCloseTo(0, 6); | |
| 29 | expect(still.y).toBeCloseTo(0, 6); | |
| 30 | }); | |
| 31 | ||
| 32 | test("it fills the object it was given, so the input loop allocates nothing", () => { | |
| 33 | const out = { x: 0, y: 0 }; | |
| 34 | expect(moveVector(0, -1, 0, out)).toBe(out); | |
| 35 | }); |
static/beep.js added +62
| @@ -0,0 +1,62 @@ | ||
| 1 | /** | |
| 2 | * The spike, out loud. | |
| 3 | * | |
| 4 | * One oscillator per beep, scheduled by how much fuse is left: a slow tick | |
| 5 | * when it is planted, a stutter when it is about to go. No audio files, and | |
| 6 | * nothing is created until the person has clicked the page — a browser will | |
| 7 | * not start an audio context before that, and should not. | |
| 8 | */ | |
| 9 | ||
| 10 | /** Seconds between beeps for this much fuse left. */ | |
| 11 | export function beepInterval(fuse) { | |
| 12 | if (fuse <= 0) return 0; | |
| 13 | return Math.max(0.12, Math.min(1, fuse / 30)); | |
| 14 | } | |
| 15 | ||
| 16 | /** The pitch rises as the fuse burns, so it is audible without watching. */ | |
| 17 | export const beepPitch = (fuse) => 760 + 620 * (1 - Math.min(1, fuse / 45)); | |
| 18 | ||
| 19 | export function makeBeeper() { | |
| 20 | let audio = null; | |
| 21 | let next = 0; | |
| 22 | ||
| 23 | const context = () => { | |
| 24 | if (audio) return audio; | |
| 25 | const Ctor = globalThis.AudioContext || globalThis.webkitAudioContext; | |
| 26 | if (!Ctor) return null; | |
| 27 | audio = new Ctor(); | |
| 28 | return audio; | |
| 29 | }; | |
| 30 | ||
| 31 | return { | |
| 32 | /** Called on a click: browsers only allow audio to start from a gesture. */ | |
| 33 | wake() { | |
| 34 | const ctx = context(); | |
| 35 | if (ctx && ctx.state === "suspended") ctx.resume(); | |
| 36 | }, | |
| 37 | /** Called every frame with the fuse the server last sent. */ | |
| 38 | update(planted, fuse, now) { | |
| 39 | if (!planted || fuse <= 0) { | |
| 40 | next = 0; | |
| 41 | return; | |
| 42 | } | |
| 43 | if (!audio) return; // no gesture yet; nothing to play into | |
| 44 | if (now < next) return; | |
| 45 | next = now + beepInterval(fuse); | |
| 46 | beep(audio, beepPitch(fuse)); | |
| 47 | }, | |
| 48 | }; | |
| 49 | } | |
| 50 | ||
| 51 | function beep(audio, hz) { | |
| 52 | const osc = audio.createOscillator(); | |
| 53 | const gain = audio.createGain(); | |
| 54 | osc.type = "square"; | |
| 55 | osc.frequency.value = hz; | |
| 56 | gain.gain.setValueAtTime(0.0001, audio.currentTime); | |
| 57 | gain.gain.exponentialRampToValueAtTime(0.09, audio.currentTime + 0.01); | |
| 58 | gain.gain.exponentialRampToValueAtTime(0.0001, audio.currentTime + 0.09); | |
| 59 | osc.connect(gain).connect(audio.destination); | |
| 60 | osc.start(); | |
| 61 | osc.stop(audio.currentTime + 0.1); | |
| 62 | } |
static/gun.js added +101
| @@ -0,0 +1,101 @@ | ||
| 1 | /** | |
| 2 | * The gun in your hands: a crosshair, a shape at the bottom of the screen | |
| 3 | * that kicks when you fire, and a marker when a shot of yours lands. | |
| 4 | * | |
| 5 | * The client never decides whether it hit anything — it reads that off the | |
| 6 | * shots the server sent, the same ones the top-down view draws. | |
| 7 | */ | |
| 8 | ||
| 9 | export const makeGun = () => ({ kick: 0, mark: 0, ammo: null }); | |
| 10 | ||
| 11 | /** Recoil and the hit marker both fade the same way: fast, and to zero. */ | |
| 12 | export const fade = (value, dt, life) => Math.max(0, value - dt / life); | |
| 13 | ||
| 14 | /** A shot happened when the magazine went down, never when it was reloaded. */ | |
| 15 | export const firedNow = (before, ammo, reloading) => | |
| 16 | before !== null && !reloading && ammo < before; | |
| 17 | ||
| 18 | /** One of my shots landed: it started where I am standing and the server says it hit. */ | |
| 19 | export function hitMarked(shots, me) { | |
| 20 | for (const shot of shots) { | |
| 21 | if (!shot.hit) continue; | |
| 22 | if (Math.hypot(shot.x1 - me.x, shot.y1 - me.y) < 30) return true; | |
| 23 | } | |
| 24 | return false; | |
| 25 | } | |
| 26 | ||
| 27 | /** Fold a fresh snapshot into the gun's state: kick on a shot, mark on a hit. */ | |
| 28 | export function gunSnapshot(gun, snap) { | |
| 29 | if (firedNow(gun.ammo, snap.me.ammo, snap.me.reloading)) gun.kick = 1; | |
| 30 | if (snap.me.alive && hitMarked(snap.shots, snap.me)) gun.mark = 1; | |
| 31 | gun.ammo = snap.me.ammo; | |
| 32 | } | |
| 33 | ||
| 34 | export function gunTick(gun, dt) { | |
| 35 | gun.kick = fade(gun.kick, dt, 0.12); | |
| 36 | gun.mark = fade(gun.mark, dt, 0.35); | |
| 37 | } | |
| 38 | ||
| 39 | /** The crosshair, the weapon and the marker, in that order of importance. */ | |
| 40 | export function drawGun(ctx, gun, width, height, snap) { | |
| 41 | const cx = width / 2; | |
| 42 | const cy = height / 2; | |
| 43 | const gap = 6 + gun.kick * 10; | |
| 44 | const arm = 9; | |
| 45 | ctx.lineWidth = 2; | |
| 46 | ctx.strokeStyle = "rgba(20,24,30,0.8)"; | |
| 47 | ctx.beginPath(); | |
| 48 | for (const [dx, dy] of [ | |
| 49 | [1, 0], | |
| 50 | [-1, 0], | |
| 51 | [0, 1], | |
| 52 | [0, -1], | |
| 53 | ]) { | |
| 54 | ctx.moveTo(cx + dx * gap, cy + dy * gap); | |
| 55 | ctx.lineTo(cx + dx * (gap + arm), cy + dy * (gap + arm)); | |
| 56 | } | |
| 57 | ctx.stroke(); | |
| 58 | ctx.strokeStyle = snap.me.alive ? "#8ef0c4" : "#888"; | |
| 59 | ctx.lineWidth = 1.4; | |
| 60 | ctx.stroke(); | |
| 61 | ||
| 62 | if (gun.mark > 0) { | |
| 63 | ctx.strokeStyle = `rgba(255,120,110,${gun.mark})`; | |
| 64 | ctx.lineWidth = 2.5; | |
| 65 | ctx.beginPath(); | |
| 66 | for (const [dx, dy] of [ | |
| 67 | [1, 1], | |
| 68 | [-1, -1], | |
| 69 | [1, -1], | |
| 70 | [-1, 1], | |
| 71 | ]) { | |
| 72 | ctx.moveTo(cx + dx * 7, cy + dy * 7); | |
| 73 | ctx.lineTo(cx + dx * 15, cy + dy * 15); | |
| 74 | } | |
| 75 | ctx.stroke(); | |
| 76 | } | |
| 77 | ||
| 78 | if (!snap.me.alive) return; | |
| 79 | // The weapon: a barrel and a body, off to the right, rising with the kick. | |
| 80 | const scale = height / 570; | |
| 81 | const lift = gun.kick * 22 * scale + (snap.me.reloading ? 40 * scale : 0); | |
| 82 | const bx = width * 0.66; | |
| 83 | const by = height + lift; | |
| 84 | ctx.fillStyle = "#23262e"; | |
| 85 | ctx.beginPath(); | |
| 86 | ctx.moveTo(bx - 34 * scale, by); | |
| 87 | ctx.lineTo(bx - 20 * scale, by - 130 * scale); | |
| 88 | ctx.lineTo(bx + 16 * scale, by - 150 * scale); | |
| 89 | ctx.lineTo(bx + 30 * scale, by - 40 * scale); | |
| 90 | ctx.lineTo(bx + 60 * scale, by); | |
| 91 | ctx.closePath(); | |
| 92 | ctx.fill(); | |
| 93 | ctx.fillStyle = "#3b414e"; | |
| 94 | ctx.fillRect(bx - 8 * scale, by - 160 * scale, 22 * scale, 26 * scale); | |
| 95 | if (gun.kick > 0.35) { | |
| 96 | ctx.fillStyle = `rgba(255,220,140,${gun.kick})`; | |
| 97 | ctx.beginPath(); | |
| 98 | ctx.arc(bx + 3 * scale, by - 162 * scale, 18 * scale * gun.kick, 0, 7); | |
| 99 | ctx.fill(); | |
| 100 | } | |
| 101 | } |
static/prompt.js added +50
| @@ -0,0 +1,50 @@ | ||
| 1 | /** | |
| 2 | * One line of advice, in the player's own words. | |
| 3 | * | |
| 4 | * A pure function of the snapshot the server sent plus two things the client | |
| 5 | * works out from the map it was given: whether you are standing on a site, | |
| 6 | * and whether you are within reach of the spike. Never more than one prompt — | |
| 7 | * the most urgent thing wins, because two instructions at once is none. | |
| 8 | */ | |
| 9 | ||
| 10 | /** Within this many units the server lets you pick up, plant or defuse. */ | |
| 11 | export const REACH = 46; | |
| 12 | ||
| 13 | const secs = (n) => Math.max(0, Math.ceil(n)); | |
| 14 | ||
| 15 | export function choosePrompt(snap, world = {}) { | |
| 16 | if (!snap) return ""; | |
| 17 | const me = snap.me; | |
| 18 | const spike = snap.spike; | |
| 19 | const onSite = world.onSite === true; | |
| 20 | const nearSpike = world.nearSpike === true; | |
| 21 | ||
| 22 | if (snap.phase === "over") return snap.result ? `Match over — ${snap.result}` : "Match over"; | |
| 23 | if (me.blind > 0.05) return "You are blind — back off until it clears"; | |
| 24 | if (!me.alive) | |
| 25 | return snap.phase === "buy" | |
| 26 | ? "Buy phase — you are back next round" | |
| 27 | : "Waiting for the next round — you're in it"; | |
| 28 | if (snap.phase === "end") return snap.result ? `${snap.result} — next round in ${secs(snap.clock)}s` : "Round over"; | |
| 29 | if (snap.phase === "buy") | |
| 30 | return `Buy phase — ${secs(snap.clock)}s left. Click a gun you can afford.`; | |
| 31 | ||
| 32 | if (me.planting > 0) return `Planting — hold F for ${secs(4 - me.planting)}s more`; | |
| 33 | if (spike.planted) { | |
| 34 | if (me.side === "def") { | |
| 35 | if (spike.defuse > 0) return `Defusing — keep holding F, ${secs(7 - spike.defuse)}s`; | |
| 36 | return nearSpike | |
| 37 | ? `Spike down, ${secs(spike.fuse)}s — hold F to defuse` | |
| 38 | : `Spike down, ${secs(spike.fuse)}s — get to it and hold F`; | |
| 39 | } | |
| 40 | return `Spike down, ${secs(spike.fuse)}s — keep them off it`; | |
| 41 | } | |
| 42 | ||
| 43 | if (me.side === "atk") { | |
| 44 | if (me.spike) return onSite ? "Hold F to plant" : "You have the spike — get to site A or B"; | |
| 45 | if (spike.carried) return "Your team has the spike — go with them and take a site"; | |
| 46 | if (nearSpike) return "Walk onto the spike to pick it up"; | |
| 47 | return "Somebody has to carry the spike — it is on the ground"; | |
| 48 | } | |
| 49 | return `Defending — hold A and B, ${secs(snap.clock)}s`; | |
| 50 | } |
static/raycast.js added +170
| @@ -0,0 +1,170 @@ | ||
| 1 | /** | |
| 2 | * The ray maths. Pure numbers in, pure numbers out: no canvas, no DOM, no | |
| 3 | * state — so every decision the first-person view makes can be tested | |
| 4 | * without a browser. | |
| 5 | * | |
| 6 | * The camera is a flat projection plane at `projection()` pixels from the | |
| 7 | * eye. A column maps to an angle through that plane, not by sweeping the | |
| 8 | * field of view evenly, which is what keeps straight walls straight. Depth | |
| 9 | * is always the perpendicular distance to the plane: the fish-eye correction | |
| 10 | * and the sprite z-clip both depend on the two being the same measure. | |
| 11 | * | |
| 12 | * Nothing here allocates. The DDA writes its answer into a result object the | |
| 13 | * caller owns, so a 900-column frame allocates nothing at all. | |
| 14 | */ | |
| 15 | ||
| 16 | export const FOV = (75 * Math.PI) / 180; | |
| 17 | ||
| 18 | /** Pixels from the eye to the projection plane for a screen this wide. */ | |
| 19 | export const projection = (width, fov = FOV) => width / 2 / Math.tan(fov / 2); | |
| 20 | ||
| 21 | /** The angle of the ray through screen column `col`, relative to the view. */ | |
| 22 | export function columnOffset(col, width, fov = FOV) { | |
| 23 | const cameraX = (2 * (col + 0.5)) / width - 1; | |
| 24 | return Math.atan(cameraX * Math.tan(fov / 2)); | |
| 25 | } | |
| 26 | ||
| 27 | /** The world angle of the ray through screen column `col`. */ | |
| 28 | export const columnAngle = (col, width, view, fov = FOV) => view + columnOffset(col, width, fov); | |
| 29 | ||
| 30 | /** The screen column a world angle falls on, fractional, off-screen allowed. */ | |
| 31 | export function angleColumn(offset, width, fov = FOV) { | |
| 32 | return (width / 2) * (1 + Math.tan(offset) / Math.tan(fov / 2)); | |
| 33 | } | |
| 34 | ||
| 35 | /** A wall `tall` units high at this depth covers this many pixels. */ | |
| 36 | export const sliceHeight = (perp, proj, tall) => (tall * proj) / Math.max(perp, 1e-6); | |
| 37 | ||
| 38 | /** A fresh result object for `castRay` to fill. One per renderer, not per ray. */ | |
| 39 | export const hitResult = () => ({ dist: 0, side: 0, col: 0, row: 0, wall: false }); | |
| 40 | ||
| 41 | /** | |
| 42 | * March a ray through the tile grid until it meets a solid tile. | |
| 43 | * | |
| 44 | * `solid(col, row)` answers for the grid; `out` is filled with the distance | |
| 45 | * travelled, which side of the tile was struck (0 = an east or west face, | |
| 46 | * 1 = a north or south face) and the tile itself. A ray that runs out of | |
| 47 | * range reports `wall: false` at `max`. | |
| 48 | */ | |
| 49 | export function castRay(solid, x, y, angle, tile, max, out) { | |
| 50 | const dx = Math.cos(angle); | |
| 51 | const dy = Math.sin(angle); | |
| 52 | let col = Math.floor(x / tile); | |
| 53 | let row = Math.floor(y / tile); | |
| 54 | const deltaX = dx === 0 ? Infinity : Math.abs(tile / dx); | |
| 55 | const deltaY = dy === 0 ? Infinity : Math.abs(tile / dy); | |
| 56 | const stepX = dx < 0 ? -1 : 1; | |
| 57 | const stepY = dy < 0 ? -1 : 1; | |
| 58 | let sideX = dx === 0 ? Infinity : dx < 0 ? (x - col * tile) / -dx : ((col + 1) * tile - x) / dx; | |
| 59 | let sideY = dy === 0 ? Infinity : dy < 0 ? (y - row * tile) / -dy : ((row + 1) * tile - y) / dy; | |
| 60 | ||
| 61 | if (solid(col, row)) { | |
| 62 | out.dist = 0; | |
| 63 | out.side = 0; | |
| 64 | out.col = col; | |
| 65 | out.row = row; | |
| 66 | out.wall = true; | |
| 67 | return out; | |
| 68 | } | |
| 69 | for (;;) { | |
| 70 | let dist; | |
| 71 | let side; | |
| 72 | if (sideX < sideY) { | |
| 73 | dist = sideX; | |
| 74 | col += stepX; | |
| 75 | sideX += deltaX; | |
| 76 | side = 0; | |
| 77 | } else { | |
| 78 | dist = sideY; | |
| 79 | row += stepY; | |
| 80 | sideY += deltaY; | |
| 81 | side = 1; | |
| 82 | } | |
| 83 | if (dist > max) { | |
| 84 | out.dist = max; | |
| 85 | out.side = side; | |
| 86 | out.col = col; | |
| 87 | out.row = row; | |
| 88 | out.wall = false; | |
| 89 | return out; | |
| 90 | } | |
| 91 | if (solid(col, row)) { | |
| 92 | out.dist = dist; | |
| 93 | out.side = side; | |
| 94 | out.col = col; | |
| 95 | out.row = row; | |
| 96 | out.wall = true; | |
| 97 | return out; | |
| 98 | } | |
| 99 | } | |
| 100 | } | |
| 101 | ||
| 102 | /** | |
| 103 | * How lit a wall slice is: one over the distance, floored so a far wall is | |
| 104 | * dim rather than black, and a north–south face darker than an east–west one | |
| 105 | * so a corner reads as a corner. | |
| 106 | */ | |
| 107 | export function wallLight(perp, side, tile) { | |
| 108 | const far = 1 / (1 + (perp / (tile * 7)) ** 2); | |
| 109 | const face = side === 1 ? 0.62 : 1; | |
| 110 | return Math.max(0.12, Math.min(1, 0.25 + 0.75 * far)) * face; | |
| 111 | } | |
| 112 | ||
| 113 | /** A floor or ceiling band dims the same way, by how far down the screen it is. */ | |
| 114 | export const bandLight = (rowFromHorizon, halfHeight) => | |
| 115 | Math.max(0.1, Math.min(1, rowFromHorizon / Math.max(1, halfHeight))); | |
| 116 | ||
| 117 | /** | |
| 118 | * Where a thing in the world lands on the screen. | |
| 119 | * | |
| 120 | * Returns the fractional column of its centre, its perpendicular depth and | |
| 121 | * the pixel height of something `tall` units high there. `behind` is true | |
| 122 | * when it is at or past the camera plane and must not be drawn at all. | |
| 123 | */ | |
| 124 | export function project(px, py, view, sx, sy, width, proj, tall, out, fov = FOV) { | |
| 125 | const dx = sx - px; | |
| 126 | const dy = sy - py; | |
| 127 | const dist = Math.hypot(dx, dy); | |
| 128 | let offset = Math.atan2(dy, dx) - view; | |
| 129 | while (offset > Math.PI) offset -= Math.PI * 2; | |
| 130 | while (offset < -Math.PI) offset += Math.PI * 2; | |
| 131 | const perp = dist * Math.cos(offset); | |
| 132 | out.dist = dist; | |
| 133 | out.offset = offset; | |
| 134 | out.perp = perp; | |
| 135 | out.behind = perp <= 1 || Math.abs(offset) > fov / 2 + 0.8; | |
| 136 | out.column = out.behind ? -1 : angleColumn(offset, width, fov); | |
| 137 | out.size = out.behind ? 0 : sliceHeight(perp, proj, tall); | |
| 138 | return out; | |
| 139 | } | |
| 140 | ||
| 141 | export const projectResult = () => ({ dist: 0, offset: 0, perp: 0, behind: true, column: -1, size: 0 }); | |
| 142 | ||
| 143 | /** | |
| 144 | * Is a sprite column in front of the wall drawn there? A sprite slightly | |
| 145 | * nearer than the wall still counts as hidden by a hair of slack, so a body | |
| 146 | * flush against a corner does not bleed through it. | |
| 147 | */ | |
| 148 | export const spriteColumnVisible = (spritePerp, wallPerp) => spritePerp < wallPerp - 2; | |
| 149 | ||
| 150 | /** | |
| 151 | * How much smoke a ray crosses between the eye and `dist`. | |
| 152 | * | |
| 153 | * The chord of the circle that falls inside the segment, as a fraction of the | |
| 154 | * circle's width: a ray that clips the edge of a smoke barely fogs, one that | |
| 155 | * goes through the middle fogs completely. That is what makes a smoke read as | |
| 156 | * a volume you are standing in rather than a disc painted on the floor. | |
| 157 | */ | |
| 158 | export function smokeAlong(x, y, dx, dy, cx, cy, r, dist) { | |
| 159 | const ox = x - cx; | |
| 160 | const oy = y - cy; | |
| 161 | const b = ox * dx + oy * dy; | |
| 162 | const c = ox * ox + oy * oy - r * r; | |
| 163 | const disc = b * b - c; | |
| 164 | if (disc <= 0) return 0; | |
| 165 | const root = Math.sqrt(disc); | |
| 166 | const enter = Math.max(0, -b - root); | |
| 167 | const leave = Math.min(dist, -b + root); | |
| 168 | if (leave <= enter) return 0; | |
| 169 | return Math.min(1, (leave - enter) / (r * 1.1)); | |
| 170 | } |
static/scene.js added +344
| @@ -0,0 +1,344 @@ | ||
| 1 | /** | |
| 2 | * The world, drawn from inside the player's head. | |
| 3 | * | |
| 4 | * One pass of rays across the screen builds the walls and a depth buffer; | |
| 5 | * everything else — bodies, the spike, ability zones, tracers — is a | |
| 6 | * billboard tested against that buffer, so a body behind a corner is not | |
| 7 | * drawn at all. Smoke is measured along each ray, not painted as a disc: | |
| 8 | * cross one and everything past it fogs. | |
| 9 | * | |
| 10 | * The ray loop allocates nothing. Colours come out of a table built once, | |
| 11 | * the depth and fog buffers are kept between frames, and the sprite list is | |
| 12 | * sorted in a buffer the scene owns. | |
| 13 | */ | |
| 14 | ||
| 15 | import { | |
| 16 | castRay, | |
| 17 | columnOffset, | |
| 18 | hitResult, | |
| 19 | project, | |
| 20 | projection, | |
| 21 | projectResult, | |
| 22 | sliceHeight, | |
| 23 | smokeAlong, | |
| 24 | spriteColumnVisible, | |
| 25 | wallLight, | |
| 26 | } from "./raycast.js"; | |
| 27 | ||
| 28 | const EYE = 40; // half a tile up: the horizon sits on the middle of the wall | |
| 29 | const WALL = 80; // walls are one tile tall | |
| 30 | const BODY = 58; // a person, in world units | |
| 31 | const MAX_SIGHT = 1800; | |
| 32 | const LEVELS = 48; | |
| 33 | ||
| 34 | export const AGENT_COLOUR = { | |
| 35 | ember: "#f08a4b", | |
| 36 | veil: "#8f7fe8", | |
| 37 | sable: "#4fd6a0", | |
| 38 | mender: "#e0c060", | |
| 39 | rook: "#d96a8f", | |
| 40 | }; | |
| 41 | ||
| 42 | // Wall colours, quantised into levels and built once, so the ray loop never | |
| 43 | // builds a string. How dark a slice is comes from `wallLight`, which already | |
| 44 | // knows about distance and which face was struck. | |
| 45 | const SHADES = (() => { | |
| 46 | const out = []; | |
| 47 | for (let i = 0; i < LEVELS; i++) { | |
| 48 | const k = (i + 0.5) / LEVELS; | |
| 49 | out.push(`rgb(${Math.round(150 * k)},${Math.round(158 * k)},${Math.round(178 * k)})`); | |
| 50 | } | |
| 51 | return out; | |
| 52 | })(); | |
| 53 | const FOG = "rgb(198,203,212)"; | |
| 54 | ||
| 55 | export function makeScene() { | |
| 56 | return { | |
| 57 | depth: new Float32Array(0), | |
| 58 | fog: new Float32Array(0), | |
| 59 | hit: hitResult(), | |
| 60 | proj: projectResult(), | |
| 61 | order: [], | |
| 62 | smokes: [], | |
| 63 | sky: null, | |
| 64 | skyKey: "", | |
| 65 | }; | |
| 66 | } | |
| 67 | ||
| 68 | const shadeOf = (light) => SHADES[Math.min(LEVELS - 1, Math.max(0, (light * LEVELS) | 0))]; | |
| 69 | ||
| 70 | /** | |
| 71 | * Draw one frame. | |
| 72 | * | |
| 73 | * `world` carries the map (`solid`, `tile`), `eye` is where the camera | |
| 74 | * stands and looks, `snap` is the last snapshot the server sent, and `view` | |
| 75 | * is the frame: its pixel size, and `look(player)` for the smoothed position | |
| 76 | * to draw somebody at. | |
| 77 | */ | |
| 78 | export function drawScene(ctx, scene, world, eye, snap, view) { | |
| 79 | const W = view.width; | |
| 80 | const H = view.height; | |
| 81 | const step = W > 1100 ? 2 : 1; | |
| 82 | const columns = Math.ceil(W / step); | |
| 83 | if (scene.depth.length !== columns) { | |
| 84 | scene.depth = new Float32Array(columns); | |
| 85 | scene.fog = new Float32Array(columns); | |
| 86 | } | |
| 87 | const proj = projection(W); | |
| 88 | const horizon = H / 2; | |
| 89 | ||
| 90 | drawSky(ctx, scene, W, H, horizon); | |
| 91 | ||
| 92 | const smokes = scene.smokes; | |
| 93 | smokes.length = 0; | |
| 94 | for (const zone of snap.zones) if ((zone.k === "smoke" || zone.k === "wall") && zone.f <= 0) smokes.push(zone); | |
| 95 | ||
| 96 | const depth = scene.depth; | |
| 97 | const fog = scene.fog; | |
| 98 | const hit = scene.hit; | |
| 99 | for (let i = 0; i < columns; i++) { | |
| 100 | const offset = columnOffset(i * step + step / 2, W); | |
| 101 | const angle = eye.angle + offset; | |
| 102 | castRay(world.solid, eye.x, eye.y, angle, world.tile, MAX_SIGHT, hit); | |
| 103 | const perp = Math.max(1, hit.dist * Math.cos(offset)); | |
| 104 | depth[i] = perp; | |
| 105 | let haze = 0; | |
| 106 | if (smokes.length) { | |
| 107 | const dx = Math.cos(angle); | |
| 108 | const dy = Math.sin(angle); | |
| 109 | for (let s = 0; s < smokes.length; s++) { | |
| 110 | const z = smokes[s]; | |
| 111 | haze += smokeAlong(eye.x, eye.y, dx, dy, z.x, z.y, z.r, hit.dist); | |
| 112 | if (haze >= 1) break; | |
| 113 | } | |
| 114 | haze = Math.min(1, haze); | |
| 115 | } | |
| 116 | fog[i] = haze; | |
| 117 | if (!hit.wall) continue; | |
| 118 | const h = Math.min(H * 6, sliceHeight(perp, proj, WALL)); | |
| 119 | const top = horizon - h / 2; | |
| 120 | ctx.fillStyle = shadeOf(wallLight(perp, hit.side, world.tile)); | |
| 121 | ctx.fillRect(i * step, top, step + 1, h); | |
| 122 | if (haze > 0.01) { | |
| 123 | ctx.globalAlpha = haze; | |
| 124 | ctx.fillStyle = FOG; | |
| 125 | ctx.fillRect(i * step, top, step + 1, h); | |
| 126 | ctx.globalAlpha = 1; | |
| 127 | } | |
| 128 | } | |
| 129 | ||
| 130 | drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, columns); | |
| 131 | } | |
| 132 | ||
| 133 | /** The two bands: a dark ceiling above the horizon, a warmer floor below. */ | |
| 134 | function drawSky(ctx, scene, W, H, horizon) { | |
| 135 | const key = `${W}x${H}`; | |
| 136 | if (scene.skyKey !== key || !scene.sky) { | |
| 137 | const sky = ctx.createLinearGradient(0, 0, 0, H); | |
| 138 | sky.addColorStop(0, "#0a0c11"); | |
| 139 | sky.addColorStop(0.49, "#23262f"); | |
| 140 | sky.addColorStop(0.5, "#3a3026"); | |
| 141 | sky.addColorStop(1, "#15120e"); | |
| 142 | scene.sky = sky; | |
| 143 | scene.skyKey = key; | |
| 144 | } | |
| 145 | ctx.fillStyle = scene.sky; | |
| 146 | ctx.fillRect(0, 0, W, H); | |
| 147 | ctx.fillStyle = "rgba(255,255,255,0.09)"; | |
| 148 | ctx.fillRect(0, horizon - 1, W, 2); | |
| 149 | } | |
| 150 | ||
| 151 | /** | |
| 152 | * Everything that is not a wall, far to near, each one clipped to the screen | |
| 153 | * columns where it beats the wall behind it. | |
| 154 | */ | |
| 155 | function drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, columns) { | |
| 156 | const W = view.width; | |
| 157 | const things = scene.order; | |
| 158 | things.length = 0; | |
| 159 | ||
| 160 | for (const zone of snap.zones) { | |
| 161 | if (zone.k === "smoke") continue; | |
| 162 | things.push({ kind: "zone", zone, x: zone.x, y: zone.y }); | |
| 163 | } | |
| 164 | for (const orb of snap.orbs) things.push({ kind: "orb", x: orb.x, y: orb.y }); | |
| 165 | if (snap.spike.x !== null && !snap.spike.carried) | |
| 166 | things.push({ kind: "spike", x: snap.spike.x, y: snap.spike.y }); | |
| 167 | for (const player of snap.players) { | |
| 168 | if (player.id === snap.me.id) continue; | |
| 169 | const at = view.look(player); | |
| 170 | things.push({ kind: "body", player, x: at.x, y: at.y, angle: at.angle }); | |
| 171 | } | |
| 172 | ||
| 173 | const p = scene.proj; | |
| 174 | for (const thing of things) { | |
| 175 | project(eye.x, eye.y, eye.angle, thing.x, thing.y, W, proj, BODY, p); | |
| 176 | thing.perp = p.perp; | |
| 177 | thing.column = p.column; | |
| 178 | thing.size = p.size; | |
| 179 | thing.behind = p.behind; | |
| 180 | } | |
| 181 | things.sort((a, b) => b.perp - a.perp); | |
| 182 | ||
| 183 | for (const thing of things) { | |
| 184 | if (thing.behind) continue; | |
| 185 | const floorY = horizon + (EYE * proj) / thing.perp; | |
| 186 | const haze = hazeAt(scene, thing.column, step, columns); | |
| 187 | ctx.globalAlpha = Math.max(0, 1 - haze); | |
| 188 | if (ctx.globalAlpha < 0.05) continue; | |
| 189 | if (thing.kind === "body") drawBody(ctx, scene, thing, floorY, step, columns); | |
| 190 | else if (thing.kind === "spike") drawSpike(ctx, scene, thing, floorY, snap, step, columns); | |
| 191 | else if (thing.kind === "orb") drawBlob(ctx, scene, thing, floorY, "#c9a0ff", 0.35, step, columns); | |
| 192 | else drawZone(ctx, scene, thing, floorY, proj, step, columns); | |
| 193 | ctx.globalAlpha = 1; | |
| 194 | } | |
| 195 | ctx.globalAlpha = 1; | |
| 196 | ||
| 197 | drawTracers(ctx, scene, eye, snap, W, proj, horizon); | |
| 198 | } | |
| 199 | ||
| 200 | const hazeAt = (scene, column, step, columns) => { | |
| 201 | const i = Math.max(0, Math.min(columns - 1, Math.round(column / step))); | |
| 202 | return scene.fog[i] ?? 0; | |
| 203 | }; | |
| 204 | ||
| 205 | /** | |
| 206 | * The visible runs of a sprite: the stretches of screen column where it is | |
| 207 | * nearer than the wall. Two rectangles at most in practice, and the caller | |
| 208 | * clips its drawing to each. | |
| 209 | */ | |
| 210 | function withRuns(ctx, scene, left, right, perp, step, columns, draw) { | |
| 211 | let runStart = -1; | |
| 212 | const from = Math.max(0, Math.floor(left / step)); | |
| 213 | const to = Math.min(columns - 1, Math.ceil(right / step)); | |
| 214 | for (let i = from; i <= to + 1; i++) { | |
| 215 | const shown = i <= to && spriteColumnVisible(perp, scene.depth[i]); | |
| 216 | if (shown && runStart < 0) runStart = i; | |
| 217 | if (!shown && runStart >= 0) { | |
| 218 | ctx.save(); | |
| 219 | ctx.beginPath(); | |
| 220 | ctx.rect(runStart * step - 0.5, 0, (i - runStart) * step + 1, 1e5); | |
| 221 | ctx.clip(); | |
| 222 | draw(); | |
| 223 | ctx.restore(); | |
| 224 | runStart = -1; | |
| 225 | } | |
| 226 | } | |
| 227 | } | |
| 228 | ||
| 229 | function drawBody(ctx, scene, thing, floorY, step, columns) { | |
| 230 | const p = thing.player; | |
| 231 | const h = thing.size; | |
| 232 | const w = h * 0.46; | |
| 233 | const cx = thing.column; | |
| 234 | const top = floorY - h; | |
| 235 | const mate = p.mate; | |
| 236 | const skin = !p.alive ? "#555a63" : mate ? "#3f9fd6" : "#d8483f"; | |
| 237 | const trim = AGENT_COLOUR[p.agent] || "#999"; | |
| 238 | ||
| 239 | withRuns(ctx, scene, cx - w, cx + w * 1.4, thing.perp, step, columns, () => { | |
| 240 | // Body, head, and a shoulder band in the agent's colour. | |
| 241 | ctx.fillStyle = skin; | |
| 242 | ctx.fillRect(cx - w / 2, top + h * 0.28, w, h * 0.72); | |
| 243 | ctx.fillStyle = trim; | |
| 244 | ctx.fillRect(cx - w / 2, top + h * 0.34, w, h * 0.1); | |
| 245 | ctx.beginPath(); | |
| 246 | ctx.arc(cx, top + h * 0.17, h * 0.15, 0, 7); | |
| 247 | ctx.fillStyle = skin; | |
| 248 | ctx.fill(); | |
| 249 | ctx.lineWidth = Math.max(1, h * 0.03); | |
| 250 | ctx.strokeStyle = trim; | |
| 251 | ctx.stroke(); | |
| 252 | if (!p.alive) return; | |
| 253 | if (p.spike) { | |
| 254 | ctx.fillStyle = "#f0d060"; | |
| 255 | ctx.fillRect(cx + w * 0.5, top + h * 0.4, w * 0.3, h * 0.3); | |
| 256 | } | |
| 257 | if (p.hp < 100) { | |
| 258 | ctx.fillStyle = "#000a"; | |
| 259 | ctx.fillRect(cx - w / 2, top - h * 0.1, w, h * 0.06); | |
| 260 | ctx.fillStyle = mate ? "#6fcf6f" : "#e8b04a"; | |
| 261 | ctx.fillRect(cx - w / 2, top - h * 0.1, w * (p.hp / 100), h * 0.06); | |
| 262 | } | |
| 263 | // A name tag over team mates only: an enemy is a shape, never a label. | |
| 264 | if (mate) { | |
| 265 | const size = Math.max(9, Math.min(18, h * 0.16)); | |
| 266 | ctx.font = `${size}px system-ui, sans-serif`; | |
| 267 | ctx.textAlign = "center"; | |
| 268 | ctx.fillStyle = "rgba(10,12,16,0.65)"; | |
| 269 | const tw = ctx.measureText(p.name).width + 8; | |
| 270 | ctx.fillRect(cx - tw / 2, top - h * 0.34, tw, size * 1.4); | |
| 271 | ctx.fillStyle = "#cfe8ff"; | |
| 272 | ctx.fillText(p.name, cx, top - h * 0.34 + size * 1.05); | |
| 273 | ctx.textAlign = "left"; | |
| 274 | } | |
| 275 | }); | |
| 276 | } | |
| 277 | ||
| 278 | function drawSpike(ctx, scene, thing, floorY, snap, step, columns) { | |
| 279 | const h = thing.size * 0.42; | |
| 280 | const cx = thing.column; | |
| 281 | const planted = snap.spike.planted; | |
| 282 | const blink = planted && Math.floor(Date.now() / 220) % 2 === 0; | |
| 283 | withRuns(ctx, scene, cx - h, cx + h, thing.perp, step, columns, () => { | |
| 284 | ctx.fillStyle = planted ? (blink ? "#ff5b4a" : "#7d2a22") : "#c8b45c"; | |
| 285 | ctx.fillRect(cx - h * 0.28, floorY - h, h * 0.56, h); | |
| 286 | ctx.fillStyle = planted && blink ? "#ffd7a0" : "#2a2418"; | |
| 287 | ctx.fillRect(cx - h * 0.14, floorY - h * 0.85, h * 0.28, h * 0.22); | |
| 288 | }); | |
| 289 | } | |
| 290 | ||
| 291 | function drawBlob(ctx, scene, thing, floorY, colour, scale, step, columns) { | |
| 292 | const r = thing.size * scale; | |
| 293 | const cx = thing.column; | |
| 294 | withRuns(ctx, scene, cx - r, cx + r, thing.perp, step, columns, () => { | |
| 295 | ctx.fillStyle = colour; | |
| 296 | ctx.beginPath(); | |
| 297 | ctx.arc(cx, floorY - r, r, 0, 7); | |
| 298 | ctx.fill(); | |
| 299 | }); | |
| 300 | } | |
| 301 | ||
| 302 | function drawZone(ctx, scene, thing, floorY, proj, step, columns) { | |
| 303 | const z = thing.zone; | |
| 304 | const wide = (z.r * proj) / thing.perp; | |
| 305 | const cx = thing.column; | |
| 306 | if (z.k === "wall") { | |
| 307 | const h = (WALL * proj) / thing.perp; | |
| 308 | withRuns(ctx, scene, cx - wide, cx + wide, thing.perp, step, columns, () => { | |
| 309 | ctx.fillStyle = "rgba(120,200,255,0.45)"; | |
| 310 | ctx.fillRect(cx - wide, floorY - h, wide * 2, h); | |
| 311 | ctx.strokeStyle = "rgba(180,225,255,0.8)"; | |
| 312 | ctx.strokeRect(cx - wide, floorY - h, wide * 2, h); | |
| 313 | }); | |
| 314 | return; | |
| 315 | } | |
| 316 | const colour = | |
| 317 | z.k === "molly" ? "rgba(235,120,50,0.6)" : z.k === "flash" ? "rgba(255,235,170,0.9)" : z.k === "heal" ? "rgba(120,230,140,0.55)" : z.k === "trip" ? "rgba(230,200,90,0.75)" : "rgba(90,220,170,0.5)"; | |
| 318 | withRuns(ctx, scene, cx - wide, cx + wide, thing.perp, step, columns, () => { | |
| 319 | ctx.fillStyle = colour; | |
| 320 | ctx.beginPath(); | |
| 321 | ctx.ellipse(cx, floorY, wide, Math.max(2, wide * 0.3), 0, 0, 7); | |
| 322 | ctx.fill(); | |
| 323 | }); | |
| 324 | } | |
| 325 | ||
| 326 | /** Shots the server let us see, as a line through the world. */ | |
| 327 | function drawTracers(ctx, scene, eye, snap, W, proj, horizon) { | |
| 328 | if (!snap.shots.length) return; | |
| 329 | const p = scene.proj; | |
| 330 | ctx.lineWidth = 2; | |
| 331 | for (const shot of snap.shots) { | |
| 332 | project(eye.x, eye.y, eye.angle, shot.x1, shot.y1, W, proj, BODY, p); | |
| 333 | if (p.behind) continue; | |
| 334 | const x1 = p.column; | |
| 335 | const y1 = horizon + (EYE * proj) / p.perp - p.size * 0.6; | |
| 336 | project(eye.x, eye.y, eye.angle, shot.x2, shot.y2, W, proj, BODY, p); | |
| 337 | if (p.behind) continue; | |
| 338 | ctx.strokeStyle = shot.hit ? "rgba(255,140,120,0.5)" : "rgba(255,240,190,0.35)"; | |
| 339 | ctx.beginPath(); | |
| 340 | ctx.moveTo(x1, y1); | |
| 341 | ctx.lineTo(p.column, horizon + (EYE * proj) / p.perp - p.size * 0.6); | |
| 342 | ctx.stroke(); | |
| 343 | } | |
| 344 | } |
static/steer.js added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | /** | |
| 2 | * Keys to a direction on the map. | |
| 3 | * | |
| 4 | * The server has always been told a world-space direction, and that contract | |
| 5 | * does not change: from inside the player's head W means "the way I am | |
| 6 | * looking", so the keys are turned by the view angle before they are sent. | |
| 7 | * In the top-down view the keys are the compass they always were. | |
| 8 | */ | |
| 9 | ||
| 10 | /** | |
| 11 | * `ax` is right on the keyboard (+1 for D), `ay` is down the screen (+1 for | |
| 12 | * S, so forward is -1). `out` is filled and returned: no allocation. | |
| 13 | */ | |
| 14 | export function moveVector(ax, ay, yaw, out = { x: 0, y: 0 }) { | |
| 15 | const cos = Math.cos(yaw); | |
| 16 | const sin = Math.sin(yaw); | |
| 17 | out.x = cos * -ay - sin * ax; | |
| 18 | out.y = sin * -ay + cos * ax; | |
| 19 | return out; | |
| 20 | } |
static/topdown.js added +184
| @@ -0,0 +1,184 @@ | ||
| 1 | /** | |
| 2 | * The board from above — the drawing this game started as. | |
| 3 | * | |
| 4 | * It is three things now: the minimap in the corner, the full-screen map Tab | |
| 5 | * holds open, and the view a phone plays in, where a raycaster with touch | |
| 6 | * look would be miserable. One function, drawn into whatever box it is given. | |
| 7 | */ | |
| 8 | ||
| 9 | import { AGENT_COLOUR } from "./scene.js"; | |
| 10 | ||
| 11 | const TILE_COLOUR = { | |
| 12 | "#": "#20242d", | |
| 13 | A: "#2c2a22", | |
| 14 | B: "#2c2a22", | |
| 15 | a: "#2a2024", | |
| 16 | d: "#1e2630", | |
| 17 | ".": "#171a21", | |
| 18 | }; | |
| 19 | ||
| 20 | /** | |
| 21 | * Draw the whole map into `box` (canvas pixels). `look(player)` gives the | |
| 22 | * smoothed position; `opts.labels` draws site letters and names, which only | |
| 23 | * fit on the big version. | |
| 24 | */ | |
| 25 | export function drawTopdown(ctx, map, snap, look, box, opts = {}) { | |
| 26 | const cols = map.rows[0].length; | |
| 27 | const s = box.w / (cols * map.tile); | |
| 28 | const labels = opts.labels !== false; | |
| 29 | ctx.save(); | |
| 30 | ctx.beginPath(); | |
| 31 | ctx.rect(box.x, box.y, box.w, box.h); | |
| 32 | ctx.clip(); | |
| 33 | ctx.translate(box.x, box.y); | |
| 34 | ||
| 35 | const t = map.tile * s; | |
| 36 | for (let row = 0; row < map.rows.length; row++) | |
| 37 | for (let col = 0; col < map.rows[row].length; col++) { | |
| 38 | const cell = map.rows[row][col]; | |
| 39 | ctx.fillStyle = TILE_COLOUR[cell] ?? "#171a21"; | |
| 40 | ctx.fillRect(col * t, row * t, t + 0.6, t + 0.6); | |
| 41 | if (labels && (cell === "A" || cell === "B")) { | |
| 42 | ctx.fillStyle = "#6b5f3a"; | |
| 43 | ctx.font = `${Math.round(t * 0.5)}px system-ui, sans-serif`; | |
| 44 | ctx.fillText(cell, col * t + t * 0.3, row * t + t * 0.68); | |
| 45 | } | |
| 46 | } | |
| 47 | ||
| 48 | if (!snap) { | |
| 49 | ctx.restore(); | |
| 50 | return; | |
| 51 | } | |
| 52 | ||
| 53 | for (const orb of snap.orbs) { | |
| 54 | ctx.fillStyle = "#c9a0ff"; | |
| 55 | ctx.beginPath(); | |
| 56 | ctx.arc(orb.x * s, orb.y * s, Math.max(2, 9 * s), 0, 7); | |
| 57 | ctx.fill(); | |
| 58 | } | |
| 59 | for (const zone of snap.zones) if (zone.k !== "smoke") drawZone(ctx, zone, s); | |
| 60 | ||
| 61 | if (snap.spike.x !== null) { | |
| 62 | const x = snap.spike.x * s; | |
| 63 | const y = snap.spike.y * s; | |
| 64 | const r = Math.max(2.5, 7 * s); | |
| 65 | ctx.fillStyle = snap.spike.planted ? (Math.floor(Date.now() / 250) % 2 ? "#ff5b4a" : "#8a2a22") : "#c8b45c"; | |
| 66 | ctx.fillRect(x - r, y - r, r * 2, r * 2); | |
| 67 | if (snap.spike.planted && snap.spike.defuse > 0) { | |
| 68 | ctx.fillStyle = "#4fd6a0"; | |
| 69 | ctx.fillRect(x - 20 * s, y + 12 * s, 40 * s * (snap.spike.defuse / 7), 3); | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | for (const shot of snap.shots) { | |
| 74 | ctx.strokeStyle = shot.hit ? "rgba(255,120,110,0.85)" : "rgba(255,240,180,0.5)"; | |
| 75 | ctx.lineWidth = Math.max(1, 2 * s); | |
| 76 | ctx.beginPath(); | |
| 77 | ctx.moveTo(shot.x1 * s, shot.y1 * s); | |
| 78 | ctx.lineTo(shot.x2 * s, shot.y2 * s); | |
| 79 | ctx.stroke(); | |
| 80 | } | |
| 81 | ||
| 82 | for (const player of snap.players) drawPlayer(ctx, player, look(player), s, snap.me, labels); | |
| 83 | // Smoke last: on this view it hides what is under it, as it should. | |
| 84 | for (const zone of snap.zones) if (zone.k === "smoke" && zone.f <= 0) drawZone(ctx, zone, s); | |
| 85 | ctx.restore(); | |
| 86 | } | |
| 87 | ||
| 88 | function drawZone(ctx, z, s) { | |
| 89 | const x = z.x * s; | |
| 90 | const y = z.y * s; | |
| 91 | const r = z.r * s; | |
| 92 | ctx.lineWidth = Math.max(1, s); | |
| 93 | if (z.k === "smoke") { | |
| 94 | ctx.fillStyle = "rgba(190,195,205,0.72)"; | |
| 95 | disc(ctx, x, y, r, true); | |
| 96 | } else if (z.k === "molly") { | |
| 97 | ctx.fillStyle = "rgba(230,110,50,0.45)"; | |
| 98 | ctx.strokeStyle = "#f08a4b"; | |
| 99 | disc(ctx, x, y, r, true, true); | |
| 100 | } else if (z.k === "wall") { | |
| 101 | ctx.fillStyle = "rgba(120,200,255,0.55)"; | |
| 102 | disc(ctx, x, y, r, true); | |
| 103 | } else if (z.k === "flash") { | |
| 104 | ctx.fillStyle = z.f > 0 ? "#ffe9a8" : "rgba(255,255,255,0.9)"; | |
| 105 | disc(ctx, x, y, z.f > 0 ? 5 * s : 30 * s, true); | |
| 106 | } else if (z.k === "dart") { | |
| 107 | ctx.strokeStyle = "rgba(90,220,170,0.7)"; | |
| 108 | disc(ctx, x, y, r, false, true); | |
| 109 | } else if (z.k === "trip") { | |
| 110 | ctx.strokeStyle = "rgba(230,200,90,0.8)"; | |
| 111 | disc(ctx, x, y, r, false, true); | |
| 112 | } else if (z.k === "heal") { | |
| 113 | ctx.fillStyle = "rgba(120,230,140,0.5)"; | |
| 114 | disc(ctx, x, y, 14 * s, true); | |
| 115 | } | |
| 116 | } | |
| 117 | ||
| 118 | function disc(ctx, x, y, r, fill, stroke) { | |
| 119 | ctx.beginPath(); | |
| 120 | ctx.arc(x, y, r, 0, 7); | |
| 121 | if (fill) ctx.fill(); | |
| 122 | if (stroke) ctx.stroke(); | |
| 123 | } | |
| 124 | ||
| 125 | /** Everybody is a dot; you are an arrow, so you can tell at a glance where you face. */ | |
| 126 | function drawPlayer(ctx, p, pos, s, me, labels) { | |
| 127 | const x = pos.x * s; | |
| 128 | const y = pos.y * s; | |
| 129 | const r = Math.max(2.5, 16 * s); | |
| 130 | const mine = p.id === me.id; | |
| 131 | const body = !p.alive ? "#555a63" : p.mate ? (mine ? "#eaeaea" : "#4fb3d9") : "#e35d5b"; | |
| 132 | ||
| 133 | if (mine) { | |
| 134 | ctx.save(); | |
| 135 | ctx.translate(x, y); | |
| 136 | ctx.rotate(pos.angle); | |
| 137 | ctx.fillStyle = body; | |
| 138 | ctx.beginPath(); | |
| 139 | ctx.moveTo(r * 1.5, 0); | |
| 140 | ctx.lineTo(-r, r * 0.9); | |
| 141 | ctx.lineTo(-r * 0.4, 0); | |
| 142 | ctx.lineTo(-r, -r * 0.9); | |
| 143 | ctx.closePath(); | |
| 144 | ctx.fill(); | |
| 145 | ctx.restore(); | |
| 146 | return; | |
| 147 | } | |
| 148 | ||
| 149 | ctx.fillStyle = body; | |
| 150 | ctx.beginPath(); | |
| 151 | ctx.arc(x, y, r, 0, 7); | |
| 152 | ctx.fill(); | |
| 153 | ctx.strokeStyle = AGENT_COLOUR[p.agent] || "#888"; | |
| 154 | ctx.lineWidth = Math.max(1, 3 * s); | |
| 155 | ctx.stroke(); | |
| 156 | if (p.alive) { | |
| 157 | ctx.strokeStyle = body; | |
| 158 | ctx.beginPath(); | |
| 159 | ctx.moveTo(x, y); | |
| 160 | ctx.lineTo(x + Math.cos(pos.angle) * r * 2, y + Math.sin(pos.angle) * r * 2); | |
| 161 | ctx.stroke(); | |
| 162 | } | |
| 163 | if (p.spike) { | |
| 164 | ctx.fillStyle = "#f0d060"; | |
| 165 | ctx.fillRect(x - r * 0.4, y - r * 1.8, r * 0.8, r * 0.8); | |
| 166 | } | |
| 167 | if (p.blind) { | |
| 168 | ctx.fillStyle = "rgba(255,255,255,0.8)"; | |
| 169 | disc(ctx, x, y - r * 1.6, r * 0.35, true); | |
| 170 | } | |
| 171 | if (labels) { | |
| 172 | ctx.fillStyle = "rgba(235,235,235,0.75)"; | |
| 173 | ctx.font = `${Math.round(11 * Math.max(1, s * 2))}px system-ui, sans-serif`; | |
| 174 | ctx.textAlign = "center"; | |
| 175 | ctx.fillText(p.name, x, y - r * 1.9); | |
| 176 | ctx.textAlign = "left"; | |
| 177 | } | |
| 178 | if (p.alive && p.hp < 100) { | |
| 179 | ctx.fillStyle = "#0008"; | |
| 180 | ctx.fillRect(x - r, y + r * 1.2, r * 2, 3); | |
| 181 | ctx.fillStyle = "#6fcf6f"; | |
| 182 | ctx.fillRect(x - r, y + r * 1.2, r * 2 * (p.hp / 100), 3); | |
| 183 | } | |
| 184 | } |
static/val.js +334 −215
| @@ -3,25 +3,52 @@ | ||
| 3 | 3 | * person does — nothing else. There is no position here the server did not |
| 4 | 4 | * hand over, and no enemy in a snapshot the server decided you cannot see, |
| 5 | 5 | * so a patched client learns nothing a plain one does not. |
| 6 | * | |
| 7 | * This file is the wiring: the socket, the keys and the pointer, the HUD. | |
| 8 | * The drawing lives beside it — `scene.js` for the first-person view, | |
| 9 | * `topdown.js` for the map, `gun.js` for the crosshair, `prompt.js` for the | |
| 10 | * line of advice and `raycast.js` for the maths all of it stands on. | |
| 6 | 11 | */ |
| 7 | 12 | "use strict"; |
| 8 | 13 | |
| 14 | import { makeBeeper } from "./beep.js"; | |
| 15 | import { drawGun, gunSnapshot, gunTick, makeGun } from "./gun.js"; | |
| 16 | import { choosePrompt, REACH } from "./prompt.js"; | |
| 17 | import { drawScene, makeScene } from "./scene.js"; | |
| 18 | import { moveVector } from "./steer.js"; | |
| 19 | import { drawTopdown } from "./topdown.js"; | |
| 20 | ||
| 9 | 21 | const $ = (id) => document.getElementById(id); |
| 10 | 22 | const view = $("view"); |
| 11 | 23 | const ctx = view.getContext("2d"); |
| 12 | 24 | |
| 13 | 25 | const KEY_SLOT = { KeyC: "c", KeyQ: "q", KeyE: "e", KeyX: "x" }; |
| 14 | const AGENT_COLOUR = { ember: "#f08a4b", veil: "#8f7fe8", sable: "#4fd6a0", mender: "#e0c060", rook: "#d96a8f" }; | |
| 26 | const COARSE = matchMedia("(pointer: coarse)").matches; | |
| 27 | const LOOK = 0.0022; // radians per pixel of mouse travel | |
| 28 | const HELP_SEEN = "val.help.seen"; | |
| 15 | 29 | |
| 16 | 30 | let map = null; |
| 31 | let solid = () => true; | |
| 17 | 32 | let snap = null; |
| 18 | 33 | let socket = null; |
| 34 | let agents = null; | |
| 19 | 35 | const shown = new Map(); // smoothed positions, so 15 frames a second look like 60 |
| 20 | 36 | const keys = new Set(); |
| 21 | let pointer = null; // aim target in canvas pixels | |
| 37 | let yaw = 0; // where this person is looking, theirs to move between snapshots | |
| 38 | let locked = false; | |
| 22 | 39 | let firing = false; |
| 40 | let mode = COARSE ? "top" : "fp"; // a raycaster with touch look is miserable | |
| 41 | let mapOpen = false; | |
| 42 | let pointer = null; // aim target in canvas pixels, top-down only | |
| 23 | 43 | let stick = null; // { id, ox, oy, mx, my } |
| 24 | 44 | let aimTouch = null; |
| 45 | let helpShown = false; | |
| 46 | let last = performance.now(); | |
| 47 | ||
| 48 | const walk = { x: 0, y: 0 }; | |
| 49 | const scene = makeScene(); | |
| 50 | const gun = makeGun(); | |
| 51 | const beeper = makeBeeper(); | |
| 25 | 52 | |
| 26 | 53 | // ------------------------------------------------------------------ socket |
| 27 | 54 | |
| @@ -35,10 +62,13 @@ function connect() { | ||
| 35 | 62 | }; |
| 36 | 63 | socket.onmessage = (event) => { |
| 37 | 64 | const data = JSON.parse(event.data); |
| 38 | if (data.t === "s") { | |
| 39 | snap = data; | |
| 40 | paintHud(data); | |
| 41 | } | |
| 65 | if (data.t !== "s") return; | |
| 66 | const first = snap === null; | |
| 67 | snap = data; | |
| 68 | if (first && mode === "fp") yaw = data.me.angle; | |
| 69 | gunSnapshot(gun, data); | |
| 70 | paintHud(data); | |
| 71 | maybeHelp(data); | |
| 42 | 72 | }; |
| 43 | 73 | } |
| 44 | 74 | |
| @@ -48,8 +78,30 @@ const send = (message) => { | ||
| 48 | 78 | |
| 49 | 79 | // ------------------------------------------------------------------- input |
| 50 | 80 | |
| 81 | function setMode(next) { | |
| 82 | mode = next; | |
| 83 | $("stage").dataset.mode = next; | |
| 84 | if (next !== "fp" && document.pointerLockElement === view) document.exitPointerLock(); | |
| 85 | if (snap) yaw = snap.me.angle; | |
| 86 | } | |
| 87 | ||
| 51 | 88 | addEventListener("keydown", (event) => { |
| 52 | 89 | if (event.target instanceof HTMLInputElement) return; |
| 90 | if (event.code === "Tab") { | |
| 91 | mapOpen = true; | |
| 92 | event.preventDefault(); | |
| 93 | return; | |
| 94 | } | |
| 95 | if (event.code === "KeyH" || event.key === "?") { | |
| 96 | toggleHelp(); | |
| 97 | event.preventDefault(); | |
| 98 | return; | |
| 99 | } | |
| 100 | if (event.code === "KeyV") { | |
| 101 | setMode(mode === "fp" ? "top" : "fp"); | |
| 102 | event.preventDefault(); | |
| 103 | return; | |
| 104 | } | |
| 53 | 105 | if (event.code === "KeyB") { |
| 54 | 106 | toggleBuy(); |
| 55 | 107 | event.preventDefault(); |
| @@ -61,30 +113,69 @@ addEventListener("keydown", (event) => { | ||
| 61 | 113 | return; |
| 62 | 114 | } |
| 63 | 115 | keys.add(event.code); |
| 64 | if (["KeyW", "KeyA", "KeyS", "KeyD", "Space", "KeyF", "KeyR"].includes(event.code)) event.preventDefault(); | |
| 116 | if (["KeyW", "KeyA", "KeyS", "KeyD", "Space", "KeyF", "KeyR", "ArrowLeft", "ArrowRight"].includes(event.code)) | |
| 117 | event.preventDefault(); | |
| 118 | }); | |
| 119 | addEventListener("keyup", (event) => { | |
| 120 | if (event.code === "Tab") mapOpen = false; | |
| 121 | keys.delete(event.code); | |
| 122 | }); | |
| 123 | addEventListener("blur", () => { | |
| 124 | keys.clear(); | |
| 125 | mapOpen = false; | |
| 126 | firing = false; | |
| 65 | 127 | }); |
| 66 | addEventListener("keyup", (event) => keys.delete(event.code)); | |
| 67 | addEventListener("blur", () => keys.clear()); | |
| 68 | 128 | |
| 69 | view.addEventListener("pointermove", (event) => { | |
| 70 | if (event.pointerType === "touch") return; | |
| 129 | // Aiming is pointer lock: click the canvas to capture the mouse, move to | |
| 130 | // look, Escape gives it back. The angle sent is the same angle as ever. | |
| 131 | view.addEventListener("click", () => { | |
| 132 | beeper.wake(); | |
| 133 | if (mode === "fp" && !locked && view.requestPointerLock) view.requestPointerLock(); | |
| 134 | }); | |
| 135 | document.addEventListener("pointerlockchange", () => { | |
| 136 | locked = document.pointerLockElement === view; | |
| 137 | if (!locked) firing = false; | |
| 138 | $("stage").dataset.locked = locked ? "1" : ""; | |
| 139 | }); | |
| 140 | addEventListener("mousemove", (event) => { | |
| 141 | if (locked) yaw += event.movementX * LOOK; | |
| 142 | }); | |
| 143 | addEventListener("mousedown", (event) => { | |
| 144 | if (locked && event.button === 0) firing = true; | |
| 145 | }); | |
| 146 | addEventListener("mouseup", (event) => { | |
| 147 | if (locked && event.button === 0) firing = false; | |
| 148 | }); | |
| 149 | ||
| 150 | // The top-down view keeps the pointer aim it always had: it is the phone | |
| 151 | // control scheme, and the fallback when the first-person view is not wanted. | |
| 152 | const canvasPoint = (event) => { | |
| 71 | 153 | const box = view.getBoundingClientRect(); |
| 72 | pointer = { x: ((event.clientX - box.left) / box.width) * view.width, y: ((event.clientY - box.top) / box.height) * view.height }; | |
| 154 | return { | |
| 155 | x: ((event.clientX - box.left) / box.width) * view.width, | |
| 156 | y: ((event.clientY - box.top) / box.height) * view.height, | |
| 157 | }; | |
| 158 | }; | |
| 159 | view.addEventListener("pointermove", (event) => { | |
| 160 | if (mode !== "top" || event.pointerType === "touch") return; | |
| 161 | pointer = canvasPoint(event); | |
| 73 | 162 | }); |
| 74 | 163 | view.addEventListener("pointerdown", (event) => { |
| 164 | beeper.wake(); | |
| 165 | if (mode !== "top") return; | |
| 75 | 166 | view.setPointerCapture(event.pointerId); |
| 76 | 167 | const box = view.getBoundingClientRect(); |
| 77 | const x = event.clientX - box.left; | |
| 78 | 168 | if (event.pointerType === "touch") { |
| 79 | if (x < box.width / 2) stick = { id: event.pointerId, ox: event.clientX, oy: event.clientY, mx: 0, my: 0 }; | |
| 169 | if (event.clientX - box.left < box.width / 2) | |
| 170 | stick = { id: event.pointerId, ox: event.clientX, oy: event.clientY, mx: 0, my: 0 }; | |
| 80 | 171 | else { |
| 81 | aimTouch = { id: event.pointerId, x: ((event.clientX - box.left) / box.width) * view.width, y: ((event.clientY - box.top) / box.height) * view.height }; | |
| 172 | aimTouch = { id: event.pointerId, ...canvasPoint(event) }; | |
| 82 | 173 | firing = true; |
| 83 | 174 | } |
| 84 | 175 | event.preventDefault(); |
| 85 | 176 | return; |
| 86 | 177 | } |
| 87 | pointer = { x: ((event.clientX - box.left) / box.width) * view.width, y: ((event.clientY - box.top) / box.height) * view.height }; | |
| 178 | pointer = canvasPoint(event); | |
| 88 | 179 | firing = true; |
| 89 | 180 | }); |
| 90 | 181 | view.addEventListener("pointermove", (event) => { |
| @@ -96,11 +187,7 @@ view.addEventListener("pointermove", (event) => { | ||
| 96 | 187 | stick.mx = (dx / len) * reach; |
| 97 | 188 | stick.my = (dy / len) * reach; |
| 98 | 189 | } |
| 99 | if (aimTouch && event.pointerId === aimTouch.id) { | |
| 100 | const box = view.getBoundingClientRect(); | |
| 101 | aimTouch.x = ((event.clientX - box.left) / box.width) * view.width; | |
| 102 | aimTouch.y = ((event.clientY - box.top) / box.height) * view.height; | |
| 103 | } | |
| 190 | if (aimTouch && event.pointerId === aimTouch.id) Object.assign(aimTouch, canvasPoint(event)); | |
| 104 | 191 | }); |
| 105 | 192 | const endPointer = (event) => { |
| 106 | 193 | if (stick && event.pointerId === stick.id) stick = null; |
| @@ -113,34 +200,37 @@ view.addEventListener("pointerup", endPointer); | ||
| 113 | 200 | view.addEventListener("pointercancel", endPointer); |
| 114 | 201 | view.addEventListener("contextmenu", (event) => event.preventDefault()); |
| 115 | 202 | |
| 116 | // The buy opens itself every buy phase; closing it is remembered until the | |
| 117 | // next one, so nobody has to fight the panel to watch the round start. | |
| 118 | function toggleBuy() { | |
| 119 | const open = $("buy").classList.toggle("on"); | |
| 120 | $("buy").dataset.closed = open ? "" : "1"; | |
| 121 | } | |
| 122 | $("buytoggle").addEventListener("click", toggleBuy); | |
| 123 | for (const button of document.querySelectorAll("[data-buy]")) | |
| 124 | button.addEventListener("click", () => send({ t: "buy", what: button.dataset.buy })); | |
| 125 | for (const button of document.querySelectorAll("[data-agent]")) | |
| 126 | button.addEventListener("click", () => send({ t: "agent", id: button.dataset.agent })); | |
| 127 | ||
| 128 | // Twenty times a second the server hears what this person is doing. | |
| 203 | // Twenty times a second the server hears what this person is doing. In the | |
| 204 | // first-person view the keys are read against the way you are facing; in the | |
| 205 | // top-down view they are the compass, the way they always were. | |
| 129 | 206 | setInterval(() => { |
| 130 | 207 | if (!snap || !map) return; |
| 131 | let mx = 0; | |
| 132 | let my = 0; | |
| 133 | if (keys.has("KeyW")) my -= 1; | |
| 134 | if (keys.has("KeyS")) my += 1; | |
| 135 | if (keys.has("KeyA")) mx -= 1; | |
| 136 | if (keys.has("KeyD")) mx += 1; | |
| 208 | let ax = 0; | |
| 209 | let ay = 0; | |
| 210 | if (keys.has("KeyW")) ay -= 1; | |
| 211 | if (keys.has("KeyS")) ay += 1; | |
| 212 | if (keys.has("KeyA")) ax -= 1; | |
| 213 | if (keys.has("KeyD")) ax += 1; | |
| 137 | 214 | if (stick) { |
| 138 | mx = stick.mx; | |
| 139 | my = stick.my; | |
| 215 | ax = stick.mx; | |
| 216 | ay = stick.my; | |
| 217 | } | |
| 218 | let mx = ax; | |
| 219 | let my = ay; | |
| 220 | let angle; | |
| 221 | if (mode === "fp") { | |
| 222 | if (keys.has("ArrowLeft")) yaw -= 0.045; | |
| 223 | if (keys.has("ArrowRight")) yaw += 0.045; | |
| 224 | angle = yaw; | |
| 225 | moveVector(ax, ay, yaw, walk); | |
| 226 | mx = walk.x; | |
| 227 | my = walk.y; | |
| 228 | } else { | |
| 229 | const aim = aimTouch || pointer; | |
| 230 | const me = worldToScreen(snap.me); | |
| 231 | angle = aim ? Math.atan2(aim.y - me.y, aim.x - me.x) : snap.me.angle; | |
| 232 | yaw = angle; | |
| 140 | 233 | } |
| 141 | const aim = aimTouch || pointer; | |
| 142 | const me = worldToScreen(snap.me); | |
| 143 | const angle = aim ? Math.atan2(aim.y - me.y, aim.x - me.x) : snap.me.angle; | |
| 144 | 234 | send({ |
| 145 | 235 | t: "i", |
| 146 | 236 | mx, |
| @@ -168,168 +258,69 @@ const worldToScreen = (p) => ({ x: p.x * scale(), y: p.y * scale() }); | ||
| 168 | 258 | function smooth(id, x, y, angle) { |
| 169 | 259 | const now = shown.get(id); |
| 170 | 260 | if (!now) { |
| 171 | shown.set(id, { x, y, angle }); | |
| 172 | return { x, y, angle }; | |
| 261 | const fresh = { x, y, angle }; | |
| 262 | shown.set(id, fresh); | |
| 263 | return fresh; | |
| 173 | 264 | } |
| 174 | 265 | now.x += (x - now.x) * 0.35; |
| 175 | 266 | now.y += (y - now.y) * 0.35; |
| 176 | let turn = ((angle - now.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI; | |
| 267 | const turn = ((angle - now.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI; | |
| 177 | 268 | now.angle += turn * 0.4; |
| 178 | 269 | return now; |
| 179 | 270 | } |
| 180 | 271 | |
| 181 | function drawMap(s) { | |
| 182 | const t = map.tile * s; | |
| 183 | for (let row = 0; row < map.rows.length; row++) | |
| 184 | for (let col = 0; col < map.rows[row].length; col++) { | |
| 185 | const cell = map.rows[row][col]; | |
| 186 | ctx.fillStyle = | |
| 187 | cell === "#" ? "#20242d" : cell === "A" || cell === "B" ? "#2c2a22" : cell === "a" ? "#2a2024" : cell === "d" ? "#1e2630" : "#171a21"; | |
| 188 | ctx.fillRect(col * t, row * t, t + 0.6, t + 0.6); | |
| 189 | if (cell === "A" || cell === "B") { | |
| 190 | ctx.fillStyle = "#6b5f3a"; | |
| 191 | ctx.font = `${Math.round(t * 0.5)}px system-ui`; | |
| 192 | ctx.fillText(cell, col * t + t * 0.3, row * t + t * 0.68); | |
| 193 | } | |
| 194 | } | |
| 195 | } | |
| 272 | const look = (p) => smooth(p.id, p.x, p.y, p.angle); | |
| 196 | 273 | |
| 197 | function drawZone(z, s) { | |
| 198 | const x = z.x * s; | |
| 199 | const y = z.y * s; | |
| 200 | const r = z.r * s; | |
| 201 | ctx.save(); | |
| 202 | if (z.k === "smoke") { | |
| 203 | ctx.fillStyle = "rgba(190,195,205,0.72)"; | |
| 204 | ctx.beginPath(); | |
| 205 | ctx.arc(x, y, r, 0, 7); | |
| 206 | ctx.fill(); | |
| 207 | } else if (z.k === "molly") { | |
| 208 | ctx.fillStyle = "rgba(230,110,50,0.45)"; | |
| 209 | ctx.strokeStyle = "#f08a4b"; | |
| 210 | ctx.beginPath(); | |
| 211 | ctx.arc(x, y, r, 0, 7); | |
| 212 | ctx.fill(); | |
| 213 | ctx.stroke(); | |
| 214 | } else if (z.k === "wall") { | |
| 215 | ctx.fillStyle = "rgba(120,200,255,0.55)"; | |
| 216 | ctx.beginPath(); | |
| 217 | ctx.arc(x, y, r, 0, 7); | |
| 218 | ctx.fill(); | |
| 219 | } else if (z.k === "flash") { | |
| 220 | ctx.fillStyle = z.f > 0 ? "#ffe9a8" : "rgba(255,255,255,0.9)"; | |
| 221 | ctx.beginPath(); | |
| 222 | ctx.arc(x, y, z.f > 0 ? 5 * s : 30 * s, 0, 7); | |
| 223 | ctx.fill(); | |
| 224 | } else if (z.k === "dart") { | |
| 225 | ctx.strokeStyle = "rgba(90,220,170,0.7)"; | |
| 226 | ctx.beginPath(); | |
| 227 | ctx.arc(x, y, r, 0, 7); | |
| 228 | ctx.stroke(); | |
| 229 | } else if (z.k === "trip") { | |
| 230 | ctx.strokeStyle = "rgba(230,200,90,0.8)"; | |
| 231 | ctx.beginPath(); | |
| 232 | ctx.arc(x, y, r, 0, 7); | |
| 233 | ctx.stroke(); | |
| 234 | } else if (z.k === "heal") { | |
| 235 | ctx.fillStyle = "rgba(120,230,140,0.5)"; | |
| 236 | ctx.beginPath(); | |
| 237 | ctx.arc(x, y, 14 * s, 0, 7); | |
| 238 | ctx.fill(); | |
| 239 | } | |
| 240 | ctx.restore(); | |
| 241 | } | |
| 242 | ||
| 243 | function drawPlayer(p, s, me) { | |
| 244 | const pos = smooth(p.id, p.x, p.y, p.angle); | |
| 245 | const x = pos.x * s; | |
| 246 | const y = pos.y * s; | |
| 247 | const r = 16 * s; | |
| 248 | const mine = p.id === me.id; | |
| 249 | ctx.fillStyle = p.mate ? (mine ? "#eaeaea" : "#4fb3d9") : "#e35d5b"; | |
| 250 | if (!p.alive) ctx.fillStyle = "#555a63"; | |
| 251 | ctx.beginPath(); | |
| 252 | ctx.arc(x, y, r, 0, 7); | |
| 253 | ctx.fill(); | |
| 254 | ctx.strokeStyle = AGENT_COLOUR[p.agent] || "#888"; | |
| 255 | ctx.lineWidth = Math.max(1, 3 * s); | |
| 256 | ctx.stroke(); | |
| 257 | if (p.alive) { | |
| 258 | ctx.strokeStyle = mine ? "#fff" : ctx.fillStyle; | |
| 259 | ctx.beginPath(); | |
| 260 | ctx.moveTo(x, y); | |
| 261 | ctx.lineTo(x + Math.cos(pos.angle) * r * 2, y + Math.sin(pos.angle) * r * 2); | |
| 262 | ctx.stroke(); | |
| 263 | } | |
| 264 | if (p.spike) { | |
| 265 | ctx.fillStyle = "#f0d060"; | |
| 266 | ctx.fillRect(x - r * 0.4, y - r * 1.8, r * 0.8, r * 0.8); | |
| 267 | } | |
| 268 | if (p.blind) { | |
| 269 | ctx.fillStyle = "rgba(255,255,255,0.8)"; | |
| 270 | ctx.beginPath(); | |
| 271 | ctx.arc(x, y - r * 1.6, r * 0.35, 0, 7); | |
| 272 | ctx.fill(); | |
| 273 | } | |
| 274 | if (!mine) { | |
| 275 | ctx.fillStyle = "rgba(235,235,235,0.75)"; | |
| 276 | ctx.font = `${Math.round(11 * Math.max(1, s * 2))}px system-ui`; | |
| 277 | ctx.textAlign = "center"; | |
| 278 | ctx.fillText(p.name, x, y - r * 1.9); | |
| 279 | ctx.textAlign = "left"; | |
| 280 | } | |
| 281 | if (p.alive && p.hp < 100) { | |
| 282 | ctx.fillStyle = "#0008"; | |
| 283 | ctx.fillRect(x - r, y + r * 1.2, r * 2, 3); | |
| 284 | ctx.fillStyle = "#6fcf6f"; | |
| 285 | ctx.fillRect(x - r, y + r * 1.2, r * 2 * (p.hp / 100), 3); | |
| 286 | } | |
| 287 | } | |
| 288 | ||
| 289 | function frame() { | |
| 274 | function frame(now) { | |
| 290 | 275 | requestAnimationFrame(frame); |
| 276 | const dt = Math.min(0.1, (now - last) / 1000); | |
| 277 | last = now; | |
| 291 | 278 | if (!map) return; |
| 292 | const s = scale(); | |
| 279 | gunTick(gun, dt); | |
| 293 | 280 | ctx.clearRect(0, 0, view.width, view.height); |
| 294 | drawMap(s); | |
| 295 | if (!snap) return; | |
| 296 | 281 | |
| 297 | for (const orb of snap.orbs) { | |
| 298 | ctx.fillStyle = "#c9a0ff"; | |
| 299 | ctx.beginPath(); | |
| 300 | ctx.arc(orb.x * s, orb.y * s, 9 * s, 0, 7); | |
| 301 | ctx.fill(); | |
| 282 | if (!snap) { | |
| 283 | drawTopdown(ctx, map, null, look, { x: 0, y: 0, w: view.width, h: view.height }); | |
| 284 | return; | |
| 302 | 285 | } |
| 303 | for (const zone of snap.zones) if (zone.k !== "smoke") drawZone(zone, s); | |
| 286 | beeper.update(snap.spike.planted, snap.spike.fuse, now / 1000); | |
| 287 | const eye = look(snap.me); | |
| 288 | const facing = mode === "fp" ? yaw : eye.angle; | |
| 304 | 289 | |
| 305 | if (snap.spike.x !== null) { | |
| 306 | const x = snap.spike.x * s; | |
| 307 | const y = snap.spike.y * s; | |
| 308 | ctx.fillStyle = snap.spike.planted ? (Math.floor(Date.now() / 250) % 2 ? "#ff5b4a" : "#8a2a22") : "#c8b45c"; | |
| 309 | ctx.fillRect(x - 7 * s, y - 7 * s, 14 * s, 14 * s); | |
| 310 | if (snap.spike.planted && snap.spike.defuse > 0) { | |
| 311 | ctx.fillStyle = "#4fd6a0"; | |
| 312 | ctx.fillRect(x - 20 * s, y + 12 * s, 40 * s * (snap.spike.defuse / 7), 4); | |
| 313 | } | |
| 290 | if (mode === "fp" && !mapOpen) { | |
| 291 | drawScene(ctx, scene, { solid, tile: map.tile }, { x: eye.x, y: eye.y, angle: facing }, snap, { | |
| 292 | width: view.width, | |
| 293 | height: view.height, | |
| 294 | look, | |
| 295 | }); | |
| 296 | drawGun(ctx, gun, view.width, view.height, snap); | |
| 297 | drawMinimap(); | |
| 298 | } else { | |
| 299 | drawTopdown(ctx, map, snap, look, { x: 0, y: 0, w: view.width, h: view.height }); | |
| 314 | 300 | } |
| 315 | 301 | |
| 316 | for (const shot of snap.shots) { | |
| 317 | ctx.strokeStyle = shot.hit ? "rgba(255,120,110,0.85)" : "rgba(255,240,180,0.55)"; | |
| 318 | ctx.lineWidth = Math.max(1, 2 * s); | |
| 319 | ctx.beginPath(); | |
| 320 | ctx.moveTo(shot.x1 * s, shot.y1 * s); | |
| 321 | ctx.lineTo(shot.x2 * s, shot.y2 * s); | |
| 322 | ctx.stroke(); | |
| 323 | } | |
| 324 | ||
| 325 | for (const player of snap.players) drawPlayer(player, s, snap.me); | |
| 326 | // Smoke goes on last: it hides what is behind it on screen too. | |
| 327 | for (const zone of snap.zones) if (zone.k === "smoke") drawZone(zone, s); | |
| 328 | ||
| 329 | 302 | if (snap.me.planting > 0) bar(snap.me.planting / 4, "#e0a040", "planting"); |
| 330 | else if (snap.spike.planted && snap.spike.defuse > 0 && snap.me.side === "def") bar(snap.spike.defuse / 7, "#4fd6a0", "defusing"); | |
| 303 | else if (snap.spike.planted && snap.spike.defuse > 0 && snap.me.side === "def") | |
| 304 | bar(snap.spike.defuse / 7, "#4fd6a0", "defusing"); | |
| 331 | 305 | $("flash").style.opacity = Math.min(0.95, snap.me.blind * 0.8); |
| 332 | 306 | $("dead").style.display = snap.me.alive ? "none" : "grid"; |
| 307 | $("aimhint").style.display = mode === "fp" && !locked && !COARSE ? "grid" : "none"; | |
| 308 | } | |
| 309 | ||
| 310 | /** The same top-down drawing, small, in the corner, with you as an arrow. */ | |
| 311 | function drawMinimap() { | |
| 312 | const w = Math.min(view.width * 0.3, 260 * (view.width / 900)); | |
| 313 | const h = (w * map.rows.length) / map.rows[0].length; | |
| 314 | const pad = 8; | |
| 315 | ctx.save(); | |
| 316 | ctx.globalAlpha = 0.85; | |
| 317 | ctx.fillStyle = "#0b0d11"; | |
| 318 | ctx.fillRect(pad - 2, pad - 2, w + 4, h + 4); | |
| 319 | drawTopdown(ctx, map, snap, look, { x: pad, y: pad, w, h }, { labels: false }); | |
| 320 | ctx.restore(); | |
| 321 | ctx.strokeStyle = "rgba(255,255,255,0.18)"; | |
| 322 | ctx.lineWidth = 1; | |
| 323 | ctx.strokeRect(pad - 2, pad - 2, w + 4, h + 4); | |
| 333 | 324 | } |
| 334 | 325 | |
| 335 | 326 | function bar(fraction, colour, label) { |
| @@ -341,7 +332,7 @@ function bar(fraction, colour, label) { | ||
| 341 | 332 | ctx.fillStyle = colour; |
| 342 | 333 | ctx.fillRect(x, y, w * Math.min(1, fraction), 16); |
| 343 | 334 | ctx.fillStyle = "#fff"; |
| 344 | ctx.font = "12px system-ui"; | |
| 335 | ctx.font = "12px system-ui, sans-serif"; | |
| 345 | 336 | ctx.textAlign = "center"; |
| 346 | 337 | ctx.fillText(label, view.width / 2, y + 12); |
| 347 | 338 | ctx.textAlign = "left"; |
| @@ -349,6 +340,50 @@ function bar(fraction, colour, label) { | ||
| 349 | 340 | |
| 350 | 341 | // --------------------------------------------------------------------- hud |
| 351 | 342 | |
| 343 | // The buy opens itself every buy phase; closing it is remembered until the | |
| 344 | // next one, so nobody has to fight the panel to watch the round start. | |
| 345 | function toggleBuy() { | |
| 346 | const open = $("buy").classList.toggle("on"); | |
| 347 | $("buy").dataset.closed = open ? "" : "1"; | |
| 348 | } | |
| 349 | $("buytoggle").addEventListener("click", toggleBuy); | |
| 350 | $("viewtoggle").addEventListener("click", () => setMode(mode === "fp" ? "top" : "fp")); | |
| 351 | $("helptoggle").addEventListener("click", () => toggleHelp()); | |
| 352 | $("helpclose").addEventListener("click", () => toggleHelp(false)); | |
| 353 | for (const button of document.querySelectorAll("[data-buy]")) | |
| 354 | button.addEventListener("click", () => send({ t: "buy", what: button.dataset.buy })); | |
| 355 | for (const button of document.querySelectorAll("[data-agent]")) | |
| 356 | button.addEventListener("click", () => send({ t: "agent", id: button.dataset.agent })); | |
| 357 | ||
| 358 | function toggleHelp(force) { | |
| 359 | const open = force === undefined ? $("help").style.display !== "grid" : force; | |
| 360 | $("help").style.display = open ? "grid" : "none"; | |
| 361 | if (!open) { | |
| 362 | helpShown = true; | |
| 363 | try { | |
| 364 | localStorage.setItem(HELP_SEEN, "1"); | |
| 365 | } catch { | |
| 366 | // A browser that refuses storage still gets the card, every visit. | |
| 367 | } | |
| 368 | } | |
| 369 | } | |
| 370 | ||
| 371 | /** The first-run card, once per browser, and never over a round in progress. */ | |
| 372 | function maybeHelp(s) { | |
| 373 | if (helpShown) return; | |
| 374 | try { | |
| 375 | if (localStorage.getItem(HELP_SEEN) === "1") { | |
| 376 | helpShown = true; | |
| 377 | return; | |
| 378 | } | |
| 379 | } catch { | |
| 380 | /* no storage: show it and move on */ | |
| 381 | } | |
| 382 | if (s.phase === "live" && s.me.alive) return; | |
| 383 | helpShown = true; | |
| 384 | $("help").style.display = "grid"; | |
| 385 | } | |
| 386 | ||
| 352 | 387 | const KITS = {}; |
| 353 | 388 | |
| 354 | 389 | function paintHud(s) { |
| @@ -364,28 +399,20 @@ function paintHud(s) { | ||
| 364 | 399 | $("buy").classList.toggle("on", s.phase === "buy" && $("buy").dataset.closed !== "1"); |
| 365 | 400 | if (s.phase !== "buy") $("buy").dataset.closed = ""; |
| 366 | 401 | |
| 367 | const kit = $("kit"); | |
| 368 | const shape = ["c", "q", "e", "x"].map((slot) => `${slot}${s.me.charges[slot]}${Math.ceil(s.me.cooldown[slot])}`).join("|") + s.me.agent + s.me.ult; | |
| 369 | if (KITS.shape !== shape) { | |
| 370 | KITS.shape = shape; | |
| 371 | kit.innerHTML = ""; | |
| 372 | for (const slot of ["c", "q", "e", "x"]) { | |
| 373 | const button = document.createElement("button"); | |
| 374 | const ready = slot === "x" ? s.me.ult >= s.me.ultNeeds : s.me.charges[slot] > 0 && s.me.cooldown[slot] <= 0; | |
| 375 | button.disabled = !ready; | |
| 376 | const count = slot === "x" ? `${s.me.ult}/${s.me.ultNeeds}` : s.me.cooldown[slot] > 0 ? `${Math.ceil(s.me.cooldown[slot])}s` : s.me.charges[slot]; | |
| 377 | button.innerHTML = `${slot.toUpperCase()} <b>${count}</b>`; | |
| 378 | button.type = "button"; | |
| 379 | button.addEventListener("click", () => send({ t: "i", c: slot })); | |
| 380 | kit.append(button); | |
| 381 | } | |
| 382 | } | |
| 402 | const text = choosePrompt(s, { | |
| 403 | onSite: onSite(s.me), | |
| 404 | nearSpike: s.spike.x !== null && Math.hypot(s.spike.x - s.me.x, s.spike.y - s.me.y) < REACH, | |
| 405 | }); | |
| 406 | if ($("prompt").textContent !== text) $("prompt").textContent = text; | |
| 407 | ||
| 408 | paintKit(s); | |
| 409 | paintBuy(s); | |
| 383 | 410 | |
| 384 | 411 | const log = $("log"); |
| 385 | const text = s.events.slice(0, 3).join("\n"); | |
| 386 | if (log.dataset.text !== text) { | |
| 387 | log.dataset.text = text; | |
| 388 | log.innerHTML = ""; | |
| 412 | const lines = s.events.slice(0, 3).join("\n"); | |
| 413 | if (log.dataset.text !== lines) { | |
| 414 | log.dataset.text = lines; | |
| 415 | log.replaceChildren(); | |
| 389 | 416 | for (const line of s.events.slice(0, 3)) { |
| 390 | 417 | const div = document.createElement("div"); |
| 391 | 418 | div.textContent = line; |
| @@ -399,7 +426,7 @@ function paintHud(s) { | ||
| 399 | 426 | const key = rows.map((p) => `${p.name}${p.kills}${p.deaths}${p.alive}${p.credits}`).join(); |
| 400 | 427 | if (body.dataset.key === key) continue; |
| 401 | 428 | body.dataset.key = key; |
| 402 | body.innerHTML = ""; | |
| 429 | body.replaceChildren(); | |
| 403 | 430 | for (const player of rows) { |
| 404 | 431 | const tr = document.createElement("tr"); |
| 405 | 432 | const cells = [ |
| @@ -419,11 +446,103 @@ function paintHud(s) { | ||
| 419 | 446 | } |
| 420 | 447 | } |
| 421 | 448 | |
| 422 | fetch("/map.json") | |
| 423 | .then((r) => r.json()) | |
| 424 | .then((data) => { | |
| 425 | map = data; | |
| 449 | /** Is this player standing where the spike arms? The grid says so. */ | |
| 450 | function onSite(me) { | |
| 451 | if (!map) return false; | |
| 452 | const row = map.rows[Math.floor(me.y / map.tile)]; | |
| 453 | const cell = row ? row[Math.floor(me.x / map.tile)] : ""; | |
| 454 | return cell === "A" || cell === "B"; | |
| 455 | } | |
| 456 | ||
| 457 | const kitOf = (id) => (agents ? agents.find((a) => a.id === id) : null); | |
| 458 | ||
| 459 | /** The four ability buttons, each saying what it does and what it has left. */ | |
| 460 | function paintKit(s) { | |
| 461 | const kit = $("kit"); | |
| 462 | const agent = kitOf(s.me.agent); | |
| 463 | const shape = | |
| 464 | ["c", "q", "e", "x"].map((slot) => `${slot}${s.me.charges[slot]}${Math.ceil(s.me.cooldown[slot])}`).join("|") + | |
| 465 | s.me.agent + | |
| 466 | s.me.ult + | |
| 467 | (agent ? "k" : ""); | |
| 468 | if (KITS.shape === shape) return; | |
| 469 | KITS.shape = shape; | |
| 470 | kit.replaceChildren(); | |
| 471 | for (const slot of ["c", "q", "e", "x"]) { | |
| 472 | const ability = agent ? agent.abilities[slot] : null; | |
| 473 | const button = document.createElement("button"); | |
| 474 | const ready = slot === "x" ? s.me.ult >= s.me.ultNeeds : s.me.charges[slot] > 0 && s.me.cooldown[slot] <= 0; | |
| 475 | button.disabled = !ready; | |
| 476 | button.type = "button"; | |
| 477 | const count = | |
| 478 | slot === "x" | |
| 479 | ? `${s.me.ult}/${s.me.ultNeeds}` | |
| 480 | : s.me.cooldown[slot] > 0 | |
| 481 | ? `${Math.ceil(s.me.cooldown[slot])}s` | |
| 482 | : s.me.charges[slot]; | |
| 483 | const name = document.createElement("span"); | |
| 484 | name.textContent = `${slot.toUpperCase()}${ability ? " " + ability.name : ""}`; | |
| 485 | const tally = document.createElement("b"); | |
| 486 | tally.textContent = String(count); | |
| 487 | button.append(name, tally); | |
| 488 | if (ability) button.title = `${ability.name} — ${ability.text}`; | |
| 489 | button.addEventListener("click", () => send({ t: "i", c: slot })); | |
| 490 | kit.append(button); | |
| 491 | } | |
| 492 | } | |
| 493 | ||
| 494 | /** Prices on every button, greyed when you cannot afford it. */ | |
| 495 | function paintBuy(s) { | |
| 496 | const agent = kitOf(s.me.agent); | |
| 497 | for (const button of document.querySelectorAll("[data-buy]")) { | |
| 498 | const what = button.dataset.buy; | |
| 499 | let cost = Number(button.dataset.cost ?? 0); | |
| 500 | if ((what === "c" || what === "q") && agent) { | |
| 501 | const ability = agent.abilities[what]; | |
| 502 | cost = ability.cost; | |
| 503 | button.replaceChildren(); | |
| 504 | const name = document.createElement("span"); | |
| 505 | name.textContent = `${what.toUpperCase()} ${ability.name}`; | |
| 506 | const price = document.createElement("b"); | |
| 507 | price.textContent = `${ability.cost} · ${s.me.charges[what]}/${ability.charges}`; | |
| 508 | button.append(name, price); | |
| 509 | button.title = `${ability.text} (${ability.charges} charge${ability.charges === 1 ? "" : "s"} a round)`; | |
| 510 | } | |
| 511 | button.disabled = cost > s.me.credits; | |
| 512 | } | |
| 513 | for (const button of document.querySelectorAll("[data-agent]")) { | |
| 514 | const picked = button.dataset.agent === s.me.agent; | |
| 515 | button.style.outline = picked ? "2px solid #6fcf9f" : ""; | |
| 516 | } | |
| 517 | } | |
| 518 | ||
| 519 | // ------------------------------------------------------------------- start | |
| 520 | ||
| 521 | Promise.all([fetch("/map.json").then((r) => r.json()), fetch("/agents.json").then((r) => r.json())]) | |
| 522 | .then(([mapData, kit]) => { | |
| 523 | map = mapData; | |
| 524 | agents = kit.agents; | |
| 525 | const rows = map.rows; | |
| 526 | const cols = rows[0].length; | |
| 527 | solid = (col, row) => | |
| 528 | col < 0 || row < 0 || row >= rows.length || col >= cols || rows[row][col] === "#"; | |
| 529 | $("stage").dataset.mode = mode; | |
| 530 | fillHelp(); | |
| 426 | 531 | resize(); |
| 427 | 532 | connect(); |
| 428 | }); | |
| 533 | }) | |
| 534 | .catch(() => ($("conn").textContent = "offline")); | |
| 535 | ||
| 536 | /** The card says the round, the sides, the keys and the kit, in that order. */ | |
| 537 | function fillHelp() { | |
| 538 | const list = $("helpagents"); | |
| 539 | if (!list || !agents) return; | |
| 540 | list.replaceChildren(); | |
| 541 | for (const agent of agents) { | |
| 542 | const li = document.createElement("li"); | |
| 543 | li.textContent = `${agent.name} (${agent.role}) — ${agent.blurb}`; | |
| 544 | list.append(li); | |
| 545 | } | |
| 546 | } | |
| 547 | ||
| 429 | 548 | requestAnimationFrame(frame); |