mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-01 23:12:29 +00:00
ae8ada62bd
* new extension backrooms-canvas * new extension backrooms-canvas * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * update according to code review update according to code review * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * apply code review, change backviews to backrooms * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
4014 lines
140 KiB
JavaScript
4014 lines
140 KiB
JavaScript
"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 <b>${stats.seed}</b><br>cells visited <b>${stats.cellsVisited}</b><br>cells cached <b>${stats.cacheSize}</b>`;
|
|
}
|
|
}
|
|
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 = '<h1>BACKROOMS</h1><p class="bv-sub">noclipped into a Copilot canvas</p>';
|
|
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 = "<kbd>W</kbd>/<kbd>S</kbd> or <kbd>↑</kbd>/<kbd>↓</kbd> walk<br><kbd>A</kbd>/<kbd>D</kbd> strafe<br><kbd>←</kbd>/<kbd>→</kbd> or <kbd>Q</kbd>/<kbd>E</kbd> turn<br><kbd>Shift</kbd> hurry<br>click the view for mouse look<br><kbd>M</kbd> or <kbd>Esc</kbd> open this menu<br><br>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);
|
|
}
|
|
})();
|