| … | … | @@ -37,8 +37,11 @@ canvas{position:absolute;inset:0;width:100%;height:100%;display:block} |
| 37 | 37 | <div class="hud" id="ctl"> |
| 38 | 38 | <label>zoom <span class="v" id="v_zoom">1.00</span> |
| 39 | 39 | <input type="range" id="zoom" min="-3" max="1.6" step="0.05" value="0"></label> |
| 40 | | <label>prefetch rings <span class="v" id="v_pre">1</span> |
| 41 | | <input type="range" id="pre" min="0" max="3" step="1" value="1"></label> |
| 40 | <label>prefetch radius <span class="v" id="v_pre">2</span> |
| 41 | <input type="range" id="pre" min="1" max="5" step="1" value="2"></label> |
| 42 | <label>move speed <span class="v" id="v_speed">400</span> m/s |
| 43 | <input type="range" id="speed" min="1.7" max="3.7" step="0.05" value="2.6"></label> |
| 44 | <label><input type="checkbox" id="follow" checked style="width:auto"> camera follows player</label> |
| 42 | 45 | <label><input type="checkbox" id="grid" checked style="width:auto"> tile grid + state</label> |
| 43 | 46 | <hr> |
| 44 | 47 | <label style="display:block"><input type="checkbox" id="biome" style="width:auto"> |
| … | … | @@ -57,7 +60,7 @@ canvas{position:absolute;inset:0;width:100%;height:100%;display:block} |
| 57 | 60 | <button id="gomtn">the mountain region</button> |
| 58 | 61 | <button id="clearq">stop prefetching</button> |
| 59 | 62 | </div> |
| 60 | | <div class="hud" id="help">drag to pan · wheel to zoom · one tile generates at a time, by design |
| 63 | <div class="hud" id="help"><b>WASD</b> to move (shift = 8x) · drag to pan · wheel to zoom · one tile generates at a time, by design |
| 61 | 64 | <span style="color:var(--warn)">amber tint</span> = coarse placeholder (7.68 km/px), sharpens as detail tiles arrive · <span style="color:var(--accent)">cyan</span> = generating now</div> |
| 62 | 65 | |
| 63 | 66 | <script> |
| … | … | @@ -93,6 +96,20 @@ const $ = id => document.getElementById(id); |
| 93 | 96 | // |
| 94 | 97 | // view.x is world j (columns, screen X); view.y is world i (rows, screen Y). |
| 95 | 98 | let view = {x: 2048 + 128, y: 128, scale: 0.25}; |
| 99 | |
| 100 | // THE PLAYER IS THE ORIGIN OF GENERATION, not the viewport. |
| 101 | // |
| 102 | // Tiles are requested inside a radius of the PLAYER's tile, so what gets |
| 103 | // generated depends on where the player is and not on how far the camera is |
| 104 | // zoomed out. Zooming out used to queue 445 tiles of 30 m/px detail to draw |
| 105 | // them at 40 px each; with generation anchored to the player that cannot |
| 106 | // happen, and the coarse layer covers whatever else is on screen. |
| 107 | // |
| 108 | // Position is in model px (1 px = 30 m). Held in world j/i to match the tile |
| 109 | // axes: player.x is j (east-west), player.y is i (north-south). |
| 110 | const player = {x: view.x, y: view.y, vx: 0, vy: 0}; |
| 111 | const keys = new Set(); |
| 112 | let lastT = performance.now(); |
| 96 | 113 | const imgs = new Map(); // "tx,ty" -> Image (loaded) or "pending" |
| 97 | 114 | let tileState = {}; // "tx,ty" -> cached|disk|queued|busy|missing|error |
| 98 | 115 | let stats = {}; |
| … | … | @@ -189,10 +206,42 @@ function draw() { |
| 189 | 206 | } |
| 190 | 207 | } |
| 191 | 208 | } |
| 209 | // the player, always at its world position (the camera may be detached) |
| 210 | const px = innerWidth/2 + (player.x - view.x) * s; |
| 211 | const py = innerHeight/2 + (player.y - view.y) * s; |
| 212 | const R = (+$("pre").value + 0.5) * TILE_PX * s; |
| 213 | ctx.strokeStyle = "rgba(116,194,205,0.35)"; |
| 214 | ctx.lineWidth = 1; ctx.setLineDash([6, 5]); |
| 215 | ctx.strokeRect(px - R, py - R, R * 2, R * 2); |
| 216 | ctx.setLineDash([]); |
| 217 | const box = Math.max(10, 0.20 * TILE_PX * s); // ~1.5 km |
| 218 | ctx.strokeStyle = "#74c2cd"; ctx.lineWidth = 2; |
| 219 | ctx.strokeRect(px - box/2, py - box/2, box, box); |
| 220 | ctx.fillStyle = "rgba(116,194,205,0.20)"; |
| 221 | ctx.fillRect(px - box/2, py - box/2, box, box); |
| 222 | |
| 192 | 223 | dirty = false; |
| 193 | 224 | } |
| 194 | 225 | |
| 195 | | function frame() { if (dirty) draw(); requestAnimationFrame(frame); } |
| 226 | function speedMps() { return Math.pow(10, +$("speed").value); } |
| 227 | |
| 228 | function step(now) { |
| 229 | const dt = Math.min(0.1, (now - lastT) / 1000); |
| 230 | lastT = now; |
| 231 | let dx = (keys.has("d") ? 1 : 0) - (keys.has("a") ? 1 : 0); |
| 232 | let dy = (keys.has("s") ? 1 : 0) - (keys.has("w") ? 1 : 0); |
| 233 | if (dx || dy) { |
| 234 | const n = Math.hypot(dx, dy) || 1; |
| 235 | const mps = speedMps() * (keys.has("shift") ? 8 : 1); |
| 236 | const pxPerS = mps / M_PER_PX; // model px per second |
| 237 | player.x += (dx / n) * pxPerS * dt; |
| 238 | player.y += (dy / n) * pxPerS * dt; |
| 239 | if ($("follow").checked) { view.x = player.x; view.y = player.y; } |
| 240 | dirty = true; |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | function frame(now) { step(now || performance.now()); if (dirty) draw(); requestAnimationFrame(frame); } |
| 196 | 245 | requestAnimationFrame(frame); |
| 197 | 246 | |
| 198 | 247 | // ---- prefetch: ask for what is visible, plus N rings, centred on the view ---- |
| … | … | @@ -219,7 +268,6 @@ async function ensureCoarse() { |
| 219 | 268 | |
| 220 | 269 | async function poll() { |
| 221 | 270 | ensureCoarse(); |
| 222 | | const vis = visibleTiles(); |
| 223 | 271 | const rings = +$("pre").value; |
| 224 | 272 | // DO NOT queue what you cannot see the detail of. At low zoom a 7.68 km tile |
| 225 | 273 | // lands on ~40 screen px, so generating 30 m/px for it is pure waste -- and |
| … | … | @@ -236,31 +284,29 @@ async function poll() { |
| 236 | 284 | // MAX_QUEUE: never ask for more than can be delivered while you are still |
| 237 | 285 | // looking at it. One tile at a time is by design, so a queue longer than this |
| 238 | 286 | // is a promise the page cannot keep -- and it is stale the moment you pan. |
| 239 | | const MIN_PX = 110; |
| 287 | // The request set is a RADIUS AROUND THE PLAYER, not the viewport. Bounded by |
| 288 | // construction: (2r+1)^2 tiles, 25 at r=2, however far you zoom out. |
| 289 | const pti = Math.floor(player.y / TILE_PX); // i = north-south |
| 290 | const ptj = Math.floor(player.x / TILE_PX); // j = east-west |
| 240 | 291 | const MAX_QUEUE = 12; |
| 241 | | const onScreen = TILE_PX * view.scale; |
| 242 | | if (onScreen < MIN_PX) { |
| 243 | | try { |
| 244 | | const r = await fetch(`/api/tiles/state?tiles=`); |
| 245 | | stats = (await r.json()).stats; hud(); |
| 246 | | } catch (e) {} |
| 247 | | return; |
| 248 | | } |
| 249 | | const want = new Set(vis.map(t => t.join(","))); |
| 250 | | if (rings > 0) { |
| 251 | | for (const [tx, ty] of vis) |
| 252 | | for (let dy = -rings; dy <= rings; dy++) |
| 253 | | for (let dx = -rings; dx <= rings; dx++) want.add((tx+dx) + "," + (ty+dy)); |
| 254 | | } |
| 292 | const want = new Set(); |
| 293 | for (let di = -rings; di <= rings; di++) |
| 294 | for (let dj = -rings; dj <= rings; dj++) want.add((pti+di) + "," + (ptj+dj)); |
| 295 | for (const [tx, ty] of visibleTiles()) want.add(tx + "," + ty); // state only |
| 255 | 296 | // centre expressed as (tx, ty) = (i, j), matching the server's ordering |
| 256 | | const ctx_ = [Math.floor(view.y / TILE_PX), Math.floor(view.x / TILE_PX)]; |
| 297 | const ctx_ = [pti, ptj]; // priority is distance from the PLAYER |
| 257 | 298 | // Sort by distance from the view centre and ask only for the nearest few. |
| 258 | 299 | const sorted = [...want].map(k => { |
| 259 | 300 | const [tx, ty] = k.split(",").map(Number); |
| 260 | 301 | return {k, d: (tx - ctx_[0]) ** 2 + (ty - ctx_[1]) ** 2}; |
| 261 | 302 | }).sort((a, b) => a.d - b.d); |
| 262 | 303 | const list = sorted.map(o => o.k).join(";"); // state for all |
| 263 | | const ask = sorted.slice(0, MAX_QUEUE).map(o => o.k).join(";"); // generate a few |
| 304 | // only tiles inside the player's radius are ever GENERATED |
| 305 | const inRing = sorted.filter(o => { |
| 306 | const [ti, tj] = o.k.split(",").map(Number); |
| 307 | return Math.abs(ti - pti) <= rings && Math.abs(tj - ptj) <= rings; |
| 308 | }); |
| 309 | const ask = inRing.slice(0, MAX_QUEUE).map(o => o.k).join(";"); |
| 264 | 310 | try { |
| 265 | 311 | if (!STOPPED) { |
| 266 | 312 | await fetch(`/api/tiles/request?tiles=${encodeURIComponent(ask)}` + |
| … | … | @@ -299,7 +345,9 @@ function hud() { |
| 299 | 345 | const span = (innerWidth / view.scale * M_PER_PX / 1000).toFixed(1); |
| 300 | 346 | $("stats").innerHTML = |
| 301 | 347 | `<span class="a">INFINITE DIFFUSION — STREAMING</span> |
| 302 | | <span class="k">centre </span>${wx} , ${wy} km <span class="k">(tile</span> ${Math.floor(view.y/TILE_PX)},${Math.floor(view.x/TILE_PX)}<span class="k">)</span> |
| 348 | <span class="k">player </span>${(player.x*M_PER_PX/1000).toFixed(2)} , ${(player.y*M_PER_PX/1000).toFixed(2)} km <span class="k">tile</span> ${Math.floor(player.y/TILE_PX)},${Math.floor(player.x/TILE_PX)} |
| 349 | <span class="k">ring </span>${(2*(+$("pre").value)+1)}x${(2*(+$("pre").value)+1)} tiles = ${((2*(+$("pre").value)+1)*7.68).toFixed(1)} km across |
| 350 | <span class="k">camera </span>${wx} , ${wy} km <span class="k">(tile</span> ${Math.floor(view.y/TILE_PX)},${Math.floor(view.x/TILE_PX)}<span class="k">)</span> |
| 303 | 351 | <span class="k">view </span>${span} km across <span class="k">tile</span> ${km} km |
| 304 | 352 | <span class="k">visible </span>${visibleTiles().length} tiles <span class="k">on-screen</span> ${(TILE_PX*view.scale).toFixed(0)} px${TILE_PX*view.scale < 48 ? ' <span class="w">— too small, not requesting</span>' : ''} |
| 305 | 353 | |
| … | … | @@ -347,5 +395,21 @@ $("go0").onclick = () => { view.x = 0; view.y = 0; dirty = true; poll(); }; |
| 347 | 395 | // the mountain region is i=0, j=2048 -> screen y=128, x=2176 |
| 348 | 396 | $("gomtn").onclick = () => { view.x = 2176; view.y = 128; dirty = true; poll(); }; |
| 349 | 397 | |
| 398 | addEventListener("keydown", e => { |
| 399 | if (/INPUT|SELECT|TEXTAREA/.test(document.activeElement.tagName)) return; |
| 400 | const k = e.key.toLowerCase(); |
| 401 | if ("wasd".includes(k) || k === "shift") { keys.add(k === "shift" ? "shift" : k); e.preventDefault(); } |
| 402 | }); |
| 403 | addEventListener("keyup", e => { |
| 404 | const k = e.key.toLowerCase(); |
| 405 | keys.delete(k === "shift" ? "shift" : k); |
| 406 | }); |
| 407 | addEventListener("blur", () => keys.clear()); |
| 408 | $("speed").addEventListener("input", () => { |
| 409 | $("v_speed").textContent = speedMps() < 1000 ? speedMps().toFixed(0) |
| 410 | : (speedMps()/1000).toFixed(1) + "k"; |
| 411 | }); |
| 412 | $("speed").dispatchEvent(new Event("input")); |
| 413 | |
| 350 | 414 | resize(); setZoom(-2); hud(); poll(); |
| 351 | 415 | </script> |