Spaces:
Paused
Paused
Claude
Claude Opus 5
Tier 1, step 4b: stop one bad entity killing the view (A7, A8)
a53030d unverified Download render.js from Almaatla/SpaceCities: direct link, hf CLI and curl.
- Browser
- Download file 15.5 kB
-
https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/main/render.js
- Command line
-
hf download hf://spaces/Almaatla/SpaceCities/render.js
-
curl -L -o render.js https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/main/render.js
15.5 kB
| /* ============================================================ | |
| Canvas rendering β the frame ORCHESTRATOR. Pure read of state β never mutates | |
| it. Assumes the caller already set up a device-pixel-ratio transform; drawFrame | |
| lays the camera transform on top, so every draw* helper works in plain world | |
| coordinates and never has to know about the viewport or camera itself. | |
| No image assets β the project is deliberately zero-dependency/no-build, so every | |
| entity is a small vector silhouette built from canvas paths rather than sprite | |
| files. To keep any one file from being a god object, the ~1900-line original was | |
| split into cohesive sibling modules that this file composes: | |
| β’ renderShared.js β DETAIL, the facing/interpolation state, geometry+colour | |
| primitives, inView culling, the health-bar primitive. | |
| β’ renderNodes.js β resource-deposit silhouettes. | |
| β’ renderBuildings.js β building hulls + bars + Odyssey selection cues. | |
| β’ renderUnits.js β unit hulls (two-pass) + overlays. | |
| β’ renderEffects.js β tracers/flashes/pings, scenario route, ghosts, overlays. | |
| This file keeps drawFrame (the draw order), spriteIcon (the HUD button art), | |
| viewBounds, and the map backdrop (fog wash, terrain, screen-space fireworks). | |
| ============================================================ */ | |
| ; | |
| import { UNITS, BUILDINGS } from "./engine/entities.js"; | |
| import { FOG_CELL_SIZE } from "./engine/fog.js"; | |
| import { activeFireworks } from "./effects.js"; | |
| import { DETAIL, facing, pruneFacing } from "./renderShared.js"; | |
| import { drawNodes } from "./renderNodes.js"; | |
| import { drawBuildings, drawBuildingShape, drawBuildingBars, drawJumpStaging, drawPowerGrid } from "./renderBuildings.js"; | |
| import { drawUnits, drawUnitShape } from "./renderUnits.js"; | |
| import { drawScenario, drawEffects, drawBuildGhost, drawWaypoints, drawEscortLinks, | |
| drawSelectionRings, drawRallyPoint, drawDragBox } from "./renderEffects.js"; | |
| // snapshotPositions + resetFacing live with the interpolation/facing state they drive | |
| // (renderShared.js); re-export them here so boot.js keeps importing them from render.js. | |
| export { snapshotPositions, resetFacing } from "./renderShared.js"; | |
| const ICON_BOX = 40; // css px of the square icon | |
| const ICON_R = 14; // normalized sprite radius inside the box | |
| const iconCache = new Map(); | |
| export function spriteIcon(kind, type, color = "#8fd3ff") { | |
| // Rasterize at device-pixel density (min 2Γ) so button art stays crisp next to the | |
| // DPR-scaled map art on a 3Γ display, instead of a fixed 2Γ. Computed BEFORE the cache | |
| // key (and folded INTO it below) so dragging the window to a different-DPR display | |
| // busts the cache instead of the icon staying rasterized at the old, now-wrong scale | |
| // for the rest of the session. | |
| const scale = Math.max(2, Math.ceil((typeof window !== "undefined" && window.devicePixelRatio) || 1)); | |
| const key = `${kind}:${type}:${color}:${scale}`; | |
| if (iconCache.has(key)) return iconCache.get(key); | |
| let canvas, c; | |
| try { | |
| canvas = document.createElement("canvas"); | |
| canvas.width = canvas.height = ICON_BOX * scale; | |
| c = canvas.getContext("2d"); | |
| if (!c) throw new Error("2d context unavailable"); | |
| c.scale(scale, scale); | |
| } catch (e) { | |
| // Canvas/context setup failing is an ENVIRONMENT problem (a momentary resource cap, | |
| // a lost context, ...) β not something specific to this (kind, type). Don't cache | |
| // it, so once the environment recovers the next call gets a real render instead of | |
| // being stuck on "" for the rest of the session. | |
| console.error(`spriteIcon: canvas setup failed for "${key}"`, e); | |
| return ""; | |
| } | |
| let url = ""; | |
| try { | |
| const def = kind === "unit" ? UNITS[type] : BUILDINGS[type]; | |
| const actualR = (def && def.radius) || 16; | |
| c.translate(ICON_BOX / 2, ICON_BOX / 2); | |
| c.scale(ICON_R / actualR, ICON_R / actualR); // normalize the sprite to ICON_R | |
| if (kind === "unit") { | |
| c.fillStyle = color; c.strokeStyle = DETAIL; c.lineWidth = 1.5; | |
| drawUnitShape(c, { id: "__icon__", type, x: 0, y: 0 }, def, color); | |
| } else { | |
| drawBuildingShape(c, { units: new Map(), buildings: new Map() }, { id: "__icon__", type, x: 0, y: 0, radius: actualR }, color); | |
| } | |
| facing.delete("__icon__"); // don't leave a stray orientation in the shared map | |
| url = canvas.toDataURL(); | |
| } catch (e) { | |
| // A failure HERE is deterministic for this (kind, type) β the shape-drawing code | |
| // itself is broken (e.g. it reads an undefined `def` property) β so cache the empty | |
| // result too: otherwise a genuinely broken icon type would redo the full | |
| // allocate+draw+throw cycle on every single call, forever, instead of failing once. | |
| console.error(`spriteIcon: failed to draw icon for "${key}"`, e); | |
| iconCache.set(key, ""); | |
| return ""; | |
| } | |
| iconCache.set(key, url); // the valid path: identical to before β a working icon type | |
| return url; // renders and caches exactly as it always has | |
| } | |
| // The world-space rectangle currently on screen, padded so an entity straddling | |
| // an edge still draws. Everything outside it is skipped β on a Gigantic (4x) | |
| // map the vast majority of the field, its fog cells, and its entities are | |
| // off-screen every frame, so culling is what keeps the draw cost bounded by | |
| // what's visible rather than by total map size. | |
| function viewBounds(camera, vw, vh, pad = 0) { | |
| const halfW = vw / (2 * camera.zoom), halfH = vh / (2 * camera.zoom); | |
| return { | |
| minX: camera.x - halfW - pad, maxX: camera.x + halfW + pad, | |
| minY: camera.y - halfH - pad, maxY: camera.y + halfH + pad, | |
| }; | |
| } | |
| // observerMode (default false β every existing caller/test stays byte-identical): Observer | |
| // Mode's whole point is seeing everything on the spectated world, so the fog wash is skipped | |
| // outright and every per-entity fog gate below (drawBuildings/drawUnits/drawBuildingBars) is | |
| // bypassed too β a pure render-time flag, never a mutation of state.fog itself. | |
| export function drawFrame(ctx, state, camera, viewportW, viewportH, dragBox, buildGhost, alpha = 1, observerMode = false) { | |
| pruneFacing(state); | |
| // try/finally, because engine/loop.js deliberately SWALLOWS a throwing render so a bad entity | |
| // can't brick the session. That only works if the frame unwinds: without the finally, a throw | |
| // left this save() unmatched, so the camera transform was never popped and every later frame | |
| // drew on top of it β including the backdrop fillRect below, which is why the screen stopped | |
| // clearing at all. The loop kept running while the view stayed dead, which to a player is a | |
| // freeze, and it is exactly the symptom test/renderEffects.test.js already records as shipped. | |
| ctx.save(); | |
| try { | |
| ctx.fillStyle = "#05070f"; | |
| ctx.fillRect(0, 0, viewportW, viewportH); | |
| ctx.translate(viewportW / 2, viewportH / 2); | |
| ctx.scale(camera.zoom, camera.zoom); | |
| ctx.translate(-camera.x, -camera.y); | |
| const view = viewBounds(camera, viewportW, viewportH, 40); // 40px pad β₯ largest entity radius | |
| // Computed ONCE per frame and threaded down to every draw* helper that needs to test | |
| // membership, instead of each of drawBuildingBars/drawJumpStaging/drawPowerGrid/drawUnits | |
| // independently allocating its own `new Set(state.selection)` sixty times a second. | |
| const selSet = new Set(state.selection); | |
| if (!observerMode) drawFogBase(ctx, state, view); | |
| drawTerrain(ctx, state, view); // charted geography β under nodes/units, always visible | |
| // Resource deposits are charted map knowledge (see data.js), not | |
| // battlefield intel β they render at full visibility regardless of | |
| // fog, on top of the dimmed backdrop. | |
| drawNodes(ctx, state, view, observerMode); | |
| if (state.scenario) drawScenario(ctx, state); // the convoy route + stations, under the units | |
| drawBuildings(ctx, state, view, observerMode); // building hulls + foe pips (bars deferred below) | |
| drawUnits(ctx, state, view, alpha, selSet, observerMode); // unit hulls, then unit overlays (two passes) | |
| drawBuildingBars(ctx, state, view, selSet, observerMode); // building health bars last, so a passing ship can't paint them out | |
| drawJumpStaging(ctx, state, view, selSet); // staging ring around a selected Spaceport (Odyssey) | |
| drawPowerGrid(ctx, state, view, selSet); // efficiency zones around a selected Reactor (Odyssey) | |
| drawEffects(ctx); | |
| if (buildGhost) drawBuildGhost(ctx, state, buildGhost); | |
| drawWaypoints(ctx, state); | |
| drawEscortLinks(ctx, state); | |
| drawSelectionRings(ctx, state, alpha); | |
| drawRallyPoint(ctx, state); | |
| if (dragBox) drawDragBox(ctx, dragBox); | |
| } finally { | |
| ctx.restore(); | |
| } | |
| drawFireworks(ctx, viewportW, viewportH); // screen-space (post-camera): milestone celebration, always on-screen | |
| } | |
| // Screen-space celebratory bursts for Odyssey progress milestones (see effects.js). Drawn | |
| // AFTER the world/camera transform is popped, so a firework sits in viewport pixels and shows | |
| // no matter where the camera is. Each spark flies out from its shell along its angle, arcs down | |
| // under a little gravity, and fades as the shell ages; additive blending makes them glow. | |
| function drawFireworks(ctx, vw, vh) { | |
| const shells = activeFireworks(); | |
| if (!shells.length) return; | |
| ctx.save(); | |
| ctx.globalCompositeOperation = "lighter"; | |
| for (const f of shells) { | |
| const cx = f.cx * vw, cy = f.cy * vh; | |
| const ease = 1 - Math.pow(1 - f.age, 2); // fast out, then settling | |
| const grav = 0.18 * f.age * f.age * vh; // downward drift over the shell's life | |
| const alpha = Math.max(0, 1 - f.age); | |
| for (const p of f.parts) { | |
| const dist = p.spd * ease * vh; | |
| const x = cx + Math.cos(p.a) * dist; | |
| const y = cy + Math.sin(p.a) * dist + grav; | |
| ctx.beginPath(); | |
| ctx.arc(x, y, 2.4, 0, Math.PI * 2); | |
| ctx.fillStyle = `hsla(${p.hue}, 95%, ${58 + 22 * alpha}%, ${alpha})`; | |
| ctx.fill(); | |
| } | |
| } | |
| ctx.restore(); | |
| } | |
| // Explored-but-not-currently-visible cells get a faint cyan "charted space" wash so the | |
| // player can see where they've already scouted β the same language the minimap uses. The | |
| // old code painted this fill (and the unexplored one) in rgba(5,7,15,*), which IS the | |
| // backdrop colour #05070f, so BOTH states were invisible: explored and unexplored ground | |
| // rendered identically to empty space, and the promised scouted-territory cue never | |
| // existed on the main map. Unexplored cells now draw nothing (bare backdrop reads as | |
| // "unknown"); currently-visible cells draw nothing (the world underneath is at full | |
| // brightness); only explored-not-visible cells get the wash. | |
| // | |
| // This layer is genuinely dynamic β fog.visible flips every tick as units move β so unlike | |
| // the terrain cache below it can't be pre-rendered once. It IS spatially coherent though | |
| // (docs/improvement-proposals.md "Stop per-cell fog/terrain fills": most rows are long | |
| // contiguous runs of the same qualify/don't-qualify state), so instead of one fillRect per | |
| // cell this walks each row and merges a run of qualifying cells into a single fillRect β | |
| // same cells painted, same colour, far fewer draw calls on a big map's mostly-uniform fog. | |
| export function drawFogBase(ctx, state, view) { | |
| const fog = state.fog; | |
| if (!fog) return; | |
| // Only the cells overlapping the viewport β clamped to the grid β instead of | |
| // all rows*cols every frame (16k+ on a 4x map). | |
| const gx0 = view ? Math.max(0, Math.floor(view.minX / FOG_CELL_SIZE)) : 0; | |
| const gx1 = view ? Math.min(fog.cols - 1, Math.floor(view.maxX / FOG_CELL_SIZE)) : fog.cols - 1; | |
| const gy0 = view ? Math.max(0, Math.floor(view.minY / FOG_CELL_SIZE)) : 0; | |
| const gy1 = view ? Math.min(fog.rows - 1, Math.floor(view.maxY / FOG_CELL_SIZE)) : fog.rows - 1; | |
| ctx.fillStyle = "rgba(79, 209, 255, 0.05)"; // charted-but-not-live wash (matches the minimap) | |
| for (let gy = gy0; gy <= gy1; gy++) { | |
| const row = gy * fog.cols; | |
| let runStart = -1; // gx a qualifying run began at, or -1 while no run is open | |
| for (let gx = gx0; gx <= gx1; gx++) { | |
| const idx = row + gx; | |
| const qualifies = !fog.visible[idx] && fog.explored[idx]; // live cells and never-seen cells draw nothing | |
| if (qualifies) { | |
| if (runStart === -1) runStart = gx; | |
| } else if (runStart !== -1) { | |
| ctx.fillRect(runStart * FOG_CELL_SIZE, gy * FOG_CELL_SIZE, (gx - runStart) * FOG_CELL_SIZE, FOG_CELL_SIZE); | |
| runStart = -1; | |
| } | |
| } | |
| if (runStart !== -1) { // a run that reached the last in-view column β flush it after the loop | |
| ctx.fillRect(runStart * FOG_CELL_SIZE, gy * FOG_CELL_SIZE, (gx1 + 1 - runStart) * FOG_CELL_SIZE, FOG_CELL_SIZE); | |
| } | |
| } | |
| } | |
| // Terrain washes, drawn as charted geography beneath everything else. Rough | |
| // ground reads as a cool slate haze, high ground as a warm gold glow β subtle, | |
| // so it shapes the field without fighting the units for attention. OPEN cells | |
| // (the majority) draw nothing. | |
| const TERRAIN_FILL = { 1: "rgba(120, 140, 180, 0.16)", 2: "rgba(255, 209, 102, 0.13)" }; | |
| // Terrain is immutable for the whole match (engine/map.js's own header comment), unlike the | |
| // fog wash above β so instead of re-walking every on-screen cell every frame, pre-render the | |
| // WHOLE grid ONCE into an offscreen canvas at cell resolution (one canvas pixel per terrain | |
| // cell β cols x rows, tiny even on a Gigantic map) and blit only the visible sub-rect with a | |
| // single drawImage per frame. Same underlayMap cache idiom minimap.js already uses for its own | |
| // static layers (see its header comment for the identical "16k+ fillRects" pathology) β keyed | |
| // on state.map identity so a fresh map (a new object from generateMap) invalidates the cache, | |
| // but the same map reuses the cached canvas for the rest of the match. | |
| let terrainCache = null, terrainCacheMap = null; | |
| function ensureTerrainCache(map, t) { | |
| if (terrainCacheMap === map) return terrainCache; | |
| const canvas = document.createElement("canvas"); | |
| canvas.width = t.cols; canvas.height = t.rows; | |
| const c = canvas.getContext("2d"); | |
| for (let gy = 0; gy < t.rows; gy++) { | |
| const row = gy * t.cols; | |
| for (let gx = 0; gx < t.cols; gx++) { | |
| const code = t.type[row + gx]; | |
| if (!code) continue; // OPEN stays transparent, same as the old per-cell "continue" | |
| c.fillStyle = TERRAIN_FILL[code]; | |
| c.fillRect(gx, gy, 1, 1); // one canvas pixel per cell β exact 1:1, no seam-hiding +1 needed | |
| } | |
| } | |
| terrainCache = canvas; | |
| terrainCacheMap = map; | |
| return terrainCache; | |
| } | |
| export function drawTerrain(ctx, state, view) { | |
| const t = state.map.terrain; | |
| if (!t) return; | |
| const canvas = ensureTerrainCache(state.map, t); | |
| const gx0 = view ? Math.max(0, Math.floor(view.minX / t.cell)) : 0; | |
| const gx1 = view ? Math.min(t.cols - 1, Math.floor(view.maxX / t.cell)) : t.cols - 1; | |
| const gy0 = view ? Math.max(0, Math.floor(view.minY / t.cell)) : 0; | |
| const gy1 = view ? Math.min(t.rows - 1, Math.floor(view.maxY / t.cell)) : t.rows - 1; | |
| if (gx1 < gx0 || gy1 < gy0) return; // camera panned fully off the grid β nothing to blit | |
| const cw = gx1 - gx0 + 1, ch = gy1 - gy0 + 1; | |
| ctx.imageSmoothingEnabled = false; // nearest-neighbour scale-up β keeps the hard-edged per-cell look | |
| ctx.drawImage(canvas, gx0, gy0, cw, ch, gx0 * t.cell, gy0 * t.cell, cw * t.cell, ch * t.cell); | |
| } | |