diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 9fb079f5e..25ee2c176 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -160,6 +160,12 @@ "description": "Quickly swipe through backlog issues to triage decisions like assign, needs-info, defer, close, or ignore.", "version": "1.0.2" }, + { + "name": "backrooms-canvas", + "source": "extensions/backrooms-canvas", + "description": "Wander an endless first-person backrooms in a Copilot canvas while agents work; their status ghost-writes on the walls.", + "version": "1.0.0" + }, { "name": "cast-imaging", "source": "plugins/cast-imaging", diff --git a/extensions/backrooms-canvas/.github/plugin/plugin.json b/extensions/backrooms-canvas/.github/plugin/plugin.json new file mode 100644 index 000000000..ef85713cb --- /dev/null +++ b/extensions/backrooms-canvas/.github/plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "backrooms-canvas", + "description": "Wander an endless first-person backrooms in a Copilot canvas while agents work; their status ghost-writes on the walls.", + "version": "1.0.0", + "author": { + "name": "John Haugabook", + "url": "https://github.com/jhauga" + }, + "keywords": [ + "backrooms", + "copilot-canvas", + "interactive-canvas", + "first-person", + "procedural-generation", + "session-breaks" + ], + "logo": "assets/preview.png", + "extensions": "." +} diff --git a/extensions/backrooms-canvas/README.md b/extensions/backrooms-canvas/README.md new file mode 100644 index 000000000..122a81a80 --- /dev/null +++ b/extensions/backrooms-canvas/README.md @@ -0,0 +1,90 @@ +# BackRooms Canvas + +A GitHub Copilot canvas that opens an endless first-person backrooms in the side panel. Yellow-wallpapered halls under humming fluorescent panels, drop-tile ceilings, and worn office carpet, filmed through the shake and grain of a 1990-era handheld camcorder. Somewhere past the fog, something else walks the same halls. + +It is a canvas port of the [BackViews VSCode extension](https://github.com/isocialPractice/vscode-backviews). The world is powered by [cmd-backedges](https://github.com/isocialPractice/cmd-backedges), a procedural infinite maze engine, so every wall, room, and prop is a pure function of the seed: the same seed always rebuilds the same rooms, forever, in every direction. + +While an agent works in the session, the halls start writing back. Its status and streaming responses are scrawled onto the walls in a messy ink script, and a camcorder-style token counter runs under the HUD battery. + +## Files + +- `extension.mjs` — canvas declaration, loopback game server, static asset handling, and agent actions. +- `game/` — the prebuilt game bundle (`webview.js`) and the `index.html` host shim served inside the canvas. +- `materials/` — photo textures (`wallpaper.jpg`, `ceiling.jpg`, `carpet.jpg`) tiled over the procedural atlas. +- `assets/` — app icon and `preview.png` for the extensions gallery. +- `package.json` — declares the Copilot SDK dependency and ESM entry point. +- `copilot-extension.json` — Copilot extension name/version metadata. + +## Prerequisites + +- **Node.js 20.19 or newer**, because the Copilot SDK requires `node ^20.19.0 || >=22.12.0`. +- A WebGL-capable canvas surface (the renderer is raw WebGL). +- The GitHub Copilot app canvas / UI-extensions experiment enabled. + +## Install + +Drop this folder at `~/.copilot/extensions/backrooms-canvas/` for user scope, or in a repository at `.github/extensions/backrooms-canvas/` for project scope. Then install dependencies from inside the copied folder: + +```sh +# User scope +cd ~/.copilot/extensions/backrooms-canvas + +# Or project scope, from the repository root +cd .github/extensions/backrooms-canvas + +npm install +``` + +Reload extensions in the GitHub Copilot app, then open the `backrooms-canvas` canvas. Click the view to capture the mouse and start walking. + +The canvas accepts optional open inputs: + +| Input | Type | Description | +| --- | --- | --- | +| `seed` | number | Maze seed. The same seed always rebuilds the same halls. `0` rolls a random seed. | +| `materialPreset` | string | Wall material set: `classic`, `office`, `pool`, `concrete`, or `panel`. | +| `monsterEnabled` | boolean | Whether something else walks the halls. | + +## Controls + +| Input | Action | +| --- | --- | +| `W` / `S` or `Up` / `Down` | Walk forward / back | +| `A` / `D` | Strafe left / right | +| `Left` / `Right` or `Q` / `E` | Turn | +| `Shift` | Hurry | +| Mouse (after clicking the view) | Look around | +| `M` or `Esc` | Open the in-game menu | + +The in-game menu has Resume, Relocate, Settings, and Help, plus live stats. Settings changed there persist in the canvas via `localStorage`, so your choices survive a reload. + +## Agent actions + +The agent drives the game and feeds the ghost-writer through three actions. They are the canvas equivalent of the original extension's `backviews_reportJob` tool and chat-session mirror. + +- `report_job { status, tokens?, done? }` — report the current job step. The status text is scrawled on the walls and the token count drives the HUD counter. Call it when work starts, again on each new step, and once more with `done: true` when finished. +- `ghost_write { text, tokens?, done? }` — ghost-write a block of text onto the wall ahead, character by character as it grows, like a streaming response. Send the growing text on each call, then once with `done: true` to settle it in place. +- `relocate` — drop the wanderer into a fresh random seed, wiping any writing already on the walls. + +To have the agent narrate itself automatically, reference these actions from a `.github/copilot-instructions.md` so it calls `report_job` on each step of a chat request. + +## How the port works + +The game itself is unchanged: `game/webview.js` is the same self-contained bundle the VSCode extension ships (WebGL renderer, maze engine, camcorder overlay, and wall-writing subsystem in one esbuild IIFE). The bundle was written to talk to a VSCode webview host through `acquireVsCodeApi()` and `window.postMessage`. + +`game/index.html` shims that host: + +- `acquireVsCodeApi()` is backed by `localStorage` for state (seed and player position) and settings persistence. +- The canvas server (`extension.mjs`) runs a loopback HTTP server that serves the bundle and streams agent activity over Server-Sent Events at `/events`. The shim translates those events into the exact `jobStatus`, `chatSession`, and `relocate` messages the bundle already listens for. +- Agent actions broadcast onto that SSE stream, so `report_job` writes on the walls and `ghost_write` streams text just as the VSCode chat mirror did. + +This mirrors the [`arcade-canvas`](../arcade-canvas) architecture: a static frontend served from a loopback server, with the agent driving it through canvas actions. + +## Credits + +- Maze generation: [cmd-backedges](https://github.com/isocialPractice/cmd-backedges) by John Haugabook. +- Original extension: [vscode-backviews](https://github.com/isocialPractice/vscode-backviews). + +## License + +MIT diff --git a/extensions/backrooms-canvas/assets/icon.png b/extensions/backrooms-canvas/assets/icon.png new file mode 100644 index 000000000..6297eaaa8 Binary files /dev/null and b/extensions/backrooms-canvas/assets/icon.png differ diff --git a/extensions/backrooms-canvas/assets/preview.png b/extensions/backrooms-canvas/assets/preview.png new file mode 100644 index 000000000..d8ba572e4 Binary files /dev/null and b/extensions/backrooms-canvas/assets/preview.png differ diff --git a/extensions/backrooms-canvas/copilot-extension.json b/extensions/backrooms-canvas/copilot-extension.json new file mode 100644 index 000000000..56cd2c105 --- /dev/null +++ b/extensions/backrooms-canvas/copilot-extension.json @@ -0,0 +1,4 @@ +{ + "name": "backrooms-canvas", + "version": 1 +} diff --git a/extensions/backrooms-canvas/extension.mjs b/extensions/backrooms-canvas/extension.mjs new file mode 100644 index 000000000..0cddfa0d0 --- /dev/null +++ b/extensions/backrooms-canvas/extension.mjs @@ -0,0 +1,472 @@ +/** + * Copilot canvas entry point for BackRooms. + * + * The game itself is the prebuilt browser bundle at game/webview.js (an + * esbuild IIFE that already carries the WebGL renderer, the procedural maze + * engine, and the whole first-person world). This file is only the host: a + * tiny local HTTP server that serves that bundle plus its photo materials, + * and a canvas registration that lets an agent drive the game through actions. + * + * The bundle was written for a VSCode webview and talks to its host through + * `acquireVsCodeApi()` + `window.postMessage`. game/index.html shims that API + * against this server's Server-Sent Events stream, so the same bundle runs + * unchanged inside the canvas. The message shapes below match the bundle's + * expectations exactly (see the original src/shared/settings.ts): + * + * host -> game : { type: 'config', settings } + * { type: 'jobStatus', job } // CopilotJob + * { type: 'chatSession', session } // ChatSessionSnapshot + * { type: 'relocate' } + * game -> host : { type: 'ready' } // handled in the shim + * { type: 'updateSetting', key, value } // handled in the shim + */ +import { createReadStream } from "node:fs"; +import { readFile, stat } from "node:fs/promises"; +import { createServer } from "node:http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { CanvasError, createCanvas, joinSession } from "@github/copilot-sdk/extension"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const gameRoot = path.join(__dirname, "game"); +const materialsRoot = path.join(__dirname, "materials"); +const assetsRoot = path.join(__dirname, "assets"); +const indexPath = path.join(gameRoot, "index.html"); + +/** + * Default settings, mirrored from the bundle's own DEFAULT_SETTINGS. The shim + * merges any per-open overrides and the player's saved menu choices on top of + * these before handing the game its first `config` message. + */ +const DEFAULT_SETTINGS = { + seed: 0, + moveSpeed: 2.2, + renderDistance: 14, + cameraShake: true, + filmGrain: true, + vhsHud: true, + furniture: true, + wallpaperShifts: false, + mouseLook: true, + invertTurn: false, + invertStrafe: false, + invertForward: false, + materialPreset: "classic", + materialHueShift: 0, + materialBrightness: 1, + monsterEnabled: true, + monsterSpeed: 2.6, + monsterSpawnMin: 1, + monsterSpawnMax: 5, + monsterForm: "random", + copilotGhostWriter: true, +}; + +const MATERIAL_PRESETS = ["classic", "office", "pool", "concrete", "panel"]; +const MONSTER_FORMS = ["spider", "humanoid", "cloud", "random"]; + +/** Idle Copilot job; the walls stay quiet and the HUD counter fades out. */ +const IDLE_JOB = { working: false, status: "", tokens: 0 }; + +const servers = new Map(); + +function contentType(filePath) { + switch (path.extname(filePath).toLowerCase()) { + case ".html": + return "text/html; charset=utf-8"; + case ".js": + return "text/javascript; charset=utf-8"; + case ".css": + return "text/css; charset=utf-8"; + case ".json": + return "application/json; charset=utf-8"; + case ".map": + return "application/json; charset=utf-8"; + case ".png": + return "image/png"; + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".svg": + return "image/svg+xml"; + case ".webp": + return "image/webp"; + default: + return "application/octet-stream"; + } +} + +/** Resolves a request path under a root, refusing anything that escapes it. */ +function resolveUnder(root, requestPath) { + const resolved = path.resolve(root, `.${requestPath}`); + if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) { + throw new CanvasError("invalid_path", "Requested path is outside the backrooms assets."); + } + return resolved; +} + +function sendJson(res, value) { + res.writeHead(200, { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + }); + res.end(JSON.stringify(value)); +} + +function sendNotFound(res) { + res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); + res.end("Not found"); +} + +function sendSse(res, event, data) { + res.write(`event: ${event}\n`); + res.write(`data: ${JSON.stringify(data)}\n\n`); +} + +function broadcast(entry, event, data) { + for (const client of entry.clients) { + sendSse(client, event, data); + } +} + +function randomSeed() { + return Math.floor(Math.random() * 999_999) + 1; +} + +/** Coerces one enum-ish value, falling back to the default when unknown. */ +function pickEnum(value, allowed, fallback) { + return typeof value === "string" && allowed.includes(value) ? value : fallback; +} + +/** Coerces a finite number into [min, max], or returns the fallback. */ +function clampNumber(value, min, max, fallback) { + if (typeof value !== "number" || !Number.isFinite(value)) { + return fallback; + } + return Math.min(max, Math.max(min, value)); +} + +/** + * Turns arbitrary open-input into a partial settings override the shim can + * merge over the defaults. Only the settings that make sense to preset from an + * agent are honored; everything else stays on its default or saved value. + */ +function normalizeOverrides(input) { + const overrides = {}; + if (!input || typeof input !== "object") { + return overrides; + } +if (input.seed !== undefined) { + const seed = Math.trunc(clampNumber(input.seed, 0, Number.MAX_SAFE_INTEGER, 0)); + overrides.seed = seed === 0 ? randomSeed() : seed; + } + if (input.materialPreset !== undefined) { + overrides.materialPreset = pickEnum(input.materialPreset, MATERIAL_PRESETS, "classic"); + } + if (input.monsterEnabled !== undefined) { + overrides.monsterEnabled = input.monsterEnabled === true; + } + if (input.monsterForm !== undefined) { + overrides.monsterForm = pickEnum(input.monsterForm, MONSTER_FORMS, "random"); + } + if (input.copilotGhostWriter !== undefined) { + overrides.copilotGhostWriter = input.copilotGhostWriter !== false; + } + return overrides; +} + +/** Coerces report_job input into a CopilotJob the game understands. */ +function normalizeJob(input) { + if (!input || typeof input !== "object") { + return { ...IDLE_JOB }; + } + const done = input.done === true; + return { + working: !done, + status: typeof input.status === "string" ? input.status.slice(0, 120) : "", + tokens: + typeof input.tokens === "number" && Number.isFinite(input.tokens) + ? Math.max(0, Math.floor(input.tokens)) + : 0, + }; +} + +/** + * Coerces ghost_write input into a ChatSessionSnapshot. The game ghost-writes + * `current` onto the wall ahead as it grows, and the token count drives the + * HUD odometer. `done` settles the writing in place and stops the counter. + */ +function normalizeSession(input) { + const text = typeof input?.text === "string" ? input.text : ""; + const done = input?.done === true; + const tokens = + typeof input?.tokens === "number" && Number.isFinite(input.tokens) + ? Math.max(0, Math.floor(input.tokens)) + : Math.ceil(text.length / 4); + return { working: !done && text.length > 0, history: [], current: text, tokens }; +} + +async function renderIndex(entry) { + const html = await readFile(indexPath, "utf8"); + // Handed to the shim so it can build the game's first `config` message and + // replay any job that is already running when the panel opens. + const init = { + defaults: DEFAULT_SETTINGS, + overrides: entry.overrides, + materials: { + wallpaper: "/materials/wallpaper.jpg", + ceiling: "/materials/ceiling.jpg", + carpet: "/materials/carpet.jpg", + }, + job: entry.job, + session: entry.session, + }; +return html.replace("__BACKROOMS_INIT__", JSON.stringify(init).replace(/ undefined); + if (!fileStat?.isFile()) { + sendNotFound(res); + return; + } + res.writeHead(200, { + "content-type": contentType(filePath), + "cache-control": "no-cache", + }); + const stream = createReadStream(filePath); + stream.on("error", () => { + if (!res.headersSent) { + sendNotFound(res); + } else { + res.destroy(); + } + }); + stream.pipe(res); +} + +async function handleRequest(entry, req, res) { + const url = new URL(req.url ?? "/", entry.url); + + if (url.pathname === "/events") { + res.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache", + connection: "keep-alive", + }); + entry.clients.add(res); + // Catch a fresh (or reconnecting) client up to the live state. + sendSse(res, "jobStatus", { job: entry.job }); + if (entry.session) { + sendSse(res, "chatSession", { session: entry.session }); + } + req.on("close", () => entry.clients.delete(res)); + return; + } + + // The shim reports explicit setting changes (menu edits, relocate): the + // per-open override for that key stops applying, so a reload keeps the + // player's choice instead of replaying the stale override. + if (req.method === "DELETE" && url.pathname.startsWith("/override/")) { + delete entry.overrides[decodeURIComponent(url.pathname.slice("/override/".length))]; + res.writeHead(204); + res.end(); + return; + } + + if (url.pathname === "/favicon.ico") { + await streamFile(res, path.join(assetsRoot, "icon.png")); + return; + } + + try { + if (url.pathname === "/" || url.pathname === "/index.html") { + res.writeHead(200, { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-cache", + }); + res.end(await renderIndex(entry)); + return; + } + + const staticPath = url.pathname.startsWith("/materials/") + ? resolveUnder(materialsRoot, url.pathname.slice("/materials".length)) + : url.pathname.startsWith("/assets/") + ? resolveUnder(assetsRoot, url.pathname.slice("/assets".length)) + : resolveUnder(gameRoot, url.pathname); + await streamFile(res, staticPath); + } catch (error) { + if (error instanceof CanvasError) { + res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }); + res.end(error.message); + return; + } + throw error; + } +} + +async function startServer(instanceId, overrides) { + const entry = { + clients: new Set(), + overrides, + job: { ...IDLE_JOB }, + session: null, + server: undefined, + url: undefined, + }; + const server = createServer((req, res) => { + handleRequest(entry, req, res).catch((error) => { + res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }); + res.end(error instanceof Error ? error.message : "Backrooms canvas server error"); + }); + }); + entry.server = server; + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + entry.url = `http://127.0.0.1:${port}/`; + servers.set(instanceId, entry); + return entry; +} + +function getOpenEntry(instanceId) { + const entry = servers.get(instanceId); + if (!entry) { + throw new CanvasError("backrooms_not_open", "Open the Backrooms canvas before invoking this action."); + } + return entry; +} + +await joinSession({ + canvases: [ + createCanvas({ + id: "backrooms-canvas", + displayName: "BackRooms", + description: + "An endless first-person backrooms to wander while agents work. The agent's status ghost-writes on the walls.", + inputSchema: { + type: "object", + properties: { + seed: { + type: "number", + description: "Maze seed. The same seed always rebuilds the same halls. 0 rolls a random seed.", + }, + materialPreset: { + type: "string", + enum: MATERIAL_PRESETS, + description: "Wall material set to start with.", + }, + monsterEnabled: { + type: "boolean", + description: "Whether something else walks the halls.", + }, + }, + additionalProperties: false, + }, + actions: [ + { + name: "report_job", + description: + "Report your current job status to the backrooms. Call it when you start a chat request, again on each new step, and once more with done=true when finished. The status text is scrawled on the walls and the token count drives a camcorder-style HUD counter.", + inputSchema: { + type: "object", + properties: { + status: { + type: "string", + description: + "Short status text to scrawl on the walls (e.g. 'reading the codebase', 'rewriting the parser').", + }, + tokens: { + type: "number", + description: "Approximate tokens consumed by the current job so far.", + }, + done: { + type: "boolean", + description: "Set true when the job is finished; the walls stop updating and the counter fades out.", + }, + }, + required: ["status"], + additionalProperties: false, + }, + handler: (ctx) => { + const entry = getOpenEntry(ctx.instanceId); + entry.job = normalizeJob(ctx.input); + broadcast(entry, "jobStatus", { job: entry.job }); + return { job: entry.job }; + }, + }, + { + name: "ghost_write", + description: + "Ghost-write a block of text onto the walls ahead, character by character as it grows, like a streaming chat response. Call repeatedly with the full text so far, then once with done=true to settle it in place.", + inputSchema: { + type: "object", + properties: { + text: { + type: "string", + description: "The full response text so far. Send the growing text on each call; the walls reveal the new characters.", + }, + tokens: { + type: "number", + description: "Approximate tokens of the response so far. Defaults to text length / 4.", + }, + done: { + type: "boolean", + description: "Set true when the response is complete; the writing settles onto the wall and the counter stops.", + }, + }, + required: ["text"], + additionalProperties: false, + }, + handler: (ctx) => { + const entry = getOpenEntry(ctx.instanceId); + entry.session = normalizeSession(ctx.input); + broadcast(entry, "chatSession", { session: entry.session }); + return { working: entry.session.working, tokens: entry.session.tokens }; + }, + }, + { + name: "relocate", + description: "Drop the wanderer into a fresh random seed, wiping any writing already on the walls.", + handler: (ctx) => { + const entry = getOpenEntry(ctx.instanceId); + const seed = randomSeed(); + // The walls are wiped on relocate, so drop the job and + // session snapshots too or a reload would replay them + // onto the fresh maze. + entry.job = { ...IDLE_JOB }; + entry.session = null; + broadcast(entry, "jobStatus", { job: entry.job }); + broadcast(entry, "relocate", { seed }); + return { seed }; + }, + }, + ], + open: async (ctx) => { + const overrides = normalizeOverrides(ctx.input); + let entry = servers.get(ctx.instanceId); + if (!entry) { + entry = await startServer(ctx.instanceId, overrides); + } else { + entry.overrides = { ...entry.overrides, ...overrides }; + } + return { + title: "BackRooms", + status: entry.job.working && entry.job.status ? entry.job.status : "Wandering", + url: entry.url, + }; + }, + onClose: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (!entry) return; + servers.delete(ctx.instanceId); + for (const client of entry.clients) { + client.end(); + } + await new Promise((resolve) => entry.server.close(() => resolve())); + }, + }), + ], +}); diff --git a/extensions/backrooms-canvas/game/index.html b/extensions/backrooms-canvas/game/index.html new file mode 100644 index 000000000..29031550c --- /dev/null +++ b/extensions/backrooms-canvas/game/index.html @@ -0,0 +1,125 @@ + + + + + + + BackRooms + + + +
+ + + + diff --git a/extensions/backrooms-canvas/game/webview.js b/extensions/backrooms-canvas/game/webview.js new file mode 100644 index 000000000..b13598f54 --- /dev/null +++ b/extensions/backrooms-canvas/game/webview.js @@ -0,0 +1,4013 @@ +"use strict"; +(() => { + // src/shared/settings.ts + var IDLE_JOB = { working: false, status: "", tokens: 0 }; + var DEFAULT_SETTINGS = { + seed: 0, + moveSpeed: 2.2, + renderDistance: 14, + cameraShake: true, + filmGrain: true, + vhsHud: true, + furniture: true, + wallpaperShifts: false, + mouseLook: true, + invertTurn: false, + invertStrafe: false, + invertForward: false, + materialPreset: "classic", + materialHueShift: 0, + materialBrightness: 1, + monsterEnabled: true, + monsterSpeed: 2.6, + monsterSpawnMin: 1, + monsterSpawnMax: 5, + monsterForm: "random", + copilotGhostWriter: true + }; + + // src/webview/film.ts + var GRAIN_TILE = 160; + var GRAIN_VARIANTS = 5; + var GRAIN_FPS = 18; + var JOB_LINGER_MS = 6e3; + var FilmOverlay = class { + constructor(canvas) { + this.canvas = canvas; + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("2D overlay context unavailable"); + } + this.ctx = ctx; + for (let v = 0; v < GRAIN_VARIANTS; v++) { + this.grainTiles.push(makeGrainTile(v)); + } + } + grainEnabled = true; + hudEnabled = true; + tokenCounterEnabled = true; + ctx; + grainTiles = []; + lastGrainAt = 0; + grainIndex = 0; + tearY = -1; + tearUntil = 0; + burstUntil = 0; + startedAt = Date.now(); + job = { ...IDLE_JOB }; + jobLingerUntil = 0; + shownTokens = 0; + lastHudAt = 0; + /** Cuts the picture to heavy static for a moment (the catch effect). */ + burst(now, durationMs = 1300) { + this.burstUntil = now + durationMs; + } + /** + * Feeds the HUD the latest Copilot job snapshot. The token count drives the + * HUD counter, which rolls toward the target value and lingers after the + * job finishes. + */ + setJob(job) { + this.job = job; + } + render(now) { + const canvas = this.canvas; + const w = canvas.clientWidth; + const h = canvas.clientHeight; + if (w === 0 || h === 0) { + return; + } + if (canvas.width !== w || canvas.height !== h) { + canvas.width = w; + canvas.height = h; + } + const ctx = this.ctx; + ctx.clearRect(0, 0, w, h); + if (this.burstUntil > now) { + ctx.globalAlpha = 0.94; + for (let y = 0; y < h; y += GRAIN_TILE) { + for (let x = 0; x < w; x += GRAIN_TILE) { + ctx.drawImage(this.grainTiles[Math.floor(Math.random() * GRAIN_VARIANTS)], x, y); + } + } + ctx.globalAlpha = 1; + ctx.font = 'bold 28px "Courier New", monospace'; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillStyle = "rgba(20, 20, 20, 0.85)"; + ctx.fillRect(w / 2 - 130, h / 2 - 28, 260, 56); + ctx.fillStyle = "#f0f2f3"; + ctx.fillText("SIGNAL LOST", w / 2, h / 2); + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + return; + } + if (!this.grainEnabled && !this.hudEnabled) { + return; + } + if (this.grainEnabled) { + if (now - this.lastGrainAt > 1e3 / GRAIN_FPS) { + this.lastGrainAt = now; + this.grainIndex = Math.floor(Math.random() * GRAIN_VARIANTS); + if (this.tearUntil < now && Math.random() < 0.03) { + this.tearY = Math.random() * h; + this.tearUntil = now + 90 + Math.random() * 160; + } + } + const tile = this.grainTiles[this.grainIndex]; + ctx.globalAlpha = 0.11; + const ox = Math.floor(Math.random() * GRAIN_TILE); + const oy = Math.floor(Math.random() * GRAIN_TILE); + for (let y = -oy; y < h; y += GRAIN_TILE) { + for (let x = -ox; x < w; x += GRAIN_TILE) { + ctx.drawImage(tile, x, y); + } + } + ctx.globalAlpha = 1; + const grad = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.42, w / 2, h / 2, Math.max(w, h) * 0.72); + grad.addColorStop(0, "rgba(0, 0, 0, 0)"); + grad.addColorStop(1, "rgba(0, 0, 0, 0.42)"); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, w, h); + const bandY = now / 34 % (h + 160) - 160; + const band = ctx.createLinearGradient(0, bandY, 0, bandY + 160); + band.addColorStop(0, "rgba(255, 255, 255, 0)"); + band.addColorStop(0.5, "rgba(255, 255, 255, 0.025)"); + band.addColorStop(1, "rgba(255, 255, 255, 0)"); + ctx.fillStyle = band; + ctx.fillRect(0, bandY, w, 160); + if (this.tearUntil > now && this.tearY >= 0) { + ctx.fillStyle = "rgba(220, 220, 210, 0.10)"; + ctx.fillRect(0, this.tearY, w, 3); + ctx.fillStyle = "rgba(0, 0, 0, 0.16)"; + ctx.fillRect(0, this.tearY + 3, w, 2); + } + } + if (this.hudEnabled) { + this.renderHud(now, w, h); + } + } + renderHud(now, w, h) { + const ctx = this.ctx; + const pad = Math.round(Math.min(w, h) * 0.045) + 8; + ctx.font = '16px "Courier New", monospace'; + ctx.textBaseline = "top"; + ctx.fillStyle = "rgba(235, 235, 225, 0.9)"; + ctx.shadowColor = "rgba(0, 0, 0, 0.8)"; + ctx.shadowBlur = 3; + if (Math.floor(now / 700) % 2 === 0) { + ctx.fillStyle = "rgba(255, 70, 60, 0.95)"; + ctx.beginPath(); + ctx.arc(pad + 7, pad + 8, 6, 0, Math.PI * 2); + ctx.fill(); + } + ctx.fillStyle = "rgba(235, 235, 225, 0.9)"; + ctx.fillText("REC", pad + 22, pad); + const elapsed = Math.floor((Date.now() - this.startedAt) / 1e3); + const counter = `${String(Math.floor(elapsed / 3600)).padStart(1, "0")}:${String( + Math.floor(elapsed / 60 % 60) + ).padStart(2, "0")}:${String(elapsed % 60).padStart(2, "0")}`; + ctx.textAlign = "right"; + ctx.fillText(`SP ${counter}`, w - pad, pad); + ctx.strokeStyle = "rgba(235, 235, 225, 0.9)"; + ctx.lineWidth = 1.5; + ctx.strokeRect(w - pad - 34, pad + 24, 30, 12); + ctx.fillRect(w - pad - 3, pad + 27, 3, 6); + ctx.fillRect(w - pad - 32, pad + 26, 8, 8); + ctx.fillRect(w - pad - 22, pad + 26, 8, 8); + this.renderTokenCounter(now, w, pad); + ctx.textAlign = "left"; + const stamp = /* @__PURE__ */ new Date(); + const months = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"]; + const hr = stamp.getHours() % 12 === 0 ? 12 : stamp.getHours() % 12; + const ampm = stamp.getHours() < 12 ? "AM" : "PM"; + const text = `${months[stamp.getMonth()]} ${String(stamp.getDate()).padStart(2, "0")} 1990 ${ampm} ${hr}:${String( + stamp.getMinutes() + ).padStart(2, "0")}`; + ctx.fillStyle = "rgba(240, 214, 130, 0.92)"; + ctx.fillText(text, pad, h - pad - 18); + ctx.shadowBlur = 0; + ctx.textAlign = "left"; + } + /** + * Dynamic token counter for the current Copilot job, tucked under the + * battery icon like one more line of a 1990s Sony camcorder's on-screen + * display: blocky monospace digits that roll toward the live count, with a + * blinking access mark while the job is running. + */ + renderTokenCounter(now, w, pad) { + if (!this.tokenCounterEnabled) { + return; + } + if (this.job.working) { + this.jobLingerUntil = now + JOB_LINGER_MS; + } else if (now > this.jobLingerUntil) { + this.shownTokens = 0; + return; + } + const dt = Math.min(0.2, (now - this.lastHudAt) / 1e3 || 0.016); + this.lastHudAt = now; + const target = this.job.tokens; + const gap = target - this.shownTokens; + this.shownTokens = Math.abs(gap) < 1 ? target : this.shownTokens + gap * Math.min(1, dt * 4); + const ctx = this.ctx; + const digits = String(Math.min(999999, Math.round(this.shownTokens))).padStart(6, "0"); + ctx.font = '16px "Courier New", monospace'; + ctx.textAlign = "right"; + ctx.fillStyle = "rgba(235, 235, 225, 0.9)"; + ctx.fillText(`TKN ${digits}`, w - pad, pad + 44); + if (this.job.working && Math.floor(now / 450) % 2 === 0) { + ctx.fillRect(w - pad - 108, pad + 46, 8, 11); + } + ctx.textAlign = "left"; + } + }; + function makeGrainTile(seed) { + const tile = document.createElement("canvas"); + tile.width = tile.height = GRAIN_TILE; + const ctx = tile.getContext("2d"); + const image = ctx.createImageData(GRAIN_TILE, GRAIN_TILE); + let state = 2654435769 ^ seed * 2246822507; + const next = () => { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + state >>>= 0; + return state / 4294967295; + }; + for (let i = 0; i < image.data.length; i += 4) { + const v = Math.floor(next() * 255); + image.data[i] = v; + image.data[i + 1] = v; + image.data[i + 2] = v; + image.data[i + 3] = 255; + } + ctx.putImageData(image, 0, 0); + return tile; + } + + // ../cmd-backedges/src/rng.ts + function mulberry32(seed) { + let state = seed >>> 0; + return { + next() { + state = state + 1831565813 | 0; + let t = state; + t = Math.imul(t ^ t >>> 15, t | 1); + t ^= t + Math.imul(t ^ t >>> 7, t | 61); + return ((t ^ t >>> 14) >>> 0) / 4294967296; + } + }; + } + var defaultRngFactory = mulberry32; + function mix32(value) { + let h = value | 0; + h ^= h >>> 16; + h = Math.imul(h, 2246822507); + h ^= h >>> 13; + h = Math.imul(h, 3266489909); + h ^= h >>> 16; + return h >>> 0; + } + function hashCombine(hash, value) { + let h = (hash ^ mix32(value | 0)) >>> 0; + h = Math.imul(h, 2654435761) >>> 0; + h = (h << 13 | h >>> 19) >>> 0; + return h >>> 0; + } + function hashCoords(seed, x, y, salt = 0) { + let h = mix32(seed | 0); + h = hashCombine(h, x); + h = hashCombine(h, y); + h = hashCombine(h, salt); + return h >>> 0; + } + function unitFromHash(hash) { + return (hash >>> 0) / 4294967296; + } + + // ../cmd-backedges/src/config.ts + var DEFAULT_CONTROLS = { + moveNorth: ["w", "ArrowUp"], + moveSouth: ["s", "ArrowDown"], + moveEast: ["d", "ArrowRight"], + moveWest: ["a", "ArrowLeft"], + exit: ["Ctrl+C"] + }; + var DEFAULT_CONFIG = { + seed: 1, + cellSize: 1, + width: { min: 0.6, max: 1 }, + height: { min: 0.6, max: 1 }, + depth: { min: 2.5, max: 4 }, + passageDensity: 0.34, + minConnections: 2, + roomFrequency: 0.09, + hallFrequency: 11e-4, + hallSize: { min: 2, max: 4 }, + propFrequency: 0.082, + atriumFrequency: 14e-5, + rng: defaultRngFactory, + controls: DEFAULT_CONTROLS, + cacheLimit: 4096 + }; + function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); + } + function normalizeRange(range, fallback) { + if (!range) { + return { ...fallback }; + } + const min = Number.isFinite(range.min) ? range.min : fallback.min; + const max = Number.isFinite(range.max) ? range.max : fallback.max; + return min <= max ? { min, max } : { min: max, max: min }; + } + function normalizeIntRange(range, fallback, lowerBound) { + const base = normalizeRange(range, fallback); + const min = Math.max(lowerBound, Math.trunc(base.min)); + const max = Math.max(min, Math.trunc(base.max)); + return { min, max }; + } + function resolveConfig(partial = {}) { + const seed = Number.isFinite(partial.seed) ? Math.trunc(partial.seed) : DEFAULT_CONFIG.seed; + const cellSize = Number.isFinite(partial.cellSize) && partial.cellSize > 0 ? partial.cellSize : DEFAULT_CONFIG.cellSize; + return Object.freeze({ + seed, + cellSize, + width: normalizeRange(partial.width, DEFAULT_CONFIG.width), + height: normalizeRange(partial.height, DEFAULT_CONFIG.height), + depth: normalizeRange(partial.depth, DEFAULT_CONFIG.depth), + passageDensity: Number.isFinite(partial.passageDensity) ? clamp(partial.passageDensity, 0, 1) : DEFAULT_CONFIG.passageDensity, + minConnections: Number.isFinite(partial.minConnections) ? Math.trunc(clamp(partial.minConnections, 0, 4)) : DEFAULT_CONFIG.minConnections, + roomFrequency: Number.isFinite(partial.roomFrequency) ? clamp(partial.roomFrequency, 0, 1) : DEFAULT_CONFIG.roomFrequency, + hallFrequency: Number.isFinite(partial.hallFrequency) ? clamp(partial.hallFrequency, 0, 1) : DEFAULT_CONFIG.hallFrequency, + hallSize: normalizeIntRange(partial.hallSize, DEFAULT_CONFIG.hallSize, 1), + propFrequency: Number.isFinite(partial.propFrequency) ? clamp(partial.propFrequency, 0, 1) : DEFAULT_CONFIG.propFrequency, + atriumFrequency: Number.isFinite(partial.atriumFrequency) ? clamp(partial.atriumFrequency, 0, 1) : DEFAULT_CONFIG.atriumFrequency, + rng: typeof partial.rng === "function" ? partial.rng : DEFAULT_CONFIG.rng, + controls: { ...DEFAULT_CONTROLS, ...partial.controls ?? {} }, + cacheLimit: Number.isFinite(partial.cacheLimit) && partial.cacheLimit >= 0 ? Math.trunc(partial.cacheLimit) : DEFAULT_CONFIG.cacheLimit + }); + } + + // ../cmd-backedges/src/types.ts + var DIRECTIONS = ["north", "east", "south", "west"]; + var SURFACE_TYPES = [ + "drywall", + "wallpaper", + "paneling", + "concrete", + "tile", + "carpet" + ]; + var FEATURE_KINDS = ["column", "furniture"]; + function opposite(direction) { + switch (direction) { + case "north": + return "south"; + case "south": + return "north"; + case "east": + return "west"; + case "west": + return "east"; + } + } + function step(direction) { + switch (direction) { + case "north": + return { dx: 0, dy: -1 }; + case "south": + return { dx: 0, dy: 1 }; + case "east": + return { dx: 1, dy: 0 }; + case "west": + return { dx: -1, dy: 0 }; + } + } + + // ../cmd-backedges/src/generator.ts + var SALT = { + /** Openness of the vertical edge east of a cell. */ + edgeVertical: 1, + /** Openness of the horizontal edge south of a cell. */ + edgeHorizontal: 2, + /** Coarse room/corridor value-noise lattice. */ + roomNoise: 3, + width: 16, + height: 17, + depth: 18, + /** Whether a cell anchors a rectangular hall. */ + hallAnchor: 48, + /** Hall width in cells. */ + hallWidth: 49, + /** Hall height in cells. */ + hallHeight: 50, + /** Whether a cell anchors a fully open atrium. */ + atriumAnchor: 80, + /** Atrium base width draw. */ + atriumWidth: 81, + /** Atrium base height draw. */ + atriumHeight: 82, + /** Atrium width variance draw. */ + atriumVarWidth: 83, + /** Atrium height variance draw. */ + atriumVarHeight: 84, + /** Whether an open cell holds an interior prop. */ + prop: 64, + /** Which kind of prop a cell holds. */ + propKind: 65, + /** Prop position within its cell. */ + propPositionX: 66, + propPositionY: 67, + /** Prop footprint size. */ + propSize: 68, + /** Stable per-prop cosmetic seed. */ + propVariant: 69, + /** Seed channel for cosmetic edge metadata. */ + material: 32 + }; + var ROOM_LATTICE = 3; + var ATRIUM_SIZE_FACTOR = 1.05; + var ATRIUM_SIZE_VARIANCE = 0.1; + function smoothstep(t) { + return t * t * (3 - 2 * t); + } + function lerp(a, b, t) { + return a + (b - a) * t; + } + function edgeKey(cx, cy, direction) { + switch (direction) { + case "east": + return { ex: cx, ey: cy, salt: SALT.edgeVertical }; + case "west": + return { ex: cx - 1, ey: cy, salt: SALT.edgeVertical }; + case "south": + return { ex: cx, ey: cy, salt: SALT.edgeHorizontal }; + case "north": + return { ex: cx, ey: cy - 1, salt: SALT.edgeHorizontal }; + } + } + var MazeGenerator = class _MazeGenerator { + /** + * @param config - Partial configuration; omitted fields take their defaults. + */ + constructor(config = {}) { + this.cache = /* @__PURE__ */ new Map(); + this.hits = 0; + this.misses = 0; + this.evictions = 0; + this.config = resolveConfig(config); + } + /** String key for the cache and lookups. */ + static key(cx, cy) { + return `${cx},${cy}`; + } + /** + * Returns the cell at the given integer coordinates, computing it on first + * access and serving it from the LRU cache thereafter. Repeated calls return + * structurally identical cells regardless of cache state. + */ + getCell(cx, cy) { + const key = _MazeGenerator.key(cx, cy); + const cached = this.cache.get(key); + if (cached) { + this.hits++; + this.cache.delete(key); + this.cache.set(key, cached); + return cached; + } + this.misses++; + const cell = this.computeCell(cx, cy); + this.cache.set(key, cell); + this.evictIfNeeded(); + return cell; + } + /** Returns every cell whose coordinates fall in the inclusive rectangle. */ + getRegion(minCx, minCy, maxCx, maxCy) { + const cells = []; + for (let cy = minCy; cy <= maxCy; cy++) { + for (let cx = minCx; cx <= maxCx; cx++) { + cells.push(this.getCell(cx, cy)); + } + } + return cells; + } + /** + * Whether the player may cross from `(cx, cy)` in `direction`. + * + * This is the authoritative movement test and is symmetric: crossing an edge + * from either side yields the same answer. An edge is open when any of the + * following holds, all of which both adjacent cells compute identically: + * + * - both cells belong to the same atrium (its fully open interior); + * - both cells belong to the same rectangular hall (its open interior); + * - both cells are rooms (interior of a merged blob-shaped open area); + * - the edge is open by base density; or + * - either cell braids the edge open to reach {@link MazeConfig.minConnections}. + */ + isPassable(cx, cy, direction) { + const { dx, dy } = step(direction); + const nx = cx + dx; + const ny = cy + dy; + const atrium = this.atriumIdOf(cx, cy); + if (atrium !== null && atrium === this.atriumIdOf(nx, ny)) { + return true; + } + if (atrium === null && this.atriumIdOf(nx, ny) === null) { + const hall = this.hallIdOf(cx, cy); + if (hall !== null && hall === this.hallIdOf(nx, ny)) { + return true; + } + if (this.isRoom(cx, cy) && this.isRoom(nx, ny)) { + return true; + } + } + if (this.baseOpen(cx, cy, direction)) { + return true; + } + if (this.forcedDirections(cx, cy).includes(direction)) { + return true; + } + return this.forcedDirections(nx, ny).includes(opposite(direction)); + } + /** Whether a cell belongs to a blob-shaped room. */ + isRoom(cx, cy) { + return this.classify(cx, cy) === "room"; + } + /** Whether a cell belongs to a prop-bearing rectangular hall. */ + isHall(cx, cy) { + return this.classify(cx, cy) === "hall"; + } + /** Whether a cell belongs to a fully open atrium. */ + isAtrium(cx, cy) { + return this.classify(cx, cy) === "atrium"; + } + /** Empties the cache without affecting determinism of future cells. */ + clearCache() { + this.cache.clear(); + } + /** Returns a snapshot of cache behavior. */ + stats() { + return { + size: this.cache.size, + limit: this.config.cacheLimit, + hits: this.hits, + misses: this.misses, + evictions: this.evictions + }; + } + // --- Structural generation (seed-only, no pluggable RNG) ----------------- + /** Raw openness of the shared edge before braiding is applied. */ + baseOpen(cx, cy, direction) { + const { ex, ey, salt } = edgeKey(cx, cy, direction); + return unitFromHash(hashCoords(this.config.seed, ex, ey, salt)) < this.config.passageDensity; + } + /** Raw hash of a cell's edge in a direction, used to order braid choices. */ + edgeHash(cx, cy, direction) { + const { ex, ey, salt } = edgeKey(cx, cy, direction); + return hashCoords(this.config.seed, ex, ey, salt); + } + /** + * Directions a cell forcibly opens to reach {@link MazeConfig.minConnections} + * passages. The lowest-hash sealed edges are chosen first. Depends only on the + * cell's own four edges, so both sides of any edge agree on the result, which + * keeps passability symmetric. + */ + forcedDirections(cx, cy) { + const min = this.config.minConnections; + if (min <= 0) { + return []; + } + let openCount = 0; + const sealed = []; + for (const direction of DIRECTIONS) { + if (this.baseOpen(cx, cy, direction)) { + openCount++; + } else { + sealed.push({ direction, hash: this.edgeHash(cx, cy, direction) }); + } + } + const need = min - openCount; + if (need <= 0) { + return []; + } + sealed.sort((a, b) => a.hash - b.hash); + return sealed.slice(0, need).map((entry) => entry.direction); + } + /** Coarse, smoothed value-noise field driving room clustering. */ + roomNoise(cx, cy) { + const gx = Math.floor(cx / ROOM_LATTICE); + const gy = Math.floor(cy / ROOM_LATTICE); + const fx = smoothstep(cx / ROOM_LATTICE - gx); + const fy = smoothstep(cy / ROOM_LATTICE - gy); + const corner = (ix, iy) => unitFromHash(hashCoords(this.config.seed, ix, iy, SALT.roomNoise)); + const top = lerp(corner(gx, gy), corner(gx + 1, gy), fx); + const bottom = lerp(corner(gx, gy + 1), corner(gx + 1, gy + 1), fx); + return lerp(top, bottom, fy); + } + /** + * Width and height (in cells) of the hall anchored at `(ax, ay)`, or `null` + * when the anchor does not spawn one. Pure function of the anchor coordinates. + */ + hallSpawn(ax, ay) { + if (this.config.hallFrequency <= 0) { + return null; + } + if (unitFromHash(hashCoords(this.config.seed, ax, ay, SALT.hallAnchor)) >= this.config.hallFrequency) { + return null; + } + const { min, max } = this.config.hallSize; + const span = max - min + 1; + const pick = (salt) => min + Math.min(span - 1, Math.floor(unitFromHash(hashCoords(this.config.seed, ax, ay, salt)) * span)); + return { w: pick(SALT.hallWidth), h: pick(SALT.hallHeight) }; + } + /** + * Canonical identifier of the hall covering a cell, or `null` when the cell is + * not in a hall. Only anchors within {@link MazeConfig.hallSize}`.max` of the + * cell can possibly cover it, so the scan is bounded and stateless. When + * rectangles overlap, the lowest-hash anchor wins, so both sides of any edge + * agree on hall membership. + */ + hallIdOf(cx, cy) { + const maxSide = this.config.hallSize.max; + let best = null; + for (let ay = cy - (maxSide - 1); ay <= cy; ay++) { + for (let ax = cx - (maxSide - 1); ax <= cx; ax++) { + const spawn = this.hallSpawn(ax, ay); + if (!spawn) { + continue; + } + if (cx >= ax && cx < ax + spawn.w && cy >= ay && cy < ay + spawn.h) { + const hash = hashCoords(this.config.seed, ax, ay, SALT.hallAnchor); + if (best === null || hash < best.hash) { + best = { ax, ay, hash }; + } + } + } + } + return best ? `${best.ax},${best.ay}` : null; + } + /** Largest possible atrium side length in cells, bounding the anchor scan. */ + atriumMaxSide() { + return Math.max(2, Math.round(this.config.hallSize.max * ATRIUM_SIZE_FACTOR * (1 + ATRIUM_SIZE_VARIANCE))); + } + /** + * Width and height (in cells) of the atrium anchored at `(ax, ay)`, or `null` + * when the anchor does not spawn one. Each side is drawn like a hall side and + * then scaled up by {@link ATRIUM_SIZE_FACTOR} with a per-side + * {@link ATRIUM_SIZE_VARIANCE}, so atriums run a little larger than halls. + */ + atriumSpawn(ax, ay) { + if (this.config.atriumFrequency <= 0) { + return null; + } + if (unitFromHash(hashCoords(this.config.seed, ax, ay, SALT.atriumAnchor)) >= this.config.atriumFrequency) { + return null; + } + const { min, max } = this.config.hallSize; + const span = max - min + 1; + const side = (sizeSalt, varianceSalt) => { + const baseCells = min + Math.min(span - 1, Math.floor(unitFromHash(hashCoords(this.config.seed, ax, ay, sizeSalt)) * span)); + const variance = (unitFromHash(hashCoords(this.config.seed, ax, ay, varianceSalt)) * 2 - 1) * ATRIUM_SIZE_VARIANCE; + return Math.max(2, Math.round(baseCells * ATRIUM_SIZE_FACTOR * (1 + variance))); + }; + return { + w: side(SALT.atriumWidth, SALT.atriumVarWidth), + h: side(SALT.atriumHeight, SALT.atriumVarHeight) + }; + } + /** + * Canonical identifier of the atrium covering a cell, or `null`. Mirrors + * {@link hallIdOf}: bounded, stateless, and lowest-hash-anchor canonical so + * both sides of any edge agree on membership. + */ + atriumIdOf(cx, cy) { + const maxSide = this.atriumMaxSide(); + let best = null; + for (let ay = cy - (maxSide - 1); ay <= cy; ay++) { + for (let ax = cx - (maxSide - 1); ax <= cx; ax++) { + const spawn = this.atriumSpawn(ax, ay); + if (!spawn) { + continue; + } + if (cx >= ax && cx < ax + spawn.w && cy >= ay && cy < ay + spawn.h) { + const hash = hashCoords(this.config.seed, ax, ay, SALT.atriumAnchor); + if (best === null || hash < best.hash) { + best = { ax, ay, hash }; + } + } + } + } + return best ? `${best.ax},${best.ay}` : null; + } + /** Resolves the open-character classification of a cell. */ + classify(cx, cy) { + if (this.atriumIdOf(cx, cy) !== null) { + return "atrium"; + } + if (this.hallIdOf(cx, cy) !== null) { + return "hall"; + } + if (this.roomNoise(cx, cy) < this.config.roomFrequency) { + return "room"; + } + return "corridor"; + } + /** Footprint and extrusion dimensions for a cell. */ + dimensions(cx, cy, kind) { + const { width, height, depth } = this.config; + const draw = (salt) => unitFromHash(hashCoords(this.config.seed, cx, cy, salt)); + if (kind === "atrium") { + return { width: width.max, height: height.max, depth: depth.max }; + } + if (kind === "hall") { + return { + width: width.max, + height: height.max, + depth: lerp((depth.min + depth.max) / 2, depth.max, draw(SALT.depth)) + }; + } + if (kind === "room") { + return { + width: lerp(width.min, width.max, draw(SALT.width)), + height: lerp(height.min, height.max, draw(SALT.height)), + depth: lerp(depth.min, depth.max, draw(SALT.depth)) + }; + } + return { + width: width.max, + height: height.max, + depth: lerp(depth.min, depth.max, draw(SALT.depth)) + }; + } + /** + * A deterministic interior prop for an open cell, or `null`. Props occur only + * in rooms and halls and are gated by {@link MazeConfig.propFrequency}. + */ + feature(cx, cy, kind) { + if (kind === "corridor" || kind === "atrium" || this.config.propFrequency <= 0) { + return null; + } + if (unitFromHash(hashCoords(this.config.seed, cx, cy, SALT.prop)) >= this.config.propFrequency) { + return null; + } + const size = this.config.cellSize; + const draw = (salt) => unitFromHash(hashCoords(this.config.seed, cx, cy, salt)); + const featureKind = FEATURE_KINDS[Math.min(FEATURE_KINDS.length - 1, Math.floor(draw(SALT.propKind) * FEATURE_KINDS.length))] ?? FEATURE_KINDS[0]; + const fx = 0.3 + 0.4 * draw(SALT.propPositionX); + const fy = 0.3 + 0.4 * draw(SALT.propPositionY); + return { + kind: featureKind, + position: { x: cx * size + fx * size, y: cy * size + fy * size }, + size: lerp(0.08, 0.22, draw(SALT.propSize)) * size, + variantSeed: hashCoords(this.config.seed, cx, cy, SALT.propVariant) + }; + } + /** Builds the full cell from its structural inputs. */ + computeCell(cx, cy) { + const kind = this.classify(cx, cy); + const size = this.config.cellSize; + const x0 = cx * size; + const y0 = cy * size; + const x1 = x0 + size; + const y1 = y0 + size; + const corners = { + north: { start: { x: x0, y: y0 }, end: { x: x1, y: y0 } }, + south: { start: { x: x0, y: y1 }, end: { x: x1, y: y1 } }, + west: { start: { x: x0, y: y0 }, end: { x: x0, y: y1 } }, + east: { start: { x: x1, y: y0 }, end: { x: x1, y: y1 } } + }; + const edges = {}; + for (const direction of DIRECTIONS) { + const solid = !this.isPassable(cx, cy, direction); + edges[direction] = { + direction, + solid, + start: corners[direction].start, + end: corners[direction].end, + metadata: this.edgeMetadata(cx, cy, direction, kind) + }; + } + return { + cx, + cy, + kind, + bounds: { min: { x: x0, y: y0 }, max: { x: x1, y: y1 } }, + dimensions: this.dimensions(cx, cy, kind), + edges, + feature: this.feature(cx, cy, kind) + }; + } + // --- Cosmetic metadata (pluggable RNG) ----------------------------------- + /** + * Builds per-edge extrusion metadata. The material seed is a structural hash + * (stable across RNG swaps), but every cosmetic value is drawn from the + * configured RNG factory, so changing the factory changes only this output. + */ + edgeMetadata(cx, cy, direction, classification) { + const { ex, ey, salt } = edgeKey(cx, cy, direction); + const materialSeed = hashCoords(this.config.seed, ex, ey, salt + SALT.material); + const rng = this.config.rng(materialSeed); + const { depth } = this.config; + const heightBase = lerp(depth.min, depth.max, rng.next()); + const heightVariance = lerp(0, (depth.max - depth.min) * 0.5, rng.next()); + const thickness = lerp(0.05, 0.2, rng.next()); + const surfaceType = SURFACE_TYPES[Math.min(SURFACE_TYPES.length - 1, Math.floor(rng.next() * SURFACE_TYPES.length))] ?? SURFACE_TYPES[0]; + return { + heightBase, + heightVariance, + thickness, + surfaceType, + materialSeed, + classification + }; + } + /** Evicts least-recently-used cells when the cache exceeds its limit. */ + evictIfNeeded() { + const limit = this.config.cacheLimit; + if (limit <= 0) { + return; + } + while (this.cache.size > limit) { + const oldest = this.cache.keys().next().value; + if (oldest === void 0) { + break; + } + this.cache.delete(oldest); + this.evictions++; + } + } + }; + + // ../cmd-backedges/src/events.ts + var EventEmitter = class { + constructor() { + this.listeners = /* @__PURE__ */ new Map(); + } + /** + * Registers a listener for an event. + * + * @returns A function that unregisters the listener. + */ + on(event, listener) { + let set = this.listeners.get(event); + if (!set) { + set = /* @__PURE__ */ new Set(); + this.listeners.set(event, set); + } + set.add(listener); + return () => this.off(event, listener); + } + /** Unregisters a previously registered listener. */ + off(event, listener) { + this.listeners.get(event)?.delete(listener); + } + /** Emits an event to all current listeners. */ + emit(event, payload) { + const set = this.listeners.get(event); + if (!set) { + return; + } + for (const listener of [...set]) { + listener(payload); + } + } + /** Removes all listeners, or all listeners for a single event. */ + clear(event) { + if (event === void 0) { + this.listeners.clear(); + } else { + this.listeners.delete(event); + } + } + }; + + // ../cmd-backedges/src/player.ts + var MazeSession = class { + /** + * @param generator - The maze to traverse. + * @param start - Optional starting cell (defaults to the origin). + */ + constructor(generator, start = {}) { + this.emitter = new EventEmitter(); + this.visited = /* @__PURE__ */ new Set(); + this.generator = generator; + this.state = { + cx: start.cx ?? 0, + cy: start.cy ?? 0, + facing: "south" + }; + this.markVisited(this.state.cx, this.state.cy); + } + /** Returns a copy of the current player state. */ + get player() { + return { ...this.state }; + } + /** The cell the player currently occupies. */ + get currentCell() { + return this.generator.getCell(this.state.cx, this.state.cy); + } + /** + * Attempts to move the player one cell in a direction. + * + * Always updates {@link PlayerState.facing}. On success the player advances + * and a `move` event (plus an `enterCell` event for first visits) fires; on + * failure a `blocked` event fires and the position is unchanged. + * + * @returns `true` if the player moved, `false` if blocked by a wall. + */ + move(direction) { + this.state.facing = direction; + const { cx, cy } = this.state; + if (!this.generator.isPassable(cx, cy, direction)) { + this.emitter.emit("blocked", { at: { cx, cy }, direction }); + return false; + } + const { dx, dy } = step(direction); + const to = { cx: cx + dx, cy: cy + dy }; + this.state.cx = to.cx; + this.state.cy = to.cy; + const cell = this.generator.getCell(to.cx, to.cy); + this.emitter.emit("move", { from: { cx, cy }, to, direction, cell }); + if (this.markVisited(to.cx, to.cy)) { + this.emitter.emit("enterCell", cell); + } + return true; + } + /** Teleports the player to an arbitrary cell, bypassing wall checks. */ + warpTo(cx, cy) { + this.state.cx = cx; + this.state.cy = cy; + const cell = this.generator.getCell(cx, cy); + if (this.markVisited(cx, cy)) { + this.emitter.emit("enterCell", cell); + } + } + /** Registers a listener for a session event; returns an unsubscribe fn. */ + on(event, listener) { + return this.emitter.on(event, listener); + } + /** Records a visit; returns `true` only on the first visit to the cell. */ + markVisited(cx, cy) { + const key = `${cx},${cy}`; + if (this.visited.has(key)) { + return false; + } + this.visited.add(key); + return true; + } + }; + + // src/webview/textures.ts + var ATLAS_GRID = 4; + var TILE_PX = 256; + var TILE = { + wallpaperA: 0, + wallpaperB: 1, + ceiling: 2, + lightPanel: 3, + carpet: 4, + concrete: 5, + paneling: 6, + drywall: 7, + ceramic: 8, + fabric: 9, + metal: 10, + wood: 11, + cardboard: 12 + }; + function applyMaterialImages(canvas, images) { + const ctx = canvas.getContext("2d"); + if (!ctx) { + return; + } + const blit = (tile, image, after) => { + const x = tile % ATLAS_GRID * TILE_PX; + const y = Math.floor(tile / ATLAS_GRID) * TILE_PX; + ctx.save(); + ctx.translate(x, y); + ctx.beginPath(); + ctx.rect(0, 0, TILE_PX, TILE_PX); + ctx.clip(); + ctx.drawImage(image, 0, 0, TILE_PX, TILE_PX); + after?.(ctx); + ctx.restore(); + }; + if (images.wallpaper) { + blit(TILE.wallpaperA, images.wallpaper); + blit(TILE.wallpaperB, images.wallpaper, (c) => { + c.fillStyle = "rgba(96, 78, 30, 0.28)"; + c.fillRect(0, 0, TILE_PX, TILE_PX); + }); + } + if (images.ceiling) { + blit(TILE.ceiling, images.ceiling, (c) => { + c.strokeStyle = "rgba(140, 134, 116, 0.85)"; + c.lineWidth = 3; + for (let p = 0; p <= TILE_PX; p += TILE_PX / 2) { + c.beginPath(); + c.moveTo(p, 0); + c.lineTo(p, TILE_PX); + c.moveTo(0, p); + c.lineTo(TILE_PX, p); + c.stroke(); + } + }); + } + if (images.carpet) { + blit(TILE.carpet, images.carpet); + } + } + function buildAtlas() { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = ATLAS_GRID * TILE_PX; + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("2D canvas context unavailable; cannot build texture atlas"); + } + const painters = { + [TILE.wallpaperA]: paintWallpaperA, + [TILE.wallpaperB]: paintWallpaperB, + [TILE.ceiling]: paintCeiling, + [TILE.lightPanel]: paintLightPanel, + [TILE.carpet]: paintCarpet, + [TILE.concrete]: paintConcrete, + [TILE.paneling]: paintPaneling, + [TILE.drywall]: paintDrywall, + [TILE.ceramic]: paintCeramic, + [TILE.fabric]: paintFabric, + [TILE.metal]: paintMetal, + [TILE.wood]: paintWood, + [TILE.cardboard]: paintCardboard + }; + for (const [index, paint] of Object.entries(painters)) { + const i = Number(index); + const x = i % ATLAS_GRID * TILE_PX; + const y = Math.floor(i / ATLAS_GRID) * TILE_PX; + ctx.save(); + ctx.translate(x, y); + ctx.beginPath(); + ctx.rect(0, 0, TILE_PX, TILE_PX); + ctx.clip(); + paint(ctx); + ctx.restore(); + } + return canvas; + } + function grain(ctx, seed, amount) { + const image = ctx.getImageData(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(seed); + const data = image.data; + for (let i = 0; i < data.length; i += 4) { + const n = (rng.next() * 2 - 1) * amount; + data[i] = clampByte(data[i] + n); + data[i + 1] = clampByte(data[i + 1] + n); + data[i + 2] = clampByte(data[i + 2] + n); + } + const off = document.createElement("canvas"); + off.width = off.height = TILE_PX; + off.getContext("2d").putImageData(image, 0, 0); + ctx.drawImage(off, 0, 0); + } + function clampByte(v) { + return v < 0 ? 0 : v > 255 ? 255 : v; + } + function stains(ctx, seed, color, count) { + const rng = mulberry32(seed); + ctx.fillStyle = color; + for (let i = 0; i < count; i++) { + const cx = rng.next() * TILE_PX; + const cy = rng.next() * TILE_PX; + const r = 12 + rng.next() * 46; + ctx.globalAlpha = 0.04 + rng.next() * 0.07; + ctx.beginPath(); + ctx.ellipse(cx, cy, r, r * (0.5 + rng.next() * 0.8), rng.next() * Math.PI, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = 1; + } + function paintWallpaperA(ctx) { + ctx.fillStyle = "#c9b765"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + ctx.fillStyle = "#b7a352"; + for (let x = 0; x < TILE_PX; x += 32) { + ctx.fillRect(x, 0, 14, TILE_PX); + } + ctx.fillStyle = "#a3914a"; + for (let x = 0; x < TILE_PX; x += 32) { + ctx.fillRect(x + 13, 0, 2, TILE_PX); + } + stains(ctx, 11, "#6f5f2c", 9); + grain(ctx, 12, 7); + } + function paintWallpaperB(ctx) { + ctx.fillStyle = "#c4b268"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + ctx.fillStyle = "#ab984f"; + for (let y = 0; y < TILE_PX; y += 32) { + for (let x = 0; x < TILE_PX; x += 32) { + const ox = Math.floor(y / 32) % 2 * 16; + diamond(ctx, x + ox + 16, y + 16, 7); + } + } + stains(ctx, 21, "#6f5f2c", 7); + grain(ctx, 22, 6); + } + function diamond(ctx, cx, cy, r) { + ctx.beginPath(); + ctx.moveTo(cx, cy - r); + ctx.lineTo(cx + r, cy); + ctx.lineTo(cx, cy + r); + ctx.lineTo(cx - r, cy); + ctx.closePath(); + ctx.fill(); + } + function paintCeiling(ctx) { + ctx.fillStyle = "#d8d3c2"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(31); + ctx.fillStyle = "#b9b4a1"; + for (let i = 0; i < 2600; i++) { + ctx.fillRect(rng.next() * TILE_PX, rng.next() * TILE_PX, 1.5, 1.5); + } + ctx.strokeStyle = "#a09a87"; + ctx.lineWidth = 3; + for (let p = 0; p <= TILE_PX; p += 128) { + ctx.beginPath(); + ctx.moveTo(p, 0); + ctx.lineTo(p, TILE_PX); + ctx.moveTo(0, p); + ctx.lineTo(TILE_PX, p); + ctx.stroke(); + } + stains(ctx, 32, "#7c7452", 5); + grain(ctx, 33, 5); + } + function paintLightPanel(ctx) { + const g = ctx.createRadialGradient(128, 128, 20, 128, 128, 190); + g.addColorStop(0, "#fefadd"); + g.addColorStop(0.7, "#f8eeb4"); + g.addColorStop(1, "#e4d78d"); + ctx.fillStyle = g; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + ctx.strokeStyle = "rgba(190, 176, 110, 0.35)"; + ctx.lineWidth = 2; + for (let p = 0; p <= TILE_PX; p += 32) { + ctx.beginPath(); + ctx.moveTo(p, 0); + ctx.lineTo(p, TILE_PX); + ctx.moveTo(0, p); + ctx.lineTo(TILE_PX, p); + ctx.stroke(); + } + grain(ctx, 41, 3); + } + function paintCarpet(ctx) { + ctx.fillStyle = "#c2b47a"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(51); + for (let i = 0; i < 5200; i++) { + const x = rng.next() * TILE_PX; + const y = rng.next() * TILE_PX; + const shade = 150 + Math.floor(rng.next() * 60); + ctx.strokeStyle = `rgb(${shade}, ${shade - 14}, ${Math.floor(shade * 0.62)})`; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x + (rng.next() * 4 - 2), y + (rng.next() * 4 - 2)); + ctx.stroke(); + } + stains(ctx, 52, "#5d5228", 8); + grain(ctx, 53, 6); + } + function paintConcrete(ctx) { + ctx.fillStyle = "#9a958a"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(61); + ctx.strokeStyle = "rgba(70, 66, 58, 0.5)"; + ctx.lineWidth = 1; + for (let i = 0; i < 5; i++) { + let x = rng.next() * TILE_PX; + let y = rng.next() * TILE_PX; + ctx.beginPath(); + ctx.moveTo(x, y); + for (let s = 0; s < 6; s++) { + x += rng.next() * 40 - 20; + y += rng.next() * 40 - 10; + ctx.lineTo(x, y); + } + ctx.stroke(); + } + stains(ctx, 62, "#4c483e", 6); + grain(ctx, 63, 10); + } + function paintPaneling(ctx) { + ctx.fillStyle = "#a8905e"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(71); + for (let x = 0; x < TILE_PX; x += 42) { + ctx.fillStyle = "#7d6741"; + ctx.fillRect(x, 0, 3, TILE_PX); + for (let i = 0; i < 22; i++) { + ctx.strokeStyle = `rgba(110, 88, 52, ${0.15 + rng.next() * 0.2})`; + const gx = x + 5 + rng.next() * 34; + ctx.beginPath(); + ctx.moveTo(gx, 0); + ctx.bezierCurveTo(gx + 4, 80, gx - 4, 170, gx + 2, TILE_PX); + ctx.stroke(); + } + } + grain(ctx, 72, 6); + } + function paintDrywall(ctx) { + ctx.fillStyle = "#cfc7ad"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + stains(ctx, 81, "#8d8465", 6); + grain(ctx, 82, 6); + } + function paintCeramic(ctx) { + ctx.fillStyle = "#b8b2a0"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + ctx.fillStyle = "#d7d2c2"; + for (let y = 0; y < TILE_PX; y += 64) { + for (let x = 0; x < TILE_PX; x += 64) { + ctx.fillRect(x + 3, y + 3, 58, 58); + } + } + stains(ctx, 91, "#6d6752", 5); + grain(ctx, 92, 5); + } + function paintFabric(ctx) { + ctx.fillStyle = "#7a6f52"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + grain(ctx, 101, 12); + } + function paintMetal(ctx) { + ctx.fillStyle = "#8e9296"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(111); + for (let x = 0; x < TILE_PX; x += 2) { + ctx.fillStyle = `rgba(255, 255, 255, ${rng.next() * 0.06})`; + ctx.fillRect(x, 0, 1, TILE_PX); + } + grain(ctx, 112, 5); + } + function paintWood(ctx) { + ctx.fillStyle = "#8b6b43"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(121); + for (let i = 0; i < 30; i++) { + ctx.strokeStyle = `rgba(70, 50, 26, ${0.15 + rng.next() * 0.25})`; + const gy = rng.next() * TILE_PX; + ctx.beginPath(); + ctx.moveTo(0, gy); + ctx.bezierCurveTo(80, gy + 6, 170, gy - 6, TILE_PX, gy + 3); + ctx.stroke(); + } + grain(ctx, 122, 6); + } + function paintCardboard(ctx) { + ctx.fillStyle = "#b59a6b"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + ctx.fillStyle = "rgba(214, 205, 175, 0.8)"; + ctx.fillRect(0, 108, TILE_PX, 40); + ctx.strokeStyle = "rgba(90, 72, 44, 0.6)"; + ctx.lineWidth = 2; + ctx.strokeRect(6, 6, TILE_PX - 12, TILE_PX - 12); + grain(ctx, 131, 8); + } + + // src/webview/renderer.ts + var FLOATS_PER_VERTEX = 10; + var EMISSIVE_SHADE = 2; + var VERTEX_SRC = ` +attribute vec3 aPosition; +attribute vec2 aUv; +attribute float aTile; +attribute vec3 aTint; +attribute float aShade; + +uniform mat4 uViewProj; +uniform vec3 uCamPos; + +varying vec2 vUv; +varying float vTile; +varying vec3 vTint; +varying float vShade; +varying float vDist; + +void main() { + vUv = aUv; + vTile = aTile; + vTint = aTint; + vShade = aShade; + vDist = distance(aPosition, uCamPos); + gl_Position = uViewProj * vec4(aPosition, 1.0); +} +`; + var FRAGMENT_SRC = ` +precision mediump float; + +uniform sampler2D uAtlas; +uniform vec3 uFogColor; +uniform float uFogDensity; +uniform float uFlicker; + +varying vec2 vUv; +varying float vTile; +varying vec3 vTint; +varying float vShade; +varying float vDist; + +const float GRID = ${ATLAS_GRID.toFixed(1)}; + +void main() { + float tile = floor(vTile + 0.5); + vec2 cell = vec2(mod(tile, GRID), floor(tile / GRID)); + // Half-texel inset keeps repeated tiles from bleeding across atlas seams. + vec2 local = fract(vUv) * (1.0 - 2.0 / 256.0) + 1.0 / 256.0; + vec4 tex = texture2D(uAtlas, (cell + local) / GRID); + + if (vShade >= ${EMISSIVE_SHADE.toFixed(1)} - 0.25) { + // Emissive light panel: flicker, no fog fade. + vec3 lit = tex.rgb * vTint * uFlicker * (vShade - 1.0); + float glowFog = 1.0 - exp(-vDist * vDist * uFogDensity * 0.35); + gl_FragColor = vec4(mix(lit, uFogColor, glowFog), 1.0); + return; + } + + vec3 color = tex.rgb * vTint * vShade * uFlicker; + float fog = 1.0 - exp(-vDist * vDist * uFogDensity); + gl_FragColor = vec4(mix(color, uFogColor, fog), 1.0); +} +`; + var FLOATS_PER_DECAL_VERTEX = 6; + var DECAL_VERTEX_SRC = ` +attribute vec3 aPosition; +attribute vec2 aUv; +attribute float aShade; + +uniform mat4 uViewProj; +uniform vec3 uCamPos; + +varying vec2 vUv; +varying float vShade; +varying float vDist; + +void main() { + vUv = aUv; + vShade = aShade; + vDist = distance(aPosition, uCamPos); + gl_Position = uViewProj * vec4(aPosition, 1.0); +} +`; + var DECAL_FRAGMENT_SRC = ` +precision mediump float; + +uniform sampler2D uTexture; +uniform float uFogDensity; +uniform float uFlicker; + +varying vec2 vUv; +varying float vShade; +varying float vDist; + +void main() { + vec4 tex = texture2D(uTexture, vUv); + float fog = 1.0 - exp(-vDist * vDist * uFogDensity); + // Ink dims with the wall lighting and dissolves into the fog. + gl_FragColor = vec4(tex.rgb * vShade * uFlicker, tex.a * (1.0 - fog)); +} +`; + var Renderer = class { + gl; + program; + uniforms; + attribs; + canvas; + fogColor = [0.055, 0.048, 0.02]; + fogDensity = 0.012; + // One rewritable mesh for animated geometry (the monster), rebuilt per frame. + dynVbo = null; + dynIbo = null; + dynCount = 0; + atlasCanvas; + atlasTexture; + // Wall-writing decals: separate program, texture, and mesh, rebuilt only + // when a new line is scrawled. + decalProgram; + decalUniforms; + decalAttribs; + decalTexture = null; + decalVbo = null; + decalIbo = null; + decalCount = 0; + constructor(canvas) { + this.canvas = canvas; + const gl = canvas.getContext("webgl", { antialias: true, alpha: false }); + if (!gl) { + throw new Error("WebGL is not available in this webview"); + } + this.gl = gl; + this.program = buildProgram(gl, VERTEX_SRC, FRAGMENT_SRC); + this.attribs = { + aPosition: gl.getAttribLocation(this.program, "aPosition"), + aUv: gl.getAttribLocation(this.program, "aUv"), + aTile: gl.getAttribLocation(this.program, "aTile"), + aTint: gl.getAttribLocation(this.program, "aTint"), + aShade: gl.getAttribLocation(this.program, "aShade") + }; + this.uniforms = {}; + for (const name of ["uViewProj", "uCamPos", "uAtlas", "uFogColor", "uFogDensity", "uFlicker"]) { + this.uniforms[name] = gl.getUniformLocation(this.program, name); + } + this.decalProgram = buildProgram(gl, DECAL_VERTEX_SRC, DECAL_FRAGMENT_SRC); + this.decalAttribs = { + aPosition: gl.getAttribLocation(this.decalProgram, "aPosition"), + aUv: gl.getAttribLocation(this.decalProgram, "aUv"), + aShade: gl.getAttribLocation(this.decalProgram, "aShade") + }; + this.decalUniforms = {}; + for (const name of ["uViewProj", "uCamPos", "uTexture", "uFogDensity", "uFlicker"]) { + this.decalUniforms[name] = gl.getUniformLocation(this.decalProgram, name); + } + this.atlasCanvas = buildAtlas(); + this.atlasTexture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.atlasCanvas); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.enable(gl.DEPTH_TEST); + gl.enable(gl.CULL_FACE); + gl.cullFace(gl.BACK); + } + /** Patches photo materials over the procedural atlas and re-uploads it. */ + applyMaterialImages(images) { + applyMaterialImages(this.atlasCanvas, images); + const gl = this.gl; + gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.atlasCanvas); + } + uploadChunk(vertices, indices) { + const gl = this.gl; + const vbo = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, vbo); + gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW); + const ibo = gl.createBuffer(); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW); + return { vbo, ibo, indexCount: indices.length }; + } + disposeChunk(mesh) { + this.gl.deleteBuffer(mesh.vbo); + this.gl.deleteBuffer(mesh.ibo); + } + /** Replaces the dynamic mesh drawn after the chunks this frame. */ + setDynamicMesh(vertices, indices) { + const gl = this.gl; + this.dynVbo ??= gl.createBuffer(); + this.dynIbo ??= gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, this.dynVbo); + gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.DYNAMIC_DRAW); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.dynIbo); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.DYNAMIC_DRAW); + this.dynCount = indices.length; + } + clearDynamicMesh() { + this.dynCount = 0; + } + /** + * Uploads (or re-uploads) the wall-writing texture from a canvas. + * Uses texture unit 1 to avoid disturbing the wall atlas on unit 0. + */ + setDecalTexture(source) { + const gl = this.gl; + this.decalTexture ??= gl.createTexture(); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.decalTexture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.activeTexture(gl.TEXTURE0); + } + /** + * Replaces the decal mesh (vertex layout: position(3), uv(2), shade(1)). + * The decal pass draws wall writings as alpha-blended quads over the walls. + */ + setDecalMesh(vertices, indices) { + const gl = this.gl; + this.decalVbo ??= gl.createBuffer(); + this.decalIbo ??= gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, this.decalVbo); + gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.DYNAMIC_DRAW); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.decalIbo); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.DYNAMIC_DRAW); + this.decalCount = indices.length; + } + clearDecalMesh() { + this.decalCount = 0; + } + resize() { + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const w = Math.floor(this.canvas.clientWidth * dpr); + const h = Math.floor(this.canvas.clientHeight * dpr); + if (w > 0 && h > 0 && (this.canvas.width !== w || this.canvas.height !== h)) { + this.canvas.width = w; + this.canvas.height = h; + } + } + draw(chunks, camera, flicker) { + const gl = this.gl; + this.resize(); + gl.viewport(0, 0, this.canvas.width, this.canvas.height); + gl.clearColor(this.fogColor[0], this.fogColor[1], this.fogColor[2], 1); + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + gl.useProgram(this.program); + const aspect = this.canvas.width / Math.max(1, this.canvas.height); + const viewProj = mat4Multiply( + mat4Perspective(camera.fovY, aspect, 0.02, 80), + mat4View(camera) + ); + gl.uniformMatrix4fv(this.uniforms.uViewProj, false, viewProj); + gl.uniform3f(this.uniforms.uCamPos, camera.x, camera.y, camera.z); + gl.uniform3f(this.uniforms.uFogColor, this.fogColor[0], this.fogColor[1], this.fogColor[2]); + gl.uniform1f(this.uniforms.uFogDensity, this.fogDensity); + gl.uniform1f(this.uniforms.uFlicker, flicker); + gl.uniform1i(this.uniforms.uAtlas, 0); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture); + for (const mesh of chunks) { + this.drawMesh(mesh.vbo, mesh.ibo, mesh.indexCount); + } + if (this.dynCount > 0 && this.dynVbo && this.dynIbo) { + this.drawMesh(this.dynVbo, this.dynIbo, this.dynCount); + } + this.drawDecals(viewProj, camera, flicker); + } + /** Alpha-blended wall-writing pass, drawn over the opaque geometry. */ + drawDecals(viewProj, camera, flicker) { + if (this.decalCount === 0 || !this.decalVbo || !this.decalIbo || !this.decalTexture) { + return; + } + const gl = this.gl; + gl.useProgram(this.decalProgram); + gl.uniformMatrix4fv(this.decalUniforms.uViewProj, false, viewProj); + gl.uniform3f(this.decalUniforms.uCamPos, camera.x, camera.y, camera.z); + gl.uniform1f(this.decalUniforms.uFogDensity, this.fogDensity); + gl.uniform1f(this.decalUniforms.uFlicker, flicker); + gl.uniform1i(this.decalUniforms.uTexture, 1); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.decalTexture); + gl.activeTexture(gl.TEXTURE0); + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + gl.depthMask(false); + for (const a of Object.values(this.attribs)) { + gl.disableVertexAttribArray(a); + } + const stride = FLOATS_PER_DECAL_VERTEX * 4; + gl.bindBuffer(gl.ARRAY_BUFFER, this.decalVbo); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.decalIbo); + gl.vertexAttribPointer(this.decalAttribs.aPosition, 3, gl.FLOAT, false, stride, 0); + gl.vertexAttribPointer(this.decalAttribs.aUv, 2, gl.FLOAT, false, stride, 12); + gl.vertexAttribPointer(this.decalAttribs.aShade, 1, gl.FLOAT, false, stride, 20); + for (const a of Object.values(this.decalAttribs)) { + gl.enableVertexAttribArray(a); + } + gl.drawElements(gl.TRIANGLES, this.decalCount, gl.UNSIGNED_SHORT, 0); + for (const a of Object.values(this.decalAttribs)) { + gl.disableVertexAttribArray(a); + } + gl.depthMask(true); + gl.disable(gl.BLEND); + } + drawMesh(vbo, ibo, indexCount) { + const gl = this.gl; + const stride = FLOATS_PER_VERTEX * 4; + gl.bindBuffer(gl.ARRAY_BUFFER, vbo); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo); + gl.vertexAttribPointer(this.attribs.aPosition, 3, gl.FLOAT, false, stride, 0); + gl.vertexAttribPointer(this.attribs.aUv, 2, gl.FLOAT, false, stride, 12); + gl.vertexAttribPointer(this.attribs.aTile, 1, gl.FLOAT, false, stride, 20); + gl.vertexAttribPointer(this.attribs.aTint, 3, gl.FLOAT, false, stride, 24); + gl.vertexAttribPointer(this.attribs.aShade, 1, gl.FLOAT, false, stride, 36); + for (const a of Object.values(this.attribs)) { + gl.enableVertexAttribArray(a); + } + gl.drawElements(gl.TRIANGLES, indexCount, gl.UNSIGNED_SHORT, 0); + } + }; + function buildProgram(gl, vsSrc, fsSrc) { + const compile = (type, src) => { + const shader = gl.createShader(type); + gl.shaderSource(shader, src); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + throw new Error(`Shader compile failed: ${gl.getShaderInfoLog(shader) ?? "unknown"}`); + } + return shader; + }; + const program = gl.createProgram(); + gl.attachShader(program, compile(gl.VERTEX_SHADER, vsSrc)); + gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fsSrc)); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + throw new Error(`Program link failed: ${gl.getProgramInfoLog(program) ?? "unknown"}`); + } + return program; + } + function mat4Perspective(fovY, aspect, near, far) { + const f = 1 / Math.tan(fovY / 2); + const nf = 1 / (near - far); + const out = new Float32Array(16); + out[0] = f / aspect; + out[5] = f; + out[10] = (far + near) * nf; + out[11] = -1; + out[14] = 2 * far * near * nf; + return out; + } + function mat4View(camera) { + const rot = mat4Multiply( + mat4RotateZ(-camera.roll), + mat4Multiply(mat4RotateX(-camera.pitch), mat4RotateY(-camera.yaw)) + ); + const trans = mat4Identity(); + trans[12] = -camera.x; + trans[13] = -camera.y; + trans[14] = -camera.z; + return mat4Multiply(rot, trans); + } + function mat4Identity() { + const out = new Float32Array(16); + out[0] = out[5] = out[10] = out[15] = 1; + return out; + } + function mat4RotateX(rad) { + const out = mat4Identity(); + const c = Math.cos(rad); + const s = Math.sin(rad); + out[5] = c; + out[6] = s; + out[9] = -s; + out[10] = c; + return out; + } + function mat4RotateY(rad) { + const out = mat4Identity(); + const c = Math.cos(rad); + const s = Math.sin(rad); + out[0] = c; + out[2] = -s; + out[8] = s; + out[10] = c; + return out; + } + function mat4RotateZ(rad) { + const out = mat4Identity(); + const c = Math.cos(rad); + const s = Math.sin(rad); + out[0] = c; + out[1] = s; + out[4] = -s; + out[5] = c; + return out; + } + function mat4Multiply(a, b) { + const out = new Float32Array(16); + for (let col = 0; col < 4; col++) { + for (let row = 0; row < 4; row++) { + let sum = 0; + for (let k = 0; k < 4; k++) { + sum += a[k * 4 + row] * b[col * 4 + k]; + } + out[col * 4 + row] = sum; + } + } + return out; + } + + // src/webview/world.ts + var DEPTH_RANGE = { min: 1.1, max: 1.6 }; + var PLAYER_RADIUS = 0.24; + var WALL_HALF_DEPTH = 0.06; + var DOOR_TOP_FRACTION = 0.84; + var CHUNK_SIZE = 4; + var LIGHT_LATTICE = 3; + var ZONE_LATTICE = 12; + var SALT2 = { + lightState: 7001, + zoneAnchor: 7101, + zoneCenterX: 7102, + zoneCenterY: 7103, + zoneRadius: 7104, + zonePhase1: 7105, + zonePhase2: 7106, + zonePalette: 7107, + zoneVariant: 7108 + }; + var WALL_TILE_BY_SURFACE = { + drywall: TILE.drywall, + wallpaper: TILE.wallpaperA, + paneling: TILE.paneling, + concrete: TILE.concrete, + tile: TILE.ceramic, + carpet: TILE.carpet + }; + var MATERIAL_PRESETS = { + classic: { tile: null, tint: [1, 1, 1] }, + office: { tile: TILE.drywall, tint: [1, 1, 1] }, + pool: { tile: TILE.ceramic, tint: [0.88, 1, 1.06] }, + concrete: { tile: TILE.concrete, tint: [1, 1, 1] }, + panel: { tile: TILE.paneling, tint: [1, 1, 1] } + }; + var ZONE_PALETTES = [ + [1.1, 0.6, 0.52], + // faded rose + [0.58, 0.95, 0.38], + // mossy green + [0.5, 0.72, 1.1], + // dusty blue + [0.72, 0.5, 0.26], + // deep sepia + [1.35, 1.32, 1.12] + // bleached bone + ]; + var WHITE = [1, 1, 1]; + var World = class { + generator; + session; + seed; + furnitureEnabled = true; + // Off by default, matching DEFAULT_SETTINGS.wallpaperShifts. + wallpaperShiftsEnabled = false; + /** + * When a photo wallpaper is loaded, the classic preset papers every wall + * with it instead of the generator's mixed surface types; per-edge wear and + * wallpaper zones still provide the variation. + */ + uniformWallpaper = false; + wallOverrideTile = null; + wallBaseTint = [1, 1, 1]; + cellsVisited = 1; + zoneCache = /* @__PURE__ */ new Map(); + chunks = /* @__PURE__ */ new Map(); + constructor(seed) { + this.seed = seed; + this.generator = new MazeGenerator({ + seed, + depth: DEPTH_RANGE, + propFrequency: 0.14 + }); + this.session = new MazeSession(this.generator); + this.session.on("enterCell", () => { + this.cellsVisited++; + }); + } + /** + * Applies a wall material preset plus its adjustable elements (hue rotation + * in degrees and a brightness multiplier). Call invalidateChunks afterwards + * so existing meshes pick the change up. + * + * The preset determines the base tile and tint for all walls. Hue rotation + * lets players shift the wallpaper color, and brightness scales the overall + * material lightness without destroying the wear variation. + */ + setMaterial(preset, hueShiftDeg, brightness) { + const base = MATERIAL_PRESETS[preset] ?? MATERIAL_PRESETS.classic; + this.wallOverrideTile = base.tile; + this.wallBaseTint = hueRotate(base.tint, hueShiftDeg).map( + (v) => Math.max(0, v * brightness) + ); + } + /** Public light sample for decorations drawn outside the chunk mesher. */ + lightAt(x, y) { + return this.lightLevelAt(x, y); + } + stats() { + return { + seed: this.seed, + cellsVisited: this.cellsVisited, + cacheSize: this.generator.stats().size + }; + } + /** + * Keeps the discrete MazeSession in step with the continuous player + * position, so its move/enterCell events stay meaningful. + * + * When the player crosses a cell boundary, this tries to move the session + * in the matching direction. If that fails (shouldn't happen - walls block + * both), it warps the session to match reality. + */ + syncSession(px, py) { + const cx = Math.floor(px); + const cy = Math.floor(py); + const at = this.session.player; + if (at.cx === cx && at.cy === cy) { + return; + } + const dx = cx - at.cx; + const dy = cy - at.cy; + if (Math.abs(dx) + Math.abs(dy) === 1) { + const direction = dx === 1 ? "east" : dx === -1 ? "west" : dy === 1 ? "south" : "north"; + if (!this.session.move(direction)) { + this.session.warpTo(cx, cy); + } + } else { + this.session.warpTo(cx, cy); + } + } + // --- Movement ------------------------------------------------------------ + /** + * Moves the player from (px, py) toward (px+dx, py+dy) in plane coordinates, + * resolving collisions per axis so the player slides along walls. + * + * This implements a sweep-and-clamp collision resolver: each axis is tested + * independently. If the desired position is blocked, the coordinate is + * clamped to the nearest wall surface (within the motion delta to avoid + * teleport-like jumps). This creates smooth wall-sliding behavior common in + * first-person games. + */ + moveResolved(px, py, dx, dy) { + let x = px; + let y = py; + const withinStep = (from, to, v) => v >= Math.min(from, to) - 1e-9 && v <= Math.max(from, to) + 1e-9; + const tryAxis = (nx, ny, axis) => { + if (this.canOccupy(nx, ny)) { + x = nx; + y = ny; + return; + } + if (axis === "x") { + const cx = Math.floor(x); + const clamped = nx > x ? cx + 1 - PLAYER_RADIUS - 1e-4 : cx + PLAYER_RADIUS + 1e-4; + if (withinStep(x, nx, clamped) && this.canOccupy(clamped, ny)) { + x = clamped; + y = ny; + } + } else { + const cy = Math.floor(y); + const clamped = ny > y ? cy + 1 - PLAYER_RADIUS - 1e-4 : cy + PLAYER_RADIUS + 1e-4; + if (withinStep(y, ny, clamped) && this.canOccupy(nx, clamped)) { + x = nx; + y = clamped; + } + } + }; + const steps = Math.max(1, Math.ceil(Math.max(Math.abs(dx), Math.abs(dy)) / PLAYER_RADIUS)); + for (let i = 0; i < steps; i++) { + tryAxis(x + dx / steps, y, "x"); + tryAxis(x, y + dy / steps, "y"); + } + return { x, y }; + } + /** + * Whether a player disc at (x, y) fits: each corner of its bounding square + * must be reachable from the center cell through open edges only. + * + * This implements circle-vs-grid collision by checking the four corners of + * the circle's bounding box. Each corner cell must be connected to the + * player's center cell via a valid path of open edges (either directly if + * they're adjacent, or through an intermediate cell at a diagonal). + */ + canOccupy(x, y) { + const cx = Math.floor(x); + const cy = Math.floor(y); + for (const ox of [-PLAYER_RADIUS, PLAYER_RADIUS]) { + for (const oy of [-PLAYER_RADIUS, PLAYER_RADIUS]) { + const ccx = Math.floor(x + ox); + const ccy = Math.floor(y + oy); + if (ccx === cx && ccy === cy) { + continue; + } + const dirX = ccx > cx ? "east" : ccx < cx ? "west" : null; + const dirY = ccy > cy ? "south" : ccy < cy ? "north" : null; + if (dirX && !dirY) { + if (!this.generator.isPassable(cx, cy, dirX)) { + return false; + } + } else if (dirY && !dirX) { + if (!this.generator.isPassable(cx, cy, dirY)) { + return false; + } + } else if (dirX && dirY) { + const viaX = this.generator.isPassable(cx, cy, dirX) && this.generator.isPassable(ccx, cy, dirY); + const viaY = this.generator.isPassable(cx, cy, dirY) && this.generator.isPassable(cx, ccy, dirX); + if (!viaX && !viaY) { + return false; + } + } + } + } + return true; + } + // --- Lights ---------------------------------------------------------------- + /** + * Determines light state for a cell: on, dead, or flickering. + * Lights live on a 3-cell lattice (offset to [1,1] within the pattern). + * Returns null for cells that have no light fixture. + * + * Dead lights (12% chance) never illuminate. Flickering lights (8% chance) + * pulse erratically. The rest stay on with the global flicker hum. + */ + lightState(cx, cy) { + const mod = (n, m) => (n % m + m) % m; + if (mod(cx, LIGHT_LATTICE) !== 1 || mod(cy, LIGHT_LATTICE) !== 1) { + return null; + } + const u = unitFromHash(hashCoords(this.seed, cx, cy, SALT2.lightState)); + if (u < 0.12) { + return "dead"; + } + if (u < 0.2) { + return "flicker"; + } + return "on"; + } + /** Summed light contribution at a plane point, in [0, 1]. */ + lightLevelAt(x, y) { + const reach = 3.2; + let level = 0; + const minGx = Math.floor((x - reach) / LIGHT_LATTICE); + const maxGx = Math.floor((x + reach) / LIGHT_LATTICE); + const minGy = Math.floor((y - reach) / LIGHT_LATTICE); + const maxGy = Math.floor((y + reach) / LIGHT_LATTICE); + for (let gy = minGy; gy <= maxGy; gy++) { + for (let gx = minGx; gx <= maxGx; gx++) { + const lcx = gx * LIGHT_LATTICE + 1; + const lcy = gy * LIGHT_LATTICE + 1; + const state = this.lightState(lcx, lcy); + if (state !== "on" && state !== "flicker") { + continue; + } + const dx = x - (lcx + 0.5); + const dy = y - (lcy + 0.5); + const d = Math.sqrt(dx * dx + dy * dy); + if (d < reach) { + const fall = 1 - d / reach; + level += fall * fall * (state === "flicker" ? 0.55 : 1); + } + } + } + return Math.min(1, level); + } + // --- Wallpaper zones ------------------------------------------------------- + /** + * Random enclosed shapes: anchors on a coarse lattice each spawn a wobbled + * closed radial blob (radius modulated by two sine harmonics with hashed + * phases). A cell inside a blob adopts that zone's palette; the innermost + * blob wins where blobs overlap. + */ + zoneAt(cx, cy) { + if (!this.wallpaperShiftsEnabled) { + return null; + } + const key = `${cx},${cy}`; + const cached = this.zoneCache.get(key); + if (cached !== void 0) { + return cached; + } + if (this.zoneCache.size > 2e4) { + this.zoneCache.clear(); + } + const gx0 = Math.floor(cx / ZONE_LATTICE); + const gy0 = Math.floor(cy / ZONE_LATTICE); + let best = null; + for (let gy = gy0 - 1; gy <= gy0 + 1; gy++) { + for (let gx = gx0 - 1; gx <= gx0 + 1; gx++) { + const draw = (salt) => unitFromHash(hashCoords(this.seed, gx, gy, salt)); + if (draw(SALT2.zoneAnchor) >= 0.45) { + continue; + } + const centerX = (gx + draw(SALT2.zoneCenterX)) * ZONE_LATTICE; + const centerY = (gy + draw(SALT2.zoneCenterY)) * ZONE_LATTICE; + const base = 3 + draw(SALT2.zoneRadius) * 5; + const dx = cx + 0.5 - centerX; + const dy = cy + 0.5 - centerY; + const dist = Math.sqrt(dx * dx + dy * dy); + const theta = Math.atan2(dy, dx); + const p1 = draw(SALT2.zonePhase1) * Math.PI * 2; + const p2 = draw(SALT2.zonePhase2) * Math.PI * 2; + const radius = base * (1 + 0.3 * Math.sin(3 * theta + p1) + 0.18 * Math.sin(5 * theta + p2)); + if (dist >= radius) { + continue; + } + const depth = dist / radius; + if (!best || depth < best.depth) { + const palette = ZONE_PALETTES[Math.floor(draw(SALT2.zonePalette) * ZONE_PALETTES.length)] ?? WHITE; + best = { + depth, + zone: { tint: [...palette], variant: draw(SALT2.zoneVariant) < 0.5 } + }; + } + } + } + const zone = best?.zone ?? null; + this.zoneCache.set(key, zone); + return zone; + } + // --- Chunk streaming ------------------------------------------------------- + /** + * Ensures every chunk within the render distance is meshed and uploaded, + * dropping chunks that fell out of range. Returns the drawable set. + */ + updateChunks(px, py, renderDistance, renderer) { + const range = renderDistance + CHUNK_SIZE; + const minCx = Math.floor((px - range) / CHUNK_SIZE); + const maxCx = Math.floor((px + range) / CHUNK_SIZE); + const minCy = Math.floor((py - range) / CHUNK_SIZE); + const maxCy = Math.floor((py + range) / CHUNK_SIZE); + const wanted = /* @__PURE__ */ new Set(); + for (let gy = minCy; gy <= maxCy; gy++) { + for (let gx = minCx; gx <= maxCx; gx++) { + const centerX = (gx + 0.5) * CHUNK_SIZE; + const centerY = (gy + 0.5) * CHUNK_SIZE; + const dist = Math.hypot(centerX - px, centerY - py); + if (dist > renderDistance + CHUNK_SIZE) { + continue; + } + const key = `${gx},${gy}`; + wanted.add(key); + if (!this.chunks.has(key)) { + this.chunks.set(key, this.buildChunk(gx, gy, renderer)); + } + } + } + for (const [key, mesh] of this.chunks) { + if (!wanted.has(key)) { + renderer.disposeChunk(mesh); + this.chunks.delete(key); + } + } + return this.chunks.values(); + } + /** Drops all uploaded chunks (e.g. when toggling furniture or zones). */ + invalidateChunks(renderer) { + for (const mesh of this.chunks.values()) { + renderer.disposeChunk(mesh); + } + this.chunks.clear(); + this.zoneCache.clear(); + } + buildChunk(gx, gy, renderer) { + const builder = new MeshBuilder(); + for (let cy = gy * CHUNK_SIZE; cy < (gy + 1) * CHUNK_SIZE; cy++) { + for (let cx = gx * CHUNK_SIZE; cx < (gx + 1) * CHUNK_SIZE; cx++) { + this.emitCell(builder, this.generator.getCell(cx, cy)); + } + } + return renderer.uploadChunk(builder.vertices(), builder.indices()); + } + emitCell(b, cell) { + const { cx, cy } = cell; + const x0 = cell.bounds.min.x; + const y0 = cell.bounds.min.y; + const x1 = cell.bounds.max.x; + const y1 = cell.bounds.max.y; + const h = cell.dimensions.depth; + const zone = this.zoneAt(cx, cy); + const shadeAt = (x, y, base, span) => base + span * this.lightLevelAt(x, y); + const floorTint = zone ? [mix(1, zone.tint[0], 0.25), mix(1, zone.tint[1], 0.25), mix(1, zone.tint[2], 0.25)] : WHITE; + b.quad( + [x0, 0, y0], + [x0, 0, y1], + [x1, 0, y1], + [x1, 0, y0], + [x0, y0], + [x0, y1], + [x1, y1], + [x1, y0], + TILE.carpet, + floorTint, + [ + shadeAt(x0, y0, 0.5, 0.55), + shadeAt(x0, y1, 0.5, 0.55), + shadeAt(x1, y1, 0.5, 0.55), + shadeAt(x1, y0, 0.5, 0.55) + ] + ); + b.quad( + [x0, h, y0], + [x1, h, y0], + [x1, h, y1], + [x0, h, y1], + [x0 * 2, y0 * 2], + [x1 * 2, y0 * 2], + [x1 * 2, y1 * 2], + [x0 * 2, y1 * 2], + TILE.ceiling, + WHITE, + [ + shadeAt(x0, y0, 0.38, 0.4), + shadeAt(x1, y0, 0.38, 0.4), + shadeAt(x1, y1, 0.38, 0.4), + shadeAt(x0, y1, 0.38, 0.4) + ] + ); + const light = this.lightState(cx, cy); + if (light) { + const inset = 0.24; + const drop = 0.02; + const lx0 = x0 + inset; + const lx1 = x1 - inset; + const ly0 = y0 + inset; + const ly1 = y1 - inset; + const py = h - drop; + const shade = light === "dead" ? 0.32 : light === "flicker" ? EMISSIVE_SHADE + 0.35 : EMISSIVE_SHADE + 0.6; + b.quad( + [lx0, py, ly0], + [lx1, py, ly0], + [lx1, py, ly1], + [lx0, py, ly1], + [0, 0], + [1, 0], + [1, 1], + [0, 1], + TILE.lightPanel, + WHITE, + [shade, shade, shade, shade] + ); + } + for (const direction of DIRECTIONS) { + if (cell.edges[direction].solid) { + this.emitWallSlab(b, cell, direction, zone); + } else { + this.emitDoorHeader(b, cell, direction, zone); + } + } + if (cell.feature && this.furnitureEnabled) { + this.emitFeature(b, cell, cell.feature); + } + } + /** Resolves the tile, tint, and wear factor for one wall edge. */ + wallMaterial(edge, zone) { + let tile; + if (this.wallOverrideTile !== null) { + tile = this.wallOverrideTile; + } else if (this.uniformWallpaper) { + tile = TILE.wallpaperA; + } else { + tile = WALL_TILE_BY_SURFACE[edge.metadata.surfaceType]; + } + let tint = this.wallBaseTint; + if (zone && (this.wallOverrideTile !== null || tile === TILE.wallpaperA)) { + tint = [tint[0] * zone.tint[0], tint[1] * zone.tint[1], tint[2] * zone.tint[2]]; + if (zone.variant && tile === TILE.wallpaperA) { + tile = TILE.wallpaperB; + } + } + const wear = 0.92 + unitFromHash(edge.metadata.materialSeed) * 0.12; + return { tile, tint, wear }; + } + /** + * A solid edge as a slab: the inward face is inset by WALL_HALF_DEPTH (the + * neighbor emits the matching opposite face), and any end where the wall + * line stops at an open passage gets a jamb cap sealing the slab depth. + */ + emitWallSlab(b, cell, direction, zone) { + const h = cell.dimensions.depth; + this.emitWallFace(b, cell, direction, 0, h, zone); + const edge = cell.edges[direction]; + const inward = step(opposite(direction)); + const mat = this.wallMaterial(edge, zone); + for (const end of [edge.start, edge.end]) { + const other = end === edge.start ? edge.end : edge.start; + const ox = Math.sign(end.x - other.x); + const oy = Math.sign(end.y - other.y); + if (this.generator.isPassable(cell.cx + ox, cell.cy + oy, direction)) { + this.emitJamb(b, end, { ox, oy }, inward, 0, h, mat); + } + } + } + /** + * A vertical cap strip sealing this cell's half of a wall slab at a wall + * end, facing out of the wall along `out`. + */ + emitJamb(b, at, out, inward, yBottom, yTop, mat) { + let ax = at.x; + let az = at.y; + let bx = at.x + inward.dx * WALL_HALF_DEPTH; + let bz = at.y + inward.dy * WALL_HALF_DEPTH; + if (-(bz - az) * out.ox + (bx - ax) * out.oy < 0) { + [ax, bx] = [bx, ax]; + [az, bz] = [bz, az]; + } + const shade = (0.42 + 0.5 * this.lightLevelAt(at.x + out.ox * 0.2, at.y + out.oy * 0.2)) * mat.wear * 0.82; + const u0 = ax + az; + const u1 = bx + bz; + b.quad( + [ax, yBottom, az], + [bx, yBottom, bz], + [bx, yTop, bz], + [ax, yTop, az], + [u0, yBottom / 1.6], + [u1, yBottom / 1.6], + [u1, yTop / 1.6], + [u0, yTop / 1.6], + mat.tile, + mat.tint, + [shade, shade, shade * 0.92, shade * 0.92] + ); + } + /** + * Header over an open edge. An opening whose wall line is solid on both + * flanks reads as a doorway punched through a wall, so it gets a lintel: + * face down to DOOR_TOP_FRACTION of the lower ceiling, a soffit underside, + * and end caps. Interior edges of merged open areas (room/hall/atrium + * pairs) only get a soffit band where the neighbor's ceiling steps down. + */ + emitDoorHeader(b, cell, direction, zone) { + const h = cell.dimensions.depth; + const { dx, dy } = step(direction); + const ncx = cell.cx + dx; + const ncy = cell.cy + dy; + const nh = this.generator.getCell(ncx, ncy).dimensions.depth; + const gen = this.generator; + const interior = gen.isRoom(cell.cx, cell.cy) && gen.isRoom(ncx, ncy) || gen.isHall(cell.cx, cell.cy) && gen.isHall(ncx, ncy) || gen.isAtrium(cell.cx, cell.cy) && gen.isAtrium(ncx, ncy); + let bottom = null; + if (!interior) { + const edge2 = cell.edges[direction]; + const flanks = [edge2.start, edge2.end].map((end) => { + const other = end === edge2.start ? edge2.end : edge2.start; + const ox = Math.sign(end.x - other.x); + const oy = Math.sign(end.y - other.y); + return !gen.isPassable(cell.cx + ox, cell.cy + oy, direction); + }); + if (flanks[0] && flanks[1]) { + bottom = Math.min(h, nh) * DOOR_TOP_FRACTION; + } + } + if (bottom === null && nh < h - 0.01) { + bottom = nh; + } + if (bottom === null || bottom >= h - 5e-3) { + return; + } + this.emitWallFace(b, cell, direction, bottom, h, zone); + const edge = cell.edges[direction]; + const inward = step(opposite(direction)); + const mat = this.wallMaterial(edge, zone); + const T = WALL_HALF_DEPTH; + let sx = edge.start.x; + let sz = edge.start.y; + let ex = edge.end.x; + let ez = edge.end.y; + if ((ez - sz) * inward.dx - (ex - sx) * inward.dy > 0) { + [sx, ex] = [ex, sx]; + [sz, ez] = [ez, sz]; + } + const soffitShade = (0.36 + 0.4 * this.lightLevelAt((sx + ex) / 2, (sz + ez) / 2)) * mat.wear; + b.quad( + [sx, bottom, sz], + [ex, bottom, ez], + [ex + inward.dx * T, bottom, ez + inward.dy * T], + [sx + inward.dx * T, bottom, sz + inward.dy * T], + [sx + sz, 0], + [ex + ez, 0], + [ex + ez, T / 1.6], + [sx + sz, T / 1.6], + mat.tile, + mat.tint, + [soffitShade, soffitShade, soffitShade, soffitShade] + ); + for (const end of [edge.start, edge.end]) { + const other = end === edge.start ? edge.end : edge.start; + const ox = Math.sign(end.x - other.x); + const oy = Math.sign(end.y - other.y); + this.emitJamb(b, end, { ox, oy }, inward, bottom, h, mat); + } + } + /** + * The inward-facing wall face for an edge, inset WALL_HALF_DEPTH into the + * cell so the slab has visible extrusion depth at openings. + */ + emitWallFace(b, cell, direction, yBottom, yTop, zone) { + const edge = cell.edges[direction]; + const inward = step(opposite(direction)); + const T = WALL_HALF_DEPTH; + let sx = edge.start.x + inward.dx * T; + let sz = edge.start.y + inward.dy * T; + let ex = edge.end.x + inward.dx * T; + let ez = edge.end.y + inward.dy * T; + const normalX = -(ez - sz); + const normalZ = ex - sx; + if (normalX * inward.dx + normalZ * inward.dy < 0) { + [sx, ex] = [ex, sx]; + [sz, ez] = [ez, sz]; + } + const { tile, tint, wear } = this.wallMaterial(edge, zone); + const u0 = sx + sz; + const u1 = ex + ez; + const v0 = yBottom / 1.6; + const v1 = yTop / 1.6; + const sample = (x, z) => (0.42 + 0.5 * this.lightLevelAt(x, z)) * wear; + const sS = sample(sx + inward.dx * 0.2, sz + inward.dy * 0.2); + const sE = sample(ex + inward.dx * 0.2, ez + inward.dy * 0.2); + b.quad( + [sx, yBottom, sz], + [ex, yBottom, ez], + [ex, yTop, ez], + [sx, yTop, sz], + [u0, v0], + [u1, v0], + [u1, v1], + [u0, v1], + tile, + tint, + [sS, sE, sE * 0.92, sS * 0.92] + ); + } + emitFeature(b, cell, feature) { + const fx = feature.position.x; + const fz = feature.position.y; + const h = cell.dimensions.depth; + const light = 0.4 + 0.5 * this.lightLevelAt(fx, fz); + if (feature.kind === "column") { + const r = Math.max(0.09, feature.size); + emitBox(b, fx - r, fx + r, 0, h, fz - r, fz + r, TILE.drywall, WHITE, light); + return; + } + const rng = mulberry32(feature.variantSeed); + const scale = clamp2(feature.size / 0.15, 0.75, 1.35); + const archetype = Math.floor(rng.next() * 4); + const s = (v) => v * scale; + switch (archetype) { + case 0: { + emitBox(b, fx - s(0.14), fx + s(0.14), 0, s(0.52), fz - s(0.12), fz + s(0.12), TILE.metal, WHITE, light); + break; + } + case 1: { + const w = s(0.32); + const d = s(0.2); + const top = s(0.3); + emitBox(b, fx - w, fx - w + s(0.04), 0, top, fz - d, fz + d, TILE.wood, WHITE, light * 0.9); + emitBox(b, fx + w - s(0.04), fx + w, 0, top, fz - d, fz + d, TILE.wood, WHITE, light * 0.9); + emitBox(b, fx - w, fx + w, top, top + s(0.04), fz - d, fz + d, TILE.wood, WHITE, light); + break; + } + case 2: { + const w = s(0.36); + const d = s(0.17); + emitBox(b, fx - w, fx + w, 0, s(0.18), fz - d, fz + d, TILE.fabric, WHITE, light); + emitBox(b, fx - w, fx + w, s(0.18), s(0.4), fz + d - s(0.07), fz + d, TILE.fabric, WHITE, light * 0.95); + break; + } + default: { + const r = s(0.17); + emitBox(b, fx - r, fx + r, 0, s(0.26), fz - r, fz + r, TILE.cardboard, WHITE, light); + const r2 = s(0.12); + const ox = (rng.next() - 0.5) * s(0.08); + const oz = (rng.next() - 0.5) * s(0.08); + emitBox(b, fx - r2 + ox, fx + r2 + ox, s(0.26), s(0.46), fz - r2 + oz, fz + r2 + oz, TILE.cardboard, WHITE, light * 1.05); + break; + } + } + } + }; + function emitBox(b, x0, x1, y0, y1, z0, z1, tile, tint, shade) { + const sides = shade * 0.85; + const uw = (x1 - x0) * 2; + const ud = (z1 - z0) * 2; + const vh = (y1 - y0) * 2; + b.quad( + [x0, y0, z1], + [x1, y0, z1], + [x1, y1, z1], + [x0, y1, z1], + [0, 0], + [uw, 0], + [uw, vh], + [0, vh], + tile, + tint, + [sides, sides, sides, sides] + ); + b.quad( + [x1, y0, z0], + [x0, y0, z0], + [x0, y1, z0], + [x1, y1, z0], + [0, 0], + [uw, 0], + [uw, vh], + [0, vh], + tile, + tint, + [sides, sides, sides, sides] + ); + b.quad( + [x1, y0, z1], + [x1, y0, z0], + [x1, y1, z0], + [x1, y1, z1], + [0, 0], + [ud, 0], + [ud, vh], + [0, vh], + tile, + tint, + [sides, sides, sides, sides] + ); + b.quad( + [x0, y0, z0], + [x0, y0, z1], + [x0, y1, z1], + [x0, y1, z0], + [0, 0], + [ud, 0], + [ud, vh], + [0, vh], + tile, + tint, + [sides, sides, sides, sides] + ); + b.quad( + [x0, y1, z0], + [x0, y1, z1], + [x1, y1, z1], + [x1, y1, z0], + [0, 0], + [0, ud], + [uw, ud], + [uw, 0], + tile, + tint, + [shade, shade, shade, shade] + ); + } + function mix(a, b, t) { + return a + (b - a) * t; + } + function hueRotate(rgb, degrees) { + const rad = degrees * Math.PI / 180; + const c = Math.cos(rad); + const s = Math.sin(rad); + const [r, g, b] = rgb; + return [ + (0.213 + c * 0.787 - s * 0.213) * r + (0.715 - c * 0.715 - s * 0.715) * g + (0.072 - c * 0.072 + s * 0.928) * b, + (0.213 - c * 0.213 + s * 0.143) * r + (0.715 + c * 0.285 + s * 0.14) * g + (0.072 - c * 0.072 - s * 0.283) * b, + (0.213 - c * 0.213 - s * 0.787) * r + (0.715 - c * 0.715 + s * 0.715) * g + (0.072 + c * 0.928 + s * 0.072) * b + ]; + } + function clamp2(v, lo, hi) { + return Math.min(hi, Math.max(lo, v)); + } + var MeshBuilder = class { + verts = []; + idx = []; + count = 0; + quad(p0, p1, p2, p3, t0, t1, t2, t3, tile, tint, shades) { + const points = [p0, p1, p2, p3]; + const uvs = [t0, t1, t2, t3]; + for (let i = 0; i < 4; i++) { + const p = points[i]; + const t = uvs[i]; + this.verts.push(p[0], p[1], p[2], t[0], t[1], tile, tint[0], tint[1], tint[2], shades[i]); + } + const base = this.count; + this.idx.push(base, base + 1, base + 2, base, base + 2, base + 3); + this.count += 4; + } + vertices() { + return new Float32Array(this.verts); + } + indices() { + if (this.count > 65535) { + throw new Error(`Chunk exceeds 16-bit index range: ${this.count} vertices`); + } + return new Uint16Array(this.idx); + } + }; + + // src/webview/graffiti.ts + var ATLAS_SIZE = 1024; + var SLOT_W = 512; + var SLOT_H = 256; + var SLOT_COLS = 2; + var STATIC_SLOTS = 7; + var LIVE_SLOT = 7; + var MAX_WRITE_DISTANCE = 6; + var RESTAMP_DISTANCE = 4; + var STAMP_COOLDOWN_MS = 2500; + var HISTORY_COOLDOWN_MS = 1200; + var REVEAL_CPS = 22; + var LIVE_REDRAW_MS = 90; + var GHOST_TAIL = 3; + var SEGMENT_CAP = 120; + var MIN_COMMIT_CHARS = 12; + var WALL_SWITCH_MS = 700; + var WRITE_Y0 = 0.55; + var WRITE_Y1 = 1.05; + var INK_FONT = 'Chiller, Creepster, "Segoe Script", "Comic Sans MS", cursive'; + var RAY_OFFSETS = [0, 0.5, -0.5, 1, -1, 1.6, -1.6, 2.4, -2.4, Math.PI]; + var WallWriting = class { + enabled = true; + job = { ...IDLE_JOB }; + session = null; + stamps = []; + nextSlot = 0; + lastWrittenStatus = ""; + lastStampX = Number.NaN; + lastStampY = Number.NaN; + lastStampAt = 0; + stampedHistory = /* @__PURE__ */ new Set(); + lastHistoryStampAt = 0; + // Live ghost writing state. The response is written in segments: the + // segment being revealed lives on LIVE_SLOT on the wall the player faces; + // finished segments are committed onto static slots and the text flows on. + live = null; + liveHit = null; + liveText = ""; + liveShown = 0; + liveConsumed = 0; + lastLiveTick = 0; + lastLiveDraw = 0; + lastLiveKey = ""; + lastWallSwitchAt = 0; + atlas; + ctx; + constructor() { + this.atlas = document.createElement("canvas"); + this.atlas.width = this.atlas.height = ATLAS_SIZE; + const ctx = this.atlas.getContext("2d"); + if (!ctx) { + throw new Error("2D context unavailable for wall writing"); + } + this.ctx = ctx; + } + setJob(job) { + this.job = job; + } + setSession(session) { + this.session = session; + } + /** Clears every writing, e.g. on relocate or when the setting turns off. */ + reset(renderer) { + this.job = { ...IDLE_JOB }; + this.session = null; + this.stamps = []; + this.nextSlot = 0; + this.lastWrittenStatus = ""; + this.lastStampX = Number.NaN; + this.lastStampY = Number.NaN; + this.stampedHistory.clear(); + this.live = null; + this.liveHit = null; + this.liveText = ""; + this.liveShown = 0; + this.liveConsumed = 0; + this.lastLiveKey = ""; + renderer.clearDecalMesh(); + } + /** Called once per frame; adds or advances writings as needed. */ + update(now, world, px, py, yaw, renderer) { + if (!this.enabled) { + return; + } + let changed = this.updateJobStamp(now, world, px, py, yaw); + changed = this.updateHistoryStamps(now, world, px, py, yaw) || changed; + changed = this.updateLive(now, world, px, py, yaw) || changed; + if (changed) { + renderer.setDecalTexture(this.atlas); + this.uploadMesh(renderer); + } + } + // --- Job-status stamps (tool / command / status file route) --------------- + updateJobStamp(now, world, px, py, yaw) { + if (!this.job.working || this.job.status.length === 0) { + return false; + } + if (now - this.lastStampAt < STAMP_COOLDOWN_MS) { + return false; + } + const statusChanged = this.job.status !== this.lastWrittenStatus; + const moved = Number.isNaN(this.lastStampX) || Math.hypot(px - this.lastStampX, py - this.lastStampY) >= RESTAMP_DISTANCE; + if (!statusChanged && !moved) { + return false; + } + const hit = findWallAhead(world, px, py, yaw); + if (!hit) { + return false; + } + const edgeKey2 = `${hit.cx},${hit.cy},${hit.direction}`; + const existing = this.stamps.findIndex((s) => s.edgeKey === edgeKey2); + if (existing !== -1 && !statusChanged || this.live?.edgeKey === edgeKey2) { + return false; + } + const slot = existing !== -1 ? this.stamps[existing].slot : this.claimStaticSlot(); + if (existing !== -1) { + this.stamps.splice(existing, 1); + } + if (!this.stampStatic(world, hit, slot, edgeKey2, this.job.status)) { + return false; + } + this.lastWrittenStatus = this.job.status; + this.lastStampX = px; + this.lastStampY = py; + this.lastStampAt = now; + return true; + } + // --- Session history stamps ------------------------------------------------ + updateHistoryStamps(now, world, px, py, yaw) { + if (!this.session || now - this.lastHistoryStampAt < HISTORY_COOLDOWN_MS) { + return false; + } + if (this.stampedHistory.size > 200) { + this.stampedHistory.clear(); + } + for (const exchange of this.session.history) { + const text = exchange.response || exchange.prompt; + if (!text) { + continue; + } + const key = hashText(exchange.prompt + "\0" + exchange.response); + if (this.stampedHistory.has(key)) { + continue; + } + const hit = this.findFreeWall(world, px, py, yaw); + if (!hit) { + return false; + } + const edgeKey2 = `${hit.cx},${hit.cy},${hit.direction}`; + const slot = this.claimStaticSlot(); + if (this.stampStatic(world, hit, slot, edgeKey2, text)) { + this.stampedHistory.add(key); + this.lastHistoryStampAt = now; + return true; + } + } + return false; + } + // --- Live ghost writing ------------------------------------------------------ + updateLive(now, world, px, py, yaw) { + const target = this.session?.current ?? ""; + if (target.length === 0) { + return false; + } + let changed = false; + if (!target.startsWith(this.liveText)) { + changed = this.commitLive(world, Number.MAX_SAFE_INTEGER) || changed; + this.live = null; + this.liveHit = null; + this.liveShown = 0; + this.liveConsumed = 0; + this.lastLiveKey = ""; + } + this.liveText = target; + let segment = target.slice(this.liveConsumed); + if (segment.length === 0 && !this.live) { + return changed; + } + const faced = findWallAhead(world, px, py, yaw); + const facedKey = faced ? `${faced.cx},${faced.cy},${faced.direction}` : null; + if (this.live && faced && facedKey !== this.live.edgeKey && this.isFreeWall(facedKey) && now - this.lastWallSwitchAt > WALL_SWITCH_MS) { + const shownInSegment2 = Math.max(0, Math.floor(this.liveShown) - this.liveConsumed); + if (shownInSegment2 >= MIN_COMMIT_CHARS) { + changed = this.commitLive(world, shownInSegment2) || changed; + } + changed = this.startLive(world, faced) || changed; + this.lastWallSwitchAt = now; + segment = target.slice(this.liveConsumed); + } + if (!this.live) { + const hit = faced && this.isFreeWall(facedKey) ? faced : this.findFreeWall(world, px, py, yaw); + if (!hit || !this.startLive(world, hit)) { + return changed; + } + changed = true; + this.lastWallSwitchAt = now; + } + const dt = Math.min(0.2, (now - this.lastLiveTick) / 1e3 || 0); + this.lastLiveTick = now; + this.liveShown = Math.min(target.length, this.liveShown + dt * REVEAL_CPS); + const shownInSegment = Math.min(segment.length, Math.max(0, Math.floor(this.liveShown) - this.liveConsumed)); + if (shownInSegment >= SEGMENT_CAP) { + changed = this.commitLive(world, shownInSegment) || changed; + return changed; + } + const done = this.session ? !this.session.working : true; + if (done && this.liveConsumed + shownInSegment >= target.length && this.live) { + changed = this.commitLive(world, segment.length) || changed; + return changed; + } + const key = `${shownInSegment}|${segment.length}|${this.live.edgeKey}`; + if (key === this.lastLiveKey || now - this.lastLiveDraw < LIVE_REDRAW_MS) { + return changed; + } + this.lastLiveKey = key; + this.lastLiveDraw = now; + const stillRevealing = this.liveConsumed + shownInSegment < target.length; + this.drawSlot( + LIVE_SLOT, + segment.slice(0, shownInSegment), + stillRevealing ? GHOST_TAIL : 0, + `${this.live.edgeKey}#${this.liveConsumed}` + ); + return true; + } + /** Places (or moves) the live quad onto the given wall, cleared. */ + startLive(world, hit) { + const vertices = buildWallQuad(world, hit, LIVE_SLOT); + if (!vertices) { + return false; + } + this.live = { edgeKey: `${hit.cx},${hit.cy},${hit.direction}`, slot: LIVE_SLOT, vertices }; + this.liveHit = hit; + this.lastLiveKey = ""; + this.drawSlot(LIVE_SLOT, "", 0); + return true; + } + /** + * Freezes up to `maxChars` of the live segment onto a static slot on the + * wall it was written on (cut at a word boundary), advances the consumed + * counter, and frees the live quad. Returns whether anything was drawn. + */ + commitLive(world, maxChars) { + const hit = this.liveHit; + const live = this.live; + this.live = null; + this.liveHit = null; + this.lastLiveKey = ""; + if (!hit || !live) { + return false; + } + const segment = this.liveText.slice(this.liveConsumed); + let cut = Math.min(segment.length, maxChars); + if (cut < segment.length) { + const space = segment.lastIndexOf(" ", cut); + if (space > cut * 0.4) { + cut = space + 1; + } + } + const committed = segment.slice(0, cut).trim(); + this.liveConsumed += cut; + this.liveShown = Math.max(this.liveShown, this.liveConsumed); + if (committed.length === 0) { + return false; + } + const slot = this.claimStaticSlot(); + const vertices = buildWallQuad(world, hit, slot); + if (!vertices) { + return false; + } + this.drawSlot(slot, committed, 0); + this.stamps.push({ edgeKey: live.edgeKey, slot, vertices }); + return true; + } + isFreeWall(edgeKey2) { + return this.live?.edgeKey !== edgeKey2 && !this.stamps.some((s) => s.edgeKey === edgeKey2); + } + // --- Shared helpers ---------------------------------------------------------- + /** Next rotating static slot, evicting whatever writing used it before. */ + claimStaticSlot() { + const slot = this.nextSlot % STATIC_SLOTS; + this.nextSlot++; + this.stamps = this.stamps.filter((s) => s.slot !== slot); + return slot; + } + stampStatic(world, hit, slot, edgeKey2, text) { + const vertices = buildWallQuad(world, hit, slot); + if (!vertices) { + return false; + } + this.drawSlot(slot, text, 0); + this.stamps.push({ edgeKey: edgeKey2, slot, vertices }); + return true; + } + /** First unclaimed wall found by fanning rays around the player's gaze. */ + findFreeWall(world, px, py, yaw) { + for (const offset of RAY_OFFSETS) { + const hit = findWallAhead(world, px, py, yaw + offset); + if (hit && this.isFreeWall(`${hit.cx},${hit.cy},${hit.direction}`)) { + return hit; + } + } + return null; + } + uploadMesh(renderer) { + const vertices = []; + const indices = []; + const all = this.live ? [...this.stamps, this.live] : this.stamps; + for (const stamp of all) { + const base = vertices.length / 6; + vertices.push(...stamp.vertices); + indices.push(base, base + 1, base + 2, base, base + 2, base + 3); + } + renderer.setDecalMesh(new Float32Array(vertices), new Uint16Array(indices)); + } + /** + * Draws one writing into an atlas slot with the marker treatment: glyphs + * jittered like handwriting, outline copies extruded ~3px toward the wall, + * black fill under a very subtle drop shadow, splatter, and finally a + * low-opacity gradient overlay (angle and stops seeded per text) that + * shifts across the strokes like uneven marker ink. + * + * `ghostTail` renders the last N characters progressively fainter, for the + * live response materializing onto the wall. + */ + drawSlot(slot, text, ghostTail, seedText) { + const ctx = this.ctx; + const ox = slot % SLOT_COLS * SLOT_W; + const oy = Math.floor(slot / SLOT_COLS) * SLOT_H; + ctx.save(); + ctx.clearRect(ox, oy, SLOT_W, SLOT_H); + if (text.trim().length === 0) { + ctx.restore(); + return; + } + ctx.beginPath(); + ctx.rect(ox, oy, SLOT_W, SLOT_H); + ctx.clip(); + const rng = mulberry(hashCode(seedText ?? text) || 1); + let size = 80; + let lines = [text.trim()]; + for (; size > 26; size -= 4) { + ctx.font = `bold ${size}px ${INK_FONT}`; + lines = wrapToLines(ctx, text, SLOT_W - 70); + if (lines.length * size * 1.12 <= SLOT_H - 36) { + break; + } + } + const maxLines = Math.max(1, Math.floor((SLOT_H - 36) / (size * 1.12))); + if (lines.length > maxLines) { + lines = lines.slice(0, maxLines); + lines[maxLines - 1] += "\u2026"; + } + const ink = (alpha) => `rgba(16, 14, 13, ${alpha.toFixed(3)})`; + const extrude = (alpha) => `rgba(34, 30, 27, ${alpha.toFixed(3)})`; + ctx.textBaseline = "alphabetic"; + const lineGap = size * 1.12; + const blockH = lineGap * (lines.length - 1); + const baseY = oy + SLOT_H / 2 - blockH / 2 + size * 0.34; + const totalChars = lines.reduce((n, line) => n + line.length, 0); + let drawnChars = 0; + for (let li = 0; li < lines.length; li++) { + const line = lines[li]; + ctx.font = `bold ${size}px ${INK_FONT}`; + const lineW = ctx.measureText(line).width; + let x = ox + (SLOT_W - lineW) / 2 + (rng() - 0.5) * 16; + const y = baseY + li * lineGap + (rng() - 0.5) * 8; + for (const char of line) { + const w = ctx.measureText(char).width; + const fromEnd = totalChars - drawnChars; + const ghost = ghostTail > 0 && fromEnd <= ghostTail ? fromEnd / (ghostTail + 1) : 0; + const alpha = (0.82 + rng() * 0.14) * (1 - ghost * 0.75); + ctx.save(); + ctx.translate(x + w / 2, y + (rng() - 0.5) * size * 0.09); + ctx.rotate((rng() - 0.5) * 0.14); + ctx.shadowColor = "transparent"; + for (const depth of [3, 2, 1]) { + ctx.fillStyle = extrude(0.2 * (1 - ghost)); + ctx.fillText(char, -w / 2 + depth, depth * 0.8); + } + ctx.shadowColor = "rgba(0, 0, 0, 0.3)"; + ctx.shadowBlur = 2; + ctx.shadowOffsetX = 1; + ctx.shadowOffsetY = 1.5; + ctx.fillStyle = ink(alpha); + ctx.fillText(char, -w / 2, 0); + ctx.restore(); + x += w * (0.94 + rng() * 0.06); + drawnChars++; + if (rng() < 0.07 && ghost === 0) { + const dripLen = 16 + rng() * 56; + const dripX = x - w / 2 + (rng() - 0.5) * 6; + const grad2 = ctx.createLinearGradient(0, y, 0, y + dripLen); + grad2.addColorStop(0, ink(0.55)); + grad2.addColorStop(1, ink(0)); + ctx.fillStyle = grad2; + ctx.fillRect(dripX, y - 2, 1.4 + rng() * 1.6, dripLen); + } + } + } + ctx.shadowColor = "transparent"; + ctx.shadowBlur = 0; + const splats = 5 + Math.floor(rng() * 6); + for (let i = 0; i < splats; i++) { + const sx = ox + 30 + rng() * (SLOT_W - 60); + const sy = oy + 30 + rng() * (SLOT_H - 60); + ctx.fillStyle = ink(0.2 + rng() * 0.4); + ctx.beginPath(); + ctx.arc(sx, sy, 0.8 + rng() * rng() * 5, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalCompositeOperation = "source-atop"; + const theta = rng() * Math.PI * 2; + const r = Math.max(SLOT_W, SLOT_H) / 2; + const cxm = ox + SLOT_W / 2; + const cym = oy + SLOT_H / 2; + const grad = ctx.createLinearGradient( + cxm - Math.cos(theta) * r, + cym - Math.sin(theta) * r, + cxm + Math.cos(theta) * r, + cym + Math.sin(theta) * r + ); + let pos = 0; + let bright = rng() < 0.5; + while (pos < 1) { + const alpha = 0.04 + rng() * 0.08; + grad.addColorStop(pos, bright ? `rgba(255, 255, 255, ${alpha.toFixed(3)})` : `rgba(30, 30, 30, ${alpha.toFixed(3)})`); + pos += 0.12 + rng() * 0.2; + bright = !bright; + } + ctx.fillStyle = grad; + ctx.fillRect(ox, oy, SLOT_W, SLOT_H); + ctx.globalCompositeOperation = "source-over"; + ctx.restore(); + } + }; + function wrapToLines(ctx, text, maxWidth) { + const words = text.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) { + return [""]; + } + const lines = []; + let line = ""; + for (const word of words) { + const candidate = line ? `${line} ${word}` : word; + if (line && ctx.measureText(candidate).width > maxWidth) { + lines.push(line); + line = word; + } else { + line = candidate; + } + } + lines.push(line); + return lines; + } + function hashCode(text) { + let hash = 2166136261; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; + } + function hashText(text) { + return hashCode(text).toString(36); + } + function mulberry(seed) { + let state = seed >>> 0; + return () => { + state = state + 1831565813 >>> 0; + let t = state; + t = Math.imul(t ^ t >>> 15, t | 1); + t ^= t + Math.imul(t ^ t >>> 7, t | 61); + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; + } + function findWallAhead(world, px, py, yaw) { + const fx = -Math.sin(yaw); + const fy = -Math.cos(yaw); + let cx = Math.floor(px); + let cy = Math.floor(py); + const stepX = fx > 0 ? 1 : -1; + const stepY = fy > 0 ? 1 : -1; + const tDeltaX = fx !== 0 ? 1 / Math.abs(fx) : Infinity; + const tDeltaY = fy !== 0 ? 1 / Math.abs(fy) : Infinity; + let tMaxX = fx !== 0 ? (fx > 0 ? cx + 1 - px : px - cx) / Math.abs(fx) : Infinity; + let tMaxY = fy !== 0 ? (fy > 0 ? cy + 1 - py : py - cy) / Math.abs(fy) : Infinity; + for (let i = 0; i < MAX_WRITE_DISTANCE * 2; i++) { + let direction; + let t; + if (tMaxX < tMaxY) { + direction = stepX > 0 ? "east" : "west"; + t = tMaxX; + tMaxX += tDeltaX; + } else { + direction = stepY > 0 ? "south" : "north"; + t = tMaxY; + tMaxY += tDeltaY; + } + if (t > MAX_WRITE_DISTANCE) { + return null; + } + if (!world.generator.isPassable(cx, cy, direction)) { + return { cx, cy, direction, hitX: px + fx * t, hitY: py + fy * t }; + } + const d = step(direction); + cx += d.dx; + cy += d.dy; + } + return null; + } + function buildWallQuad(world, hit, slot) { + const cell = world.generator.getCell(hit.cx, hit.cy); + const edge = cell.edges[hit.direction]; + const inward = step(opposite(hit.direction)); + const off = WALL_HALF_DEPTH + 8e-3; + let ax = edge.start.x + inward.dx * off; + let az = edge.start.y + inward.dy * off; + let bx = edge.end.x + inward.dx * off; + let bz = edge.end.y + inward.dy * off; + if (-(bz - az) * inward.dx + (bx - ax) * inward.dy < 0) { + [ax, bx] = [bx, ax]; + [az, bz] = [bz, az]; + } + const len = Math.hypot(bx - ax, bz - az); + const width = Math.min(1.5, len - 0.24); + if (width < 0.6) { + return null; + } + const ux = (bx - ax) / len; + const uz = (bz - az) / len; + const hitU = (hit.hitX - ax) * ux + (hit.hitY - az) * uz; + const center2 = Math.min(len - 0.12 - width / 2, Math.max(0.12 + width / 2, hitU)); + const x0 = ax + ux * (center2 - width / 2); + const z0 = az + uz * (center2 - width / 2); + const x1 = ax + ux * (center2 + width / 2); + const z1 = az + uz * (center2 + width / 2); + const shade = 0.42 + 0.5 * world.lightAt((x0 + x1) / 2 + inward.dx * 0.2, (z0 + z1) / 2 + inward.dy * 0.2); + const u0 = slot % SLOT_COLS * SLOT_W / ATLAS_SIZE; + const v0 = Math.floor(slot / SLOT_COLS) * SLOT_H / ATLAS_SIZE; + const u1 = u0 + SLOT_W / ATLAS_SIZE; + const v1 = v0 + SLOT_H / ATLAS_SIZE; + return [ + x0, + WRITE_Y0, + z0, + u0, + v1, + shade, + x1, + WRITE_Y0, + z1, + u1, + v1, + shade, + x1, + WRITE_Y1, + z1, + u1, + v0, + shade, + x0, + WRITE_Y1, + z0, + u0, + v0, + shade + ]; + } + + // src/webview/input.ts + var Input = class { + constructor(surface) { + this.surface = surface; + window.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + this.onMenuToggle?.(); + return; + } + const target = e.target; + if (target instanceof HTMLElement && (target.matches("input, select, textarea, button") || target.isContentEditable)) { + return; + } + if (e.key.toLowerCase() === "m") { + this.onMenuToggle?.(); + return; + } + this.held.add(normalize(e.key)); + if (isGameKey(e.key)) { + e.preventDefault(); + } + }); + window.addEventListener("keyup", (e) => this.held.delete(normalize(e.key))); + window.addEventListener("blur", () => this.held.clear()); + surface.addEventListener("click", () => { + if (this.mouseLookEnabled && document.pointerLockElement !== surface) { + surface.requestPointerLock(); + } + }); + document.addEventListener("mousemove", (e) => { + if (document.pointerLockElement === this.surface) { + this.lookDx += e.movementX; + this.lookDy += e.movementY; + } + }); + } + held = /* @__PURE__ */ new Set(); + lookDx = 0; + mouseLookEnabled = true; + onMenuToggle = null; + lookDy = 0; + releasePointer() { + if (document.pointerLockElement === this.surface) { + document.exitPointerLock(); + } + } + /** Accumulated mouse-look delta since the last call, in pixels. */ + consumeLook() { + const out = { dx: this.lookDx, dy: this.lookDy }; + this.lookDx = 0; + this.lookDy = 0; + return out; + } + state() { + const has = (...keys) => keys.some((k) => this.held.has(k)); + return { + forward: (has("w", "arrowup") ? 1 : 0) - (has("s", "arrowdown") ? 1 : 0), + strafe: (has("d") ? 1 : 0) - (has("a") ? 1 : 0), + turn: (has("arrowright", "e") ? 1 : 0) - (has("arrowleft", "q") ? 1 : 0), + running: has("shift") + }; + } + }; + function normalize(key) { + return key.toLowerCase(); + } + function isGameKey(key) { + return ["w", "a", "s", "d", "q", "e", "shift"].includes(key.toLowerCase()) || key.startsWith("Arrow"); + } + + // src/webview/monster.ts + var BODY_FORMS = ["spider", "humanoid", "cloud"]; + var CATCH_DISTANCE = 0.45; + var REPATH_MS = 600; + var PATH_NODE_CAP = 900; + var DARK = [1, 1, 1]; + var BODY_SHADE = 0.16; + var EYE_TINT = [1, 0.16, 0.1]; + var Monster = class { + constructor(world, config, now) { + this.world = world; + this.config = config; + this.arm(now); + } + form = "spider"; + x = 0; + y = 0; + config; + stalking = false; + spawnAt = 0; + path = []; + lastPathAt = 0; + phase = 0; + heading = 0; + get isStalking() { + return this.stalking; + } + /** Applies new tuning live; the spawn window only affects future arms. */ + configure(config) { + const formChanged = config.form !== this.config.form; + this.config = config; + if (formChanged && config.form !== "random") { + this.form = config.form; + } + } + /** Returns to dormant and schedules the next appearance. */ + arm(now) { + this.stalking = false; + this.path = []; + const min = Math.min(this.config.spawnMinMs, this.config.spawnMaxMs); + const max = Math.max(this.config.spawnMinMs, this.config.spawnMaxMs); + this.spawnAt = now + min + Math.random() * (max - min); + } + update(now, dt, px, py) { + if (!this.stalking) { + if (now >= this.spawnAt) { + this.spawn(px, py); + return "spawned"; + } + return null; + } + const distToPlayer = Math.hypot(px - this.x, py - this.y); + if (distToPlayer < CATCH_DISTANCE) { + this.arm(now); + return "caught"; + } + if (now - this.lastPathAt > REPATH_MS) { + this.lastPathAt = now; + this.path = this.findPath(Math.floor(this.x), Math.floor(this.y), Math.floor(px), Math.floor(py)); + } + let target = this.path[0] ? center(this.path[0]) : this.greedyStep(px, py); + if (this.path.length <= 1 && distToPlayer < 1.4) { + target = { x: px, y: py }; + } + const dx = target.x - this.x; + const dy = target.y - this.y; + const dist = Math.hypot(dx, dy); + const travel = this.config.speed * dt; + if (dist > 1e-4) { + const t = Math.min(1, travel / dist); + this.x += dx * t; + this.y += dy * t; + this.heading = Math.atan2(dy, dx); + this.phase += travel * 4.4; + } + if (this.path[0] && Math.hypot(center(this.path[0]).x - this.x, center(this.path[0]).y - this.y) < 0.08) { + this.path.shift(); + } + return null; + } + spawn(px, py) { + if (this.config.form === "random") { + this.form = BODY_FORMS[Math.floor(Math.random() * BODY_FORMS.length)]; + } else { + this.form = this.config.form; + } + const angle = Math.random() * Math.PI * 2; + const dist = 9 + Math.random() * 5; + this.x = Math.floor(px + Math.cos(angle) * dist) + 0.5; + this.y = Math.floor(py + Math.sin(angle) * dist) + 0.5; + this.path = []; + this.lastPathAt = 0; + this.stalking = true; + } + /** Breadth-first search through open edges, capped for the infinite grid. */ + findPath(fromCx, fromCy, toCx, toCy) { + if (fromCx === toCx && fromCy === toCy) { + return []; + } + const key = (cx, cy) => `${cx},${cy}`; + const parents = /* @__PURE__ */ new Map(); + parents.set(key(fromCx, fromCy), null); + const queue = [{ cx: fromCx, cy: fromCy }]; + let found = false; + while (queue.length > 0 && parents.size < PATH_NODE_CAP) { + const node = queue.shift(); + if (node.cx === toCx && node.cy === toCy) { + found = true; + break; + } + for (const direction of DIRECTIONS) { + if (!this.world.generator.isPassable(node.cx, node.cy, direction)) { + continue; + } + const { dx, dy } = step(direction); + const next = { cx: node.cx + dx, cy: node.cy + dy }; + const nextKey = key(next.cx, next.cy); + if (!parents.has(nextKey)) { + parents.set(nextKey, key(node.cx, node.cy)); + queue.push(next); + } + } + } + if (!found) { + return []; + } + const path = []; + let cursor = key(toCx, toCy); + while (cursor && cursor !== key(fromCx, fromCy)) { + const [cx, cy] = cursor.split(",").map(Number); + path.unshift({ cx, cy }); + cursor = parents.get(cursor) ?? null; + } + return path; + } + /** No path known: shuffle toward the player through any open edge. */ + greedyStep(px, py) { + const cx = Math.floor(this.x); + const cy = Math.floor(this.y); + let best = { x: this.x, y: this.y }; + let bestDist = Number.POSITIVE_INFINITY; + for (const direction of DIRECTIONS) { + if (!this.world.generator.isPassable(cx, cy, direction)) { + continue; + } + const { dx, dy } = step(direction); + const candidate = center({ cx: cx + dx, cy: cy + dy }); + const dist = Math.hypot(px - candidate.x, py - candidate.y); + if (dist < bestDist) { + bestDist = dist; + best = candidate; + } + } + return best; + } + /** Emits this frame's world-space geometry. */ + buildMesh(now, px, py) { + const b = new MeshBuilder(); + switch (this.form) { + case "spider": + this.buildSpider(b); + break; + case "humanoid": + this.buildHumanoid(b); + break; + case "cloud": + this.buildCloud(b, now); + break; + } + this.buildEyes(b, px, py); + return { vertices: b.vertices(), indices: b.indices() }; + } + buildSpider(b) { + const { x, y } = this; + emitBox(b, x - 0.19, x + 0.19, 0.2, 0.42, y - 0.23, y + 0.23, TILE.fabric, DARK, BODY_SHADE); + emitBox(b, x - 0.09, x + 0.09, 0.26, 0.4, y - 0.34, y - 0.2, TILE.fabric, DARK, BODY_SHADE * 1.2); + for (let i = 0; i < 8; i++) { + const side = i < 4 ? -1 : 1; + const spread = (i % 4 - 1.5) * 0.5 + this.heading; + const lift = Math.max(0, Math.sin(this.phase + i * (Math.PI / 2))) * 0.1; + const hip = [x + side * 0.17, 0.34, y + Math.sin(spread) * 0.15]; + const foot = [ + x + side * (0.5 + 0.1 * Math.sin(i * 2.1)), + lift, + y + Math.sin(spread) * 0.42 + Math.cos(spread) * side * 0.12 + ]; + this.limb(b, hip, foot, 0.045); + } + } + buildHumanoid(b) { + const { x, y } = this; + const sway = Math.sin(this.phase * 0.5) * 0.03; + emitBox(b, x - 0.11 + sway, x + 0.11 + sway, 0.52, 1.06, y - 0.07, y + 0.07, TILE.fabric, DARK, BODY_SHADE); + emitBox(b, x - 0.06 + sway, x + 0.06 + sway, 1.06, 1.2, y - 0.06, y + 0.06, TILE.fabric, DARK, BODY_SHADE * 1.15); + const strideX = Math.cos(this.heading) * 0.16; + const strideY = Math.sin(this.heading) * 0.16; + const gait = Math.sin(this.phase); + this.limb(b, [x - 0.06, 0.55, y], [x - 0.06 + strideX * gait, 0, y + strideY * gait], 0.05); + this.limb(b, [x + 0.06, 0.55, y], [x + 0.06 - strideX * gait, 0, y - strideY * gait], 0.05); + this.limb(b, [x - 0.13 + sway, 1, y], [x - 0.13 + sway - strideX * gait * 0.5, 0.5, y - strideY * gait * 0.5], 0.04); + this.limb(b, [x + 0.13 + sway, 1, y], [x + 0.13 + sway + strideX * gait * 0.5, 0.5, y + strideY * gait * 0.5], 0.04); + } + buildCloud(b, now) { + const { x, y } = this; + const t = now / 1e3; + for (let i = 0; i < 10; i++) { + const jx = Math.sin(t * 1.3 + i * 2.4) * 0.14; + const jy = Math.sin(t * 1.7 + i * 1.9) * 0.1; + const jz = Math.cos(t * 1.1 + i * 3.2) * 0.14; + const size = 0.12 + i * 37 % 10 * 0.02; + const cx = x + Math.sin(i * 2.4) * 0.2 + jx; + const cy = 0.45 + Math.sin(i * 1.6) * 0.3 + jy; + const cz = y + Math.cos(i * 2.9) * 0.2 + jz; + emitBox(b, cx - size, cx + size, cy - size, cy + size, cz - size, cz + size, TILE.concrete, DARK, BODY_SHADE * (0.8 + i % 3 * 0.2)); + } + } + /** Two small emissive eyes billboarded toward the player. */ + buildEyes(b, px, py) { + const eyeHeight = this.form === "spider" ? 0.36 : this.form === "humanoid" ? 1.13 : 0.7; + const toPlayerX = px - this.x; + const toPlayerY = py - this.y; + const len = Math.hypot(toPlayerX, toPlayerY) || 1; + const fx = toPlayerX / len; + const fy = toPlayerY / len; + const rx = -fy; + const ry = fx; + const ex = this.x + fx * 0.2; + const ey = this.y + fy * 0.2; + const r = 0.03; + for (const side of [-1, 1]) { + const cx = ex + rx * side * 0.06; + const cy = ey + ry * side * 0.06; + b.quad( + [cx - rx * r, eyeHeight - r, cy - ry * r], + [cx + rx * r, eyeHeight - r, cy + ry * r], + [cx + rx * r, eyeHeight + r, cy + ry * r], + [cx - rx * r, eyeHeight + r, cy - ry * r], + [0, 0], + [1, 0], + [1, 1], + [0, 1], + TILE.lightPanel, + EYE_TINT, + [EMISSIVE_SHADE + 0.8, EMISSIVE_SHADE + 0.8, EMISSIVE_SHADE + 0.8, EMISSIVE_SHADE + 0.8] + ); + } + } + /** A thin double-sided crossed-quad limb between two points. */ + limb(b, from, to, width) { + const shades = [BODY_SHADE, BODY_SHADE, BODY_SHADE, BODY_SHADE]; + const uv = [ + [0, 0], + [1, 0], + [1, 1], + [0, 1] + ]; + const planes = [ + [width, 0, 0], + [0, 0, width] + ]; + for (const offset of planes) { + const a0 = [from[0] - offset[0], from[1] - offset[1], from[2] - offset[2]]; + const a1 = [from[0] + offset[0], from[1] + offset[1], from[2] + offset[2]]; + const b1 = [to[0] + offset[0], to[1] + offset[1], to[2] + offset[2]]; + const b0 = [to[0] - offset[0], to[1] - offset[1], to[2] - offset[2]]; + b.quad(a0, a1, b1, b0, uv[0], uv[1], uv[2], uv[3], TILE.fabric, DARK, shades); + b.quad(b0, b1, a1, a0, uv[0], uv[1], uv[2], uv[3], TILE.fabric, DARK, shades); + } + } + }; + function center(cell) { + return { x: cell.cx + 0.5, y: cell.cy + 0.5 }; + } + + // src/webview/menu.ts + var STYLE = ` +.bv-menu { + position: absolute; inset: 0; display: none; z-index: 30; + align-items: center; justify-content: center; + background: rgba(16, 17, 19, 0.72); + font-family: "Segoe UI", system-ui, sans-serif; + color: #f0f2f3; +} +.bv-menu.open { display: flex; } +.bv-card { + background: linear-gradient(#22252a, #1c1f23); + border: 1px solid #3a3f46; border-radius: 10px; + min-width: 300px; max-width: 380px; max-height: 82%; overflow-y: auto; + padding: 22px 26px; box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55); +} +.bv-card h1 { margin: 0 0 2px; font-size: 20px; letter-spacing: 3px; font-weight: 600; } +.bv-card .bv-sub { margin: 0 0 18px; font-size: 12px; color: #9aa3ad; } +.bv-menu button { + display: block; width: 100%; margin: 8px 0; padding: 10px 14px; + background: #2b3036; color: #f0f2f3; border: 1px solid #454c55; + border-radius: 6px; font-size: 14px; cursor: pointer; text-align: left; +} +.bv-menu button:hover { background: #343a42; } +.bv-menu button.bv-accent { background: #6b4b12; border-color: #93691c; } +.bv-menu button.bv-accent:hover { background: #7d5915; } +.bv-row { display: flex; align-items: center; justify-content: space-between; margin: 10px 0; font-size: 13px; gap: 12px; } +.bv-row label { flex: 1; color: #cfd6dc; } +.bv-row input[type="checkbox"] { width: 16px; height: 16px; accent-color: #93691c; } +.bv-row input[type="range"] { width: 130px; accent-color: #93691c; } +.bv-row input[type="number"] { + width: 110px; background: #15171a; color: #f0f2f3; + border: 1px solid #454c55; border-radius: 4px; padding: 5px 7px; font-size: 13px; +} +.bv-row .bv-val { width: 34px; text-align: right; color: #9aa3ad; font-variant-numeric: tabular-nums; } +.bv-row select { + background: #15171a; color: #f0f2f3; border: 1px solid #454c55; + border-radius: 4px; padding: 5px 7px; font-size: 13px; min-width: 150px; +} +.bv-h { + margin: 16px 0 4px; font-size: 11px; letter-spacing: 2px; + color: #8a93a0; text-transform: uppercase; +} +.bv-h:first-of-type { margin-top: 8px; } +.bv-stats { margin-top: 14px; padding-top: 12px; border-top: 1px solid #33383f; font-size: 12px; color: #9aa3ad; line-height: 1.7; } +.bv-help { font-size: 13px; color: #cfd6dc; line-height: 1.9; } +.bv-help kbd { + background: #15171a; border: 1px solid #454c55; border-radius: 4px; + padding: 1px 6px; font-family: inherit; font-size: 12px; +} +.bv-back { margin-top: 16px !important; } +`; + var Menu = class { + constructor(parent, callbacks) { + this.callbacks = callbacks; + const style = document.createElement("style"); + style.textContent = STYLE; + document.head.appendChild(style); + this.root = document.createElement("div"); + this.root.className = "bv-menu"; + parent.appendChild(this.root); + this.views = { + main: this.buildMain(), + settings: this.buildSettings(), + help: this.buildHelp() + }; + for (const view of Object.values(this.views)) { + this.root.appendChild(view); + } + this.show("main"); + } + root; + views; + statsEl = null; + settings = null; + openFlag = false; + lastFocused = null; + get isOpen() { + return this.openFlag; + } + open() { + this.openFlag = true; + this.lastFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null; + this.root.classList.add("open"); + this.show("main"); + } + close() { + this.openFlag = false; + this.root.classList.remove("open"); + if (this.lastFocused?.isConnected) { + this.lastFocused.focus(); + } + this.lastFocused = null; + } + syncSettings(settings) { + this.settings = settings; + for (const input of this.root.querySelectorAll("[data-key]")) { + const key = input.dataset.key; + const value = settings[key]; + if (input.type === "checkbox") { + input.checked = Boolean(value); + } else { + input.value = String(value); + const label = input.parentElement?.querySelector(".bv-val"); + if (label) { + label.textContent = String(value); + } + } + } + } + syncStats(stats) { + if (this.statsEl) { + this.statsEl.innerHTML = `seed ${stats.seed}
cells visited ${stats.cellsVisited}
cells cached ${stats.cacheSize}`; + } + } + show(name) { + for (const [key, view] of Object.entries(this.views)) { + view.style.display = key === name ? "block" : "none"; + } + // Only the visible card is a live dialog; hand it focus so assistive + // tech announces the context change instead of staying on the game. + if (this.openFlag) { + const view = this.views[name]; + const target = view.querySelector("button, input, select") ?? view; + target.focus(); + } + } + card() { + const card = document.createElement("div"); + card.className = "bv-card"; + card.setAttribute("role", "dialog"); + card.setAttribute("aria-modal", "true"); + card.setAttribute("aria-label", "BackRooms pause menu"); + card.tabIndex = -1; + card.innerHTML = '

BACKROOMS

noclipped into a Copilot canvas

'; + return card; + } + button(label, onClick, accent = false) { + const button = document.createElement("button"); + button.textContent = label; + if (accent) { + button.className = "bv-accent"; + } + button.addEventListener("click", onClick); + return button; + } + buildMain() { + const card = this.card(); + card.appendChild(this.button("Resume", () => this.callbacks.onResume())); + card.appendChild(this.button("Relocate (new seed)", () => this.callbacks.onRelocate(), true)); + card.appendChild(this.button("Settings", () => this.show("settings"))); + card.appendChild(this.button("Help", () => this.show("help"))); + this.statsEl = document.createElement("div"); + this.statsEl.className = "bv-stats"; + card.appendChild(this.statsEl); + return card; + } + toggleRow(label, key) { + const row = document.createElement("div"); + row.className = "bv-row"; +const input = document.createElement("input"); + input.type = "checkbox"; + input.dataset.key = key; + input.setAttribute("aria-label", label); + input.addEventListener("change", () => this.callbacks.onSettingChange(key, input.checked)); + const text = document.createElement("label"); + text.textContent = label; + row.append(text, input); + return row; + } + sliderRow(label, key, min, max, stepSize) { + const row = document.createElement("div"); + row.className = "bv-row"; + const text = document.createElement("label"); + text.textContent = label; + const value = document.createElement("span"); + value.className = "bv-val"; +const input = document.createElement("input"); + input.type = "range"; + input.min = String(min); + input.max = String(max); + input.step = String(stepSize); + input.dataset.key = key; + input.setAttribute("aria-label", label); + input.addEventListener("input", () => { + value.textContent = input.value; + this.callbacks.onSettingChange(key, Number(input.value)); + }); + row.append(text, input, value); + return row; + } + heading(text) { + const h = document.createElement("div"); + h.className = "bv-h"; + h.textContent = text; + return h; + } + selectRow(label, key, options) { + const row = document.createElement("div"); + row.className = "bv-row"; + const text = document.createElement("label"); + text.textContent = label; +const select = document.createElement("select"); + select.dataset.key = key; + select.setAttribute("aria-label", label); + for (const option of options) { + const el = document.createElement("option"); + el.value = option.value; + el.textContent = option.label; + select.appendChild(el); + } + select.addEventListener("change", () => this.callbacks.onSettingChange(key, select.value)); + row.append(text, select); + return row; + } + buildSettings() { + const card = this.card(); + card.appendChild(this.heading("Camera")); + card.appendChild(this.toggleRow("Camera shake", "cameraShake")); + card.appendChild(this.toggleRow("Film grain", "filmGrain")); + card.appendChild(this.toggleRow("Camcorder HUD", "vhsHud")); + card.appendChild(this.heading("Controls")); + card.appendChild(this.toggleRow("Mouse look", "mouseLook")); + card.appendChild(this.toggleRow("Invert turn", "invertTurn")); + card.appendChild(this.toggleRow("Invert strafe", "invertStrafe")); + card.appendChild(this.toggleRow("Invert forward/back", "invertForward")); + card.appendChild(this.sliderRow("Walk speed", "moveSpeed", 0.5, 6, 0.1)); + card.appendChild(this.heading("Materials")); + card.appendChild( + this.selectRow("Wall material", "materialPreset", [ + { value: "classic", label: "Classic wallpaper mix" }, + { value: "office", label: "Plain drywall" }, + { value: "pool", label: "Ceramic tile" }, + { value: "concrete", label: "Bare concrete" }, + { value: "panel", label: "Wood paneling" } + ]) + ); + card.appendChild(this.sliderRow("Hue shift", "materialHueShift", -180, 180, 5)); + card.appendChild(this.sliderRow("Brightness", "materialBrightness", 0.6, 1.4, 0.05)); + card.appendChild(this.heading("Monster")); + card.appendChild(this.toggleRow("Monster", "monsterEnabled")); + card.appendChild( + this.selectRow("Form", "monsterForm", [ + { value: "spider", label: "Spider-like" }, + { value: "humanoid", label: "Human-like" }, + { value: "cloud", label: "Cloud-like" }, + { value: "random", label: "Random each spawn" } + ]) + ); + card.appendChild(this.sliderRow("Speed", "monsterSpeed", 0.5, 5, 0.1)); + card.appendChild(this.sliderRow("Spawn after (min)", "monsterSpawnMin", 0.1, 10, 0.1)); + card.appendChild(this.sliderRow("Spawn before (min)", "monsterSpawnMax", 0.5, 15, 0.5)); + card.appendChild(this.heading("World")); + card.appendChild(this.toggleRow("Furniture", "furniture")); + card.appendChild(this.toggleRow("Wallpaper shifts", "wallpaperShifts")); + card.appendChild(this.sliderRow("Render distance", "renderDistance", 6, 28, 1)); + card.appendChild(this.heading("Copilot")); + card.appendChild(this.toggleRow("Ghost-writer on the walls", "copilotGhostWriter")); + const seedRow = document.createElement("div"); + seedRow.className = "bv-row"; + const seedLabel = document.createElement("label"); + seedLabel.textContent = "Seed (0 = random)"; +const seedInput = document.createElement("input"); + seedInput.type = "number"; + seedInput.dataset.key = "seed"; + seedInput.setAttribute("aria-label", "Seed (0 = random)"); + seedInput.addEventListener("change", () => { + const seed = Math.trunc(Number(seedInput.value)) || 0; + this.callbacks.onSettingChange("seed", seed); + this.callbacks.onReseed(seed); + }); + seedRow.append(seedLabel, seedInput); + card.appendChild(seedRow); + const back = this.button("Back", () => this.show("main")); + back.classList.add("bv-back"); + card.appendChild(back); + return card; + } + buildHelp() { + const card = this.card(); + const help = document.createElement("div"); + help.className = "bv-help"; + help.innerHTML = "W/S or / walk
A/D strafe
/ or Q/E turn
Shift hurry
click the view for mouse look
M or Esc open this menu

The maze is infinite and deterministic: the same seed always rebuilds the same rooms."; + card.appendChild(help); + const back = this.button("Back", () => this.show("main")); + back.classList.add("bv-back"); + card.appendChild(back); + return card; + } + }; + + // src/webview/main.ts + var EYE_HEIGHT = 0.78; + var vscode = acquireVsCodeApi(); + var Game = class { + settings = { ...DEFAULT_SETTINGS }; + renderer; + film; + graffiti = new WallWriting(); + input; + menu; + toast; + world; + monster = null; + uniformWallpaper = false; + // Player, in plane coordinates. + px = 0.5; + py = 0.5; + yaw = 0; + pitch = 0; + bobPhase = 0; + lastFrame = 0; + lastPersist = 0; + flickerDipUntil = 0; + toastTimer; + constructor(root) { + const glCanvas = document.createElement("canvas"); + const filmCanvas = document.createElement("canvas"); + for (const [canvas, z] of [[glCanvas, "1"], [filmCanvas, "2"]]) { + canvas.style.cssText = `position:absolute;inset:0;width:100%;height:100%;z-index:${z};`; + } + filmCanvas.style.pointerEvents = "none"; + root.append(glCanvas, filmCanvas); + this.renderer = new Renderer(glCanvas); + this.film = new FilmOverlay(filmCanvas); + this.input = new Input(glCanvas); + this.input.onMenuToggle = () => this.toggleMenu(); + this.menu = new Menu(root, { + onResume: () => this.toggleMenu(), + onRelocate: () => this.relocate(), + onSettingChange: (key, value) => this.changeSetting(key, value), + // A nonzero seed already rebuilds through applySettings; 0 means "roll one now". + onReseed: (seed2) => { + if (seed2 === 0) { + this.rebuildWorld(randomSeed()); + } + } + }); + this.toast = document.createElement("div"); + this.toast.setAttribute("role", "status"); + this.toast.style.cssText = 'position:absolute;left:50%;bottom:9%;transform:translateX(-50%);z-index:20;background:rgba(22,24,27,0.85);color:#f0f2f3;border:1px solid #454c55;border-radius:6px;padding:8px 16px;font:13px "Segoe UI",system-ui,sans-serif;opacity:0;transition:opacity .4s;pointer-events:none;'; + root.appendChild(this.toast); + const restored = vscode.getState(); + const seed = restored?.seed ?? (this.settings.seed !== 0 ? this.settings.seed : randomSeed()); + this.world = new World(seed); + if (restored) { + this.px = restored.px; + this.py = restored.py; + this.yaw = restored.yaw; + this.world.session.warpTo(Math.floor(this.px), Math.floor(this.py)); + } + window.addEventListener("message", (event) => { + if (event.source !== window || !event.data || typeof event.data !== "object") { + return; + } + const message = event.data; + if (message.type === "config") { + this.applySettings(message.settings); + } else if (message.type === "relocate") { + this.relocate(message.seed); + } else if (message.type === "jobStatus") { + this.film.setJob(message.job); + this.graffiti.setJob(message.job); + } else if (message.type === "chatSession") { + this.film.setJob({ + working: message.session.working, + status: "", + tokens: message.session.tokens + }); + this.graffiti.setSession(message.session); + } + }); + vscode.postMessage({ type: "ready" }); + this.showToast(`seed ${seed} - walk with WASD or arrows, M for menu`); + this.syncMonster(); + void this.loadMaterialImages(); + requestAnimationFrame((t) => this.frame(t)); + } + /** + * Loads the photo materials shipped under materials/ and patches them over + * the procedural atlas. Any file that is missing or fails to decode leaves + * its procedural tile in place. + */ + async loadMaterialImages() { + const uris = window.__BACKROOMS_MATERIALS__ ?? {}; + const load = (uri) => new Promise((resolve) => { + if (!uri) { + resolve(void 0); + return; + } + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => resolve(void 0); + image.src = uri; + }); + const [wallpaper, ceiling, carpet] = await Promise.all([ + load(uris.wallpaper), + load(uris.ceiling), + load(uris.carpet) + ]); + if (!wallpaper && !ceiling && !carpet) { + return; + } + this.renderer.applyMaterialImages({ wallpaper, ceiling, carpet }); + if (wallpaper) { + this.world.uniformWallpaper = true; + this.uniformWallpaper = true; + this.world.invalidateChunks(this.renderer); + } + } + toggleMenu() { + if (this.menu.isOpen) { + this.menu.close(); + } else { + this.input.releasePointer(); + this.menu.syncSettings(this.settings); + this.menu.syncStats(this.world.stats()); + this.menu.open(); + } + } + // The host may pick the seed (so its action response stays accurate); + // anything invalid or absent rolls a fresh one here. + relocate(seed) { + const next = typeof seed === "number" && Number.isFinite(seed) && seed > 0 ? Math.floor(seed) : randomSeed(); + this.rebuildWorld(next); + // Adopt the new seed as the effective setting; otherwise the config + // replayed on a reload still carries the old seed and immediately + // rebuilds away from the relocated world. + this.changeSetting("seed", next); + this.menu.close(); + this.showToast(`relocated to seed ${next}`); + } + rebuildWorld(seed) { + this.world.invalidateChunks(this.renderer); + this.graffiti.reset(this.renderer); + this.world = new World(seed); + this.world.furnitureEnabled = this.settings.furniture; + this.world.wallpaperShiftsEnabled = this.settings.wallpaperShifts; + this.world.setMaterial( + this.settings.materialPreset, + this.settings.materialHueShift, + this.settings.materialBrightness + ); + this.world.uniformWallpaper = this.uniformWallpaper; + this.px = 0.5; + this.py = 0.5; + this.syncMonster(true); + this.persist(); + this.menu.syncStats(this.world.stats()); + } + monsterConfig() { + return { + speed: this.settings.monsterSpeed, + spawnMinMs: this.settings.monsterSpawnMin * 6e4, + spawnMaxMs: this.settings.monsterSpawnMax * 6e4, + form: this.settings.monsterForm + }; + } + /** Creates, retunes, or removes the monster to match current settings. */ + syncMonster(rearm = false) { + if (!this.settings.monsterEnabled) { + this.monster = null; + this.renderer.clearDynamicMesh(); + return; + } + if (!this.monster || rearm) { + this.monster = new Monster(this.world, this.monsterConfig(), performance.now()); + } else { + this.monster.configure(this.monsterConfig()); + } + } + changeSetting(key, value) { + this.applySettings({ ...this.settings, [key]: value }); + vscode.postMessage({ type: "updateSetting", key, value }); + } + applySettings(settings) { + const previous = this.settings; + this.settings = settings; + this.film.grainEnabled = settings.filmGrain; + this.film.hudEnabled = settings.vhsHud; + this.film.tokenCounterEnabled = settings.copilotGhostWriter; + if (this.graffiti.enabled && !settings.copilotGhostWriter) { + this.graffiti.reset(this.renderer); + } + this.graffiti.enabled = settings.copilotGhostWriter; + this.input.mouseLookEnabled = settings.mouseLook; + this.renderer.fogDensity = 2.6 / (settings.renderDistance * settings.renderDistance); + if (previous.furniture !== settings.furniture || previous.wallpaperShifts !== settings.wallpaperShifts || previous.materialPreset !== settings.materialPreset || previous.materialHueShift !== settings.materialHueShift || previous.materialBrightness !== settings.materialBrightness) { + this.world.furnitureEnabled = settings.furniture; + this.world.wallpaperShiftsEnabled = settings.wallpaperShifts; + this.world.setMaterial(settings.materialPreset, settings.materialHueShift, settings.materialBrightness); + this.world.invalidateChunks(this.renderer); + } + this.syncMonster(previous.monsterEnabled !== settings.monsterEnabled); + if (settings.seed !== previous.seed && settings.seed !== 0 && settings.seed !== this.world.seed) { + this.rebuildWorld(settings.seed); + } + this.menu.syncSettings(settings); + } + showToast(text) { + this.toast.textContent = text; + this.toast.style.opacity = "1"; + clearTimeout(this.toastTimer); + this.toastTimer = setTimeout(() => { + this.toast.style.opacity = "0"; + }, 4200); + } + persist() { + vscode.setState({ seed: this.world.seed, px: this.px, py: this.py, yaw: this.yaw }); + } + frame(now) { + const dt = Math.min(0.05, (now - this.lastFrame) / 1e3 || 0.016); + this.lastFrame = now; + const t = now / 1e3; + let speed = 0; + if (!this.menu.isOpen) { + speed = this.step(dt); + if (this.monster) { + const event = this.monster.update(now, dt, this.px, this.py); + if (event === "spawned") { + this.showToast("the air changes. something else is in the halls."); + } else if (event === "caught") { + this.film.burst(now); + this.px = 0.5; + this.py = 0.5; + this.world.session.warpTo(0, 0); + this.persist(); + this.showToast("tape resumes somewhere familiar. it is still out there."); + } + } + } + if (this.monster?.isStalking) { + const mesh = this.monster.buildMesh(now, this.px, this.py); + this.renderer.setDynamicMesh(mesh.vertices, mesh.indices); + } else { + this.renderer.clearDynamicMesh(); + } + let shakeYaw = 0; + let shakePitch = 0; + let shakeRoll = 0; + let shakeUp = 0; + if (this.settings.cameraShake) { + const drift = 1 + speed * 1.6; + shakeYaw = (Math.sin(t * 0.9) * 6e-3 + Math.sin(t * 2.3 + 1.7) * 3e-3) * drift; + shakePitch = (Math.sin(t * 1.3 + 0.6) * 4e-3 + Math.sin(t * 3.1) * 2e-3) * drift; + shakeRoll = Math.sin(t * 0.7 + 2.1) * 4e-3 * drift + Math.sin(this.bobPhase) * 6e-3 * speed; + shakeUp = Math.sin(this.bobPhase * 2) * 0.014 * speed; + } + if (Math.random() < 15e-4 && this.flickerDipUntil < now) { + this.flickerDipUntil = now + 60 + Math.random() * 120; + } + let flicker = 1 + Math.sin(t * 11) * 0.012 + Math.sin(t * 47) * 8e-3; + if (this.flickerDipUntil > now) { + flicker *= 0.82; + } + const camera = { + x: this.px, + y: EYE_HEIGHT + shakeUp, + z: this.py, + yaw: this.yaw + shakeYaw, + pitch: this.pitch + shakePitch, + roll: shakeRoll, + fovY: 72 * Math.PI / 180 + }; + this.graffiti.update(now, this.world, this.px, this.py, this.yaw, this.renderer); + const chunks = this.world.updateChunks(this.px, this.py, this.settings.renderDistance, this.renderer); + this.renderer.draw(chunks, camera, flicker); + this.film.render(now); + if (now - this.lastPersist > 1500) { + this.lastPersist = now; + this.persist(); + } + requestAnimationFrame((next) => this.frame(next)); + } + /** Applies input to the player; returns normalized speed for bob effects. */ + step(dt) { + const input = this.input.state(); + const look = this.input.consumeLook(); + const turnSign = this.settings.invertTurn ? -1 : 1; + this.yaw -= (look.dx * 26e-4 + input.turn * 1.9 * dt) * turnSign; + this.pitch = clamp3(this.pitch - look.dy * 22e-4, -1.25, 1.25); + const rate = this.settings.moveSpeed * (input.running ? 1.7 : 1); + const forward = input.forward * (this.settings.invertForward ? -1 : 1); + const strafe = input.strafe * (this.settings.invertStrafe ? -1 : 1); + const fx = -Math.sin(this.yaw); + const fy = -Math.cos(this.yaw); + const rx = Math.cos(this.yaw); + const ry = -Math.sin(this.yaw); + const dx = (fx * forward + rx * strafe) * rate * dt; + const dy = (fy * forward + ry * strafe) * rate * dt; + if (dx === 0 && dy === 0) { + return 0; + } + const moved = this.world.moveResolved(this.px, this.py, dx, dy); + const actual = Math.hypot(moved.x - this.px, moved.y - this.py); + this.px = moved.x; + this.py = moved.y; + this.world.syncSession(this.px, this.py); + this.bobPhase += actual * 5.6; + return Math.min(1, actual / (rate * dt + 1e-6)); + } + }; + function randomSeed() { + return Math.floor(Math.random() * 999999) + 1; + } + function clamp3(v, lo, hi) { + return Math.min(hi, Math.max(lo, v)); + } + var app = document.getElementById("app"); + if (app) { + new Game(app); + } +})(); diff --git a/extensions/backrooms-canvas/materials/carpet.jpg b/extensions/backrooms-canvas/materials/carpet.jpg new file mode 100644 index 000000000..0a631716d Binary files /dev/null and b/extensions/backrooms-canvas/materials/carpet.jpg differ diff --git a/extensions/backrooms-canvas/materials/ceiling.jpg b/extensions/backrooms-canvas/materials/ceiling.jpg new file mode 100644 index 000000000..8d314ec3b Binary files /dev/null and b/extensions/backrooms-canvas/materials/ceiling.jpg differ diff --git a/extensions/backrooms-canvas/materials/wallpaper.jpg b/extensions/backrooms-canvas/materials/wallpaper.jpg new file mode 100644 index 000000000..9b0fd07d9 Binary files /dev/null and b/extensions/backrooms-canvas/materials/wallpaper.jpg differ diff --git a/extensions/backrooms-canvas/package-lock.json b/extensions/backrooms-canvas/package-lock.json new file mode 100644 index 000000000..f7bfc5854 --- /dev/null +++ b/extensions/backrooms-canvas/package-lock.json @@ -0,0 +1,446 @@ +{ + "name": "backrooms-canvas", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "backrooms-canvas", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@github/copilot-sdk": "latest" + } + }, + "node_modules/@github/copilot": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.71.tgz", + "integrity": "sha512-F3axBi+sXSLYDJbxCBW36bM6MYKNC2rlyAf3Ivo/MjiHKKJW7j5AmaR1IRYS9Gt8r9mxOwWFM1cJFA+CuLaR8g==", + "dependencies": { + "detect-libc": "^2.1.2" + }, + "bin": { + "copilot": "npm-loader.js" + }, + "optionalDependencies": { + "@github/copilot-darwin-arm64": "1.0.71", + "@github/copilot-darwin-x64": "1.0.71", + "@github/copilot-linux-arm64": "1.0.71", + "@github/copilot-linux-x64": "1.0.71", + "@github/copilot-linuxmusl-arm64": "1.0.71", + "@github/copilot-linuxmusl-x64": "1.0.71", + "@github/copilot-win32-arm64": "1.0.71", + "@github/copilot-win32-x64": "1.0.71" + } + }, + "node_modules/@github/copilot-darwin-arm64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.71.tgz", + "integrity": "sha512-mEWzyqbqRAWgyU7i2uuSRoVPx/TwaFQX0nZmw0bc30aJ0BnO7cy2kYQyCHw8ykmf/tfxT0xauZ6k0BOFmWizzQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-arm64": "copilot" + } + }, + "node_modules/@github/copilot-darwin-x64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.71.tgz", + "integrity": "sha512-Md9yEg406OBVBx3w4PeEj62TubulVLBcHleqmCoOoUmPgUxPZotUbrqz3rtbzADbXfrrD7JWvVsbd2UiNL194w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-x64": "copilot" + } + }, + "node_modules/@github/copilot-linux-arm64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.71.tgz", + "integrity": "sha512-ykLJYOqBj3jRB5IJCDugLClAqbr7DmtTbUjlNY7+Jdq/n6i+d7xUQGclf1IWL5gnxbGQVAf+zkToD+sRM389Kg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-arm64": "copilot" + } + }, + "node_modules/@github/copilot-linux-x64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.71.tgz", + "integrity": "sha512-pC0FNHG+BBwZd6yZlM85kkAGN+uJhM6o+THi76N2GnnSxmw7+remb1mvYxdgRVbdCm+LBUIbCKRWJLuMwrfb6A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-x64": "copilot" + } + }, + "node_modules/@github/copilot-linuxmusl-arm64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.71.tgz", + "integrity": "sha512-hBmDljFTjacxqZTasCEy43H8EIzuXB/hHEBBCMFjhB9J00nIxsO6Dh0woTifKpx7knTYZdpTjjca3D0pAoZlUA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linuxmusl-arm64": "copilot" + } + }, + "node_modules/@github/copilot-linuxmusl-x64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.71.tgz", + "integrity": "sha512-CfTXU8pa5dxRz22xQzoi3TiG1PJo9+WR8PRDiPSdkIBSyPJ1NvX87DJmfXjTgeAfR+wkjt/p0keDCaBBVhNmUA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linuxmusl-x64": "copilot" + } + }, + "node_modules/@github/copilot-sdk": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.7.tgz", + "integrity": "sha512-dgCFCPfxWUkrgclQbrm7WCFzTf5RnJHsK1Lqsc3KjPBbDLPutJT0qIGg3xJ0ZELLyX0icg3TOmVczhR4HdwHxw==", + "dependencies": { + "@github/copilot": "^1.0.71", + "koffi": "^3.1.0", + "vscode-jsonrpc": "^8.2.1", + "zod": "^4.3.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@github/copilot-win32-arm64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.71.tgz", + "integrity": "sha512-+HI1DokixXhHUahj06Fw67ZAigBuXKC58BFma4UJOGrQsDgwOSbqeTQHCw6vuymzjKlg3sactfsCUTaefkjscQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-arm64": "copilot.exe" + } + }, + "node_modules/@github/copilot-win32-x64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.71.tgz", + "integrity": "sha512-02kXOBd9CwBbCaztuf71WYWn+uGapCuiaasomN4tcMH3HBVZ4gi3J0ZUoRcgcS80xh81uQyeBHbnUKzb/RE/9A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-x64": "copilot.exe" + } + }, + "node_modules/@koromix/koffi-darwin-arm64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.1.1.tgz", + "integrity": "sha512-+Dl0zQDh1Wb55AWOn9hp7K30qgkODvrvN+ZNkFOh81Q0oFX/rpJQtocgjAuYk2zFAcajSeVDumkcHMPwnKSXzA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-x64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.1.1.tgz", + "integrity": "sha512-cDFAKn1qdZBFLrp7dAc9QUDw3l4xAhTJbOdPWWb0LxssVicUdHcRCLZGrDsmPW2tpH6LGNNeLgqRpAoD2Mo8iA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-arm64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.1.1.tgz", + "integrity": "sha512-zaP7FJISI/scQW9Wa5QicY3a09WmtKBWSbmC+5nfCqPzwWe7Hx2so74Er7mPsDfCiMMR0Ya+evKbJQDkfyXicg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-ia32": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.1.1.tgz", + "integrity": "sha512-7GejVb688TLM8rbjfc0oezJrATxZc0dn801xWEDJekN2DgmRXu7HquGqWQ6z3NeSq7ZxEggz4T3xtlbCysQapA==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-x64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.1.1.tgz", + "integrity": "sha512-XLiCFP9OFCyOoGTjAimtDKLhzhfo34WcP1ShVWxRzNCWDGjfz8BYjwd69cp/cDSUXZbxamqs4+/6vmkePq9wxA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.1.1.tgz", + "integrity": "sha512-HA9xINK7G4dRAkpfnBWD9VfuyIBgW1SuK+KPHjksUwRMOnhgqP8J/JqgrAzdzcDiefGBkqEacIP776OUwz7knQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-ia32": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.1.1.tgz", + "integrity": "sha512-jG7IFytmP8K5Qtbx0ro0ZeuX3JjSsLxmYhq+nmXDdrtOAlxIsWGynuiDLS6Jk3vOchVii2m6Y2f/L3GLG2fG5A==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-loong64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.1.1.tgz", + "integrity": "sha512-CIsT1cNnih8FuU52Me/IVlJBpH28SQfoDeYPctJswgJzaARktusF7m4MUbtR1PBDjuquCVM4/vFyNdOzfPonvA==", + "cpu": [ + "loong64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-riscv64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.1.1.tgz", + "integrity": "sha512-9D6RmqeKsSvs3U6jILJU9PcAjMwKKyn7yLxNBb5k6z9PCoUoGJ3/BrhXAX0qjrLLwEiIpP/hS/40RuXvH8Lc3Q==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-x64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.1.1.tgz", + "integrity": "sha512-pyTcX5fePeYbt7TZAwRby69wdlRx3PT+g15ra5IYdat/Pgh3qAKEYeZ+uu7WpPGOy43p/oSRqqZoa2kORzozlA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-ia32": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.1.1.tgz", + "integrity": "sha512-iPnPzvG2HOfdzaiG1drdkt86sAqmTPDv9mAf+5gL7mRzkeeQC88EVGboRy7eXwdXn7R+v0ntA3iQxdHrBn6yXw==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-x64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.1.1.tgz", + "integrity": "sha512-/Xqc3R0SVoMCYjMPZnJ9bULtRo364+dKmnQhfDrI83tSpxUHRw7HRNf12vBeL+hPgKxSBjtMpWfQ/ZIyVyLFag==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-arm64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.1.1.tgz", + "integrity": "sha512-JhqHauEwQvdcWUERxrV5HH/DT9W7hY1A1eU6/o8tB+yck+D3kt5elpRDBt9KjpW6h+vHPy3V0sjDvO0CXyabTA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-ia32": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.1.1.tgz", + "integrity": "sha512-ZRuyYmlGS/rCc966qqs0qREXDW4FRdul7rDF1VgSWHbVmdc196PUgUT+blq/GjZgTwqzeEXtMRgM+cU8krHjvA==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-x64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.1.1.tgz", + "integrity": "sha512-KqHPmvj6QILhNyI/To8QSihHsijeVGIYYPBOUnXEpcnH2LuLbargY4Hd6dDeTN3Z90uUUxN+1FWz1UnhVzFOiA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/koffi": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.1.1.tgz", + "integrity": "sha512-mRX6AMeeKCxSOeOopqAcLAl5jcNvge7NAG8l7rF/8gGJATI0tdHFYjteIdE0mGOtWdsrJOij+PjnP8Q9c1gwgA==", + "hasInstallScript": true, + "funding": { + "url": "https://liberapay.com/Koromix" + }, + "optionalDependencies": { + "@koromix/koffi-darwin-arm64": "3.1.1", + "@koromix/koffi-darwin-x64": "3.1.1", + "@koromix/koffi-freebsd-arm64": "3.1.1", + "@koromix/koffi-freebsd-ia32": "3.1.1", + "@koromix/koffi-freebsd-x64": "3.1.1", + "@koromix/koffi-linux-arm64": "3.1.1", + "@koromix/koffi-linux-ia32": "3.1.1", + "@koromix/koffi-linux-loong64": "3.1.1", + "@koromix/koffi-linux-riscv64": "3.1.1", + "@koromix/koffi-linux-x64": "3.1.1", + "@koromix/koffi-openbsd-ia32": "3.1.1", + "@koromix/koffi-openbsd-x64": "3.1.1", + "@koromix/koffi-win32-arm64": "3.1.1", + "@koromix/koffi-win32-ia32": "3.1.1", + "@koromix/koffi-win32-x64": "3.1.1" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/extensions/backrooms-canvas/package.json b/extensions/backrooms-canvas/package.json new file mode 100644 index 000000000..abb1d48df --- /dev/null +++ b/extensions/backrooms-canvas/package.json @@ -0,0 +1,20 @@ +{ + "name": "backrooms-canvas", + "version": "1.0.0", + "main": "extension.mjs", + "author": "John Haugabook", + "license": "MIT", + "type": "module", + "dependencies": { + "@github/copilot-sdk": "latest" + }, + "description": "Wander an endless first-person backrooms in a Copilot canvas while agents work; their status ghost-writes on the walls.", + "keywords": [ + "backrooms", + "copilot-canvas", + "interactive-canvas", + "first-person", + "procedural-generation", + "session-breaks" + ] +}