Prompt
1) Goal & Scope Create a single‑file HTML/JS canvas app that renders a procedurally generated dungeon and a player carrying a torch. The world is revealed only where the torch has line‑of‑sight (LOS); previously seen tiles retain a dim, decaying memory glow. Closed doors block both movement and light; doors auto‑open when the player is adjacent or attempts to step into them. 2) World Model Grid of tiles W×H. Each cell has one of: VOID, WALL, ROOM, CORRIDOR, DOOR. Doors store: Position (x,y), orientation {H|V}, state {open|closed}. Rooms: axis‑aligned rectangles with center (cx,cy). Deterministic RNG via seed string (e.g., cyrb128 → mulberry32 or equivalent). 3) Generation Requirements Rooms Place targetRooms non‑overlapping rooms with padding. Room sizes in [rmin, rmax]. Walls Any VOID orthogonally adjacent to a ROOM becomes WALL. Connectivity Graph Build nodes at room centers. Connect via MST + sparse extra edges (≈10–15% of non‑MST edges) for cycles. Doors (one per room side) For each graph edge (room A→B): choose a door on the side of each room facing the other. At most one door per side per room; reuse an existing side door if needed. Place door in the wall ring; record orientation. Corridors (merged, 1‑tile wide) Carve path between the outside cell of A’s door and B’s door using a weighted BFS/Lee or grid A* that: Prefers stepping onto existing CORRIDOR cells (merges parallel attempts). Allows carving through VOID and non‑room WALL (but not room‑adjacent walls if that would breach rooms). Mark carved cells as CORRIDOR. Corridor Side Walls Any VOID orthogonally adjacent to a CORRIDOR becomes WALL. Connectivity Check Verify every room is reachable from any room through ROOM/CORRIDOR/DOOR cells (ignoring door states for generation). 4) Rendering Pre‑render a base color layer (in sRGB) onto an offscreen canvas: Ground, room floors, corridor floors, walls. Doors are drawn later (to reflect open/closed). Procedural tileset from seed: Palette (HSL → sRGB), mild noise/grunge for stone, distinct room/corridor hues. Sprites: doorH/doorV, doorOpenH/doorOpenV, player, torch. The main view draws a camera crop centered on the player, then scales by zoom. 5) Lighting (LOS torch — physically accurate falloff) Ambient = 0.0 (unexplored is black). Linear‑space pipeline: keep all lighting math in linear color; convert sRGB↔linear only at image I/O edges. Ray‑marched LOS on a sub‑cell grid with higher resolution: Sub‑cell scale S ∈ {4..6} (default 4 or 5, higher costs more). Cast ≈1200–2000 rays uniformly over [0, 2π) (default 1600). March step ≈ 0.2–0.3 tile (default 0.25); stop at world bounds or light radius. Walls stop light. Door rule: Closed door: blocks light like a wall. Open door: light passes (no special attenuation). Corner‑occluders (no diagonal leaks): if a ray transitions into a diagonal neighbor (Δx≠0 and Δy≠0) and the two orthogonally adjacent tiles forming that corner are both blocking, terminate the ray (prevents light “threading the needle” between diagonally adjacent blockers). Falloff (purely physical): point‑light inverse‑square from the torch origin, no constant region: Intensity vs. distance in tiles d: L(d) = 1 / (d^2 + ε^2) where ε is a tiny constant (e.g., ε = 0.1 tile) to avoid a singularity at d=0. (This does not introduce a perceptible flat zone.) Optionally allow a shaping exponent p (default 1.0) → L(d) = (1 / (d^2 + ε^2))^p for “soft/hard” presets without violating physical behavior. Light radius & buffer: radius ≥ halfViewport + halo; the light buffer must cover (camera + halo) to avoid edge sampling gaps. Composition per pixel (linear space): finalRGB = baseLinear * ( exposure * torchRGB_linear * L(d_sample) + memIntensity * memory[tile] ) Convert finalRGB → sRGB for display. 6) Fog of War (memory) memory is a Float32Array per tile in [0..1]. On each lighting solve, mark tiles seen by rays as 1.0. Exponential decay every frame: mem *= 0.5^(dt / halfLife). Defaults: memIntensity ≈ 0.08, halfLife ≈ 20s. Doors & sprites are drawn only if (tile is lit) OR (memory > epsilon). 7) Player, Doors, Movement Spawn on a safe passable tile (prefer corridors of degree ≥2; fallback to room center; else first passable). Movement: W/A/S/D or arrow keys; 4‑dir, tile‑step. Passability: ROOM, CORRIDOR → passable. DOOR → passable only if open. WALL/VOID → blocked. Auto‑open rules: If the player attempts to step into a closed door: open it, then move. Also open any adjacent closed doors after movement (QoL). On spawn and each move: recompute lighting; update memory; redraw. 8) Camera & UI Camera shows a configurable window of tiles, clamped to world; zoom 1–3×. Controls: Seed (randomize), Map W×H, Rooms count, Room size range. View tiles vw×vh, Zoom, Exposure. Lighting quality: presets that raise resolution (e.g., Low: S=3, rays≈900; Med: S=4, rays≈1300; High: S=5, rays≈1800). Falloff preset (optional): adjusts exponent p only; base model remains inverse‑square from the torch. N: regenerate (keep or change seed), R: safe respawn. Export current view as PNG. 9) Determinism & Performance Deterministic per seed for generation, palette, tileset. Lighting recomputed only when state changes (movement/door opens); memory decay applies each frame. Use typed arrays and preallocated buffers. Avoid per‑frame allocations in hot loops; reuse offscreen canvases and buffers. 10) Acceptance Criteria (functional) Every room is connected via corridors; corridors are 1‑tile wide (no double‑wide runs). ≤ 1 door per side of any room; corridor/room connections have exactly one door. Closed doors block both movement and light; open doors allow both. Unexplored area renders black; explored tiles show dim memory that decays over time. Torch lighting is purely physical: intensity follows inverse‑square from the torch (no constant‑intensity inner region). Apparent reach depends on exposure/quality settings. No diagonal light leaks: light cannot pass between two diagonally adjacent blocking tiles (corner‑occluder rule). Higher‑resolution light calculation is used by default: at least S≥4, rays≥1200, and march step ≤ 0.3 tile. Player always spawns visible; camera centers on the player and clamps at edges. 11) Suggested Interfaces (pseudocode) generateDungeon({W,H,targetRooms,rmin,rmax,seed}) => { grid, rooms[], doors[], doorOpen[y][x], doorOrient[y][x], W, H, connected:boolean } buildTileset(seed, tile) => { images... } renderBaseToCanvas(dungeon, tiles, tile) => baseCanvas, baseLinearRGB // Physically accurate torch; high-res subcell grid + corner occluders solveTorch(dungeon, playerXY, { tile, // tile size in pixels S, // subcell scale (recommend 4..6) rays, // 1200..2000 step, // ≈0.25 tile radiusTiles, // >= halfViewport + halo p=1.0, // optional shaping exponent (1.0 = pure inverse-square) eps=0.1 // small epsilon to avoid singularity at d=0 }) => { samplePix(px,py) /* returns L in [0..1] at subcell */ }, seenMask(W*H) compose(viewCanvas, baseCanvas, baseLinear, sampler, exposure, region{px,py,w,h}, memory, dungeon, tile, memIntensity, tiles, zoom) openDoorAt(x,y); openAdjacentDoors(playerXY) Lighting kernel details (normative): For a ray sample at world position (x,y), with torch origin (ox,oy) and distance d = hypot(x-ox, y-oy): L(d) = (1 / (d*d + eps*eps))^p When stepping from (tx_prev, ty_prev) to (tx, ty) where both differ (diagonal), if isBlocking(tx, ty_prev) && isBlocking(tx_prev, ty) then terminate the ray before writing to the buffer.
A system prompt was added to support web rendering
```html <!DOCTYPE html> <html> <head> <title>Procedural Dungeon with Torch Lighting</title> <style> body { margin: 0; overflow: hidden; background: #000; color: #ccc; font-family: monospace; } #container { display: flex; flex-direction: column; align-items: center; padding: 10px; } canvas { image-rendering: pixelated; border: 1px solid #333; } .controls { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 10px; max-width: 800px; } label { display: flex; flex-direction: column; font-size: 12px; } input, button, select { padding: 2px 4px; font-family: monospace; } </style> </head> <body> <div id="container"> <div class="controls"> <label>Seed<input type="text" id="seed" value="dungeon"></label> <label>W<input type="number" id="mapW" value="64" min="32" max="256"></label> <label>H<input type="number" id="mapH" value="64" min="32" max="256"></label> <label>Rooms<input type="number" id="targetRooms" value="20" min="5" max="100"></label> <label>R.Min<input type="number" id="rmin" value="4" min="2" max="10"></label> <label>R.Max<input type="number" id="rmax" value="8" min="4" max="16"></label> <label>View W<input type="number" id="vw" value="21" min="9" max="63" step="2"></label> <label>View H<input type="number" id="vh" value="21" min="9" max="63" step="2"></label> <label>Zoom<select id="zoom"><option>1</option><option selected>2</option><option>3</option></select></label> <label>Exposure<input type="range" id="exposure" min="0.1" max="5" step="0.1" value="1.5"></label> <label>Light<select id="lightQuality"><option>Low</option><option selected>Medium</option><option>High</option></select></label> <button id="regen">N: Regenerate</button> <button id="respawn">R: Respawn</button> <button id="export">Export PNG</button> </div> <canvas id="mainCanvas"></canvas> </div> <script> // Utility functions function cyrb128(str) { let h1 = 1779033703, h2 = 3144134277, h3 = 1013904242, h4 = 2773480762; for (let i = 0, k; i < str.length; i++) { k = str.charCodeAt(i); k = Math.imul(k, 0xcc9e2d51); k = (k << 15) | (k >>> 17); k = Math.imul(k, 0x1b873593); h1 ^= k; h1 = (h1 << 13) | (h1 >>> 19); h1 = Math.imul(h1, 5) + 0xe6546b64; [h1, h2, h3, h4] = [h2, h3, h4, h1]; } return [h1 >>> 0, h2 >>> 0, h3 >>> 0, h4 >>> 0]; } function mulberry32(a) { return function() { a |= 0; a = a + 0x6D2B79F5 | 0; var t = Math.imul(a ^ a >>> 15, 1 | a); t = (t + Math.imul(t ^ t >>> 7, 61 | t)) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; } } function srand(seed) { const [a, b, c, d] = cyrb128(seed); return mulberry32(a ^ b ^ c ^ d); } function srgbToLinear(c) { return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); } function linearToSrgb(c) { return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; } // Constants const VOID = 0, WALL = 1, ROOM = 2, CORRIDOR = 3, DOOR = 4; const TILE_SIZE = 16; const EPS = 0.1; const P_EXPO = 1.0; const MEM_INTENSITY = 0.08; const MEM_HALFLIFE = 20000; // ms // Global state let W, H, seed, rng; let grid, rooms, doors, doorOpen, doorOrient; let baseCanvas, baseCtx, baseLinearData; let memory, lastLightUpdate = 0; let player = {x: 0, y: 0}; let lightBuffer, lightCtx; let offscreenCanvas, offscreenCtx; // DOM Elements const $ = (id) => document.getElementById(id); const mainCanvas = $('mainCanvas'); const mainCtx = mainCanvas.getContext('2d'); // Initialize function init() { // Set up canvases lightBuffer = document.createElement('canvas'); lightCtx = lightBuffer.getContext('2d'); offscreenCanvas = document.createElement('canvas'); offscreenCtx = offscreenCanvas.getContext('2d'); // Event listeners $('regen').onclick = () => generate(); $('respawn').onclick = () => respawn(); $('export').onclick = () => exportImage(); window.onkeydown = handleKeyDown; generate(); } function generate() { // Read parameters seed = $('seed').value; W = parseInt($('mapW').value); H = parseInt($('mapH').value); const targetRooms = parseInt($('targetRooms').value); const rmin = parseInt($('rmin').value); const rmax = parseInt($('rmax').value); // Initialize RNG rng = srand(seed); // Generate dungeon const dungeon = generateDungeon({W, H, targetRooms, rmin, rmax}); grid = dungeon.grid; rooms = dungeon.rooms; doors = dungeon.doors; doorOpen = dungeon.doorOpen; doorOrient = dungeon.doorOrient; // Pre-render base renderBase(); // Initialize memory memory = new Float32Array(W * H); // Place player respawn(); } function respawn() { // Find a safe spawn point let candidates = []; for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { if (grid[y][x] === CORRIDOR) { let neighbors = 0; for (let dx = -1; dx <= 1; dx++) { for (let dy = -1; dy <= 1; dy++) { if (Math.abs(dx) + Math.abs(dy) === 1) { const nx = x + dx, ny = y + dy; if (nx >= 0 && nx < W && ny >= 0 && ny < H && (grid[ny][nx] === CORRIDOR || grid[ny][nx] === ROOM)) { neighbors++; } } } } if (neighbors >= 2) candidates.push({x, y}); } } } if (candidates.length === 0) { // Fallback to room centers for (const r of rooms) { candidates.push({x: r.cx, y: r.cy}); } } if (candidates.length === 0) { // Final fallback for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { if (grid[y][x] === CORRIDOR || grid[y][x] === ROOM) { candidates.push({x, y}); } } } } if (candidates.length > 0) { const idx = Math.floor(rng() * candidates.length); player = candidates[idx]; } else { player = {x: Math.floor(W/2), y: Math.floor(H/2)}; } // Reset memory memory.fill(0); lastLightUpdate = performance.now(); // Render render(); } function generateDungeon({W, H, targetRooms, rmin, rmax}) { // Initialize grid const grid = Array(H).fill().map(() => Array(W).fill(VOID)); const rooms = []; const doors = []; const doorOpen = Array(H).fill().map(() => Array(W).fill(false)); const doorOrient = Array(H).fill().map(() => Array(W).fill(null)); // Place rooms let attempts = 0; const padding = 2; while (rooms.length < targetRooms && attempts < targetRooms * 10) { const w = rmin + Math.floor(rng() * (rmax - rmin + 1)); const h = rmin + Math.floor(rng() * (rmax - rmin + 1)); const x = Math.floor(rng() * (W - w - padding * 2)) + padding; const y = Math.floor(rng() * (H - h - padding * 2)) + padding; // Check for overlap with existing rooms let overlap = false; for (const r of rooms) { if (!(x + w + padding < r.x || x > r.x + r.w + padding || y + h + padding < r.y || y > r.y + r.h + padding)) { overlap = true; break; } } if (!overlap) { rooms.push({x, y, w, h, cx: x + Math.floor(w/2), cy: y + Math.floor(h/2)}); for (let ry = y; ry < y + h; ry++) { for (let rx = x; rx < x + w; rx++) { if (ry >= 0 && ry < H && rx >= 0 && rx < W) { grid[ry][rx] = ROOM; } } } } attempts++; } // Create walls around rooms for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { if (grid[y][x] === ROOM) { for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { const nx = x + dx, ny = y + dy; if (nx >= 0 && nx < W && ny >= 0 && ny < H && grid[ny][nx] === VOID) { grid[ny][nx] = WALL; } } } } } } // Create connectivity graph if (rooms.length < 2) { return {grid, rooms, doors, doorOpen, doorOrient, connected: rooms.length <= 1}; } // Build MST with extra edges const edges = []; for (let i = 0; i < rooms.length; i++) { for (let j = i + 1; j < rooms.length; j++) { const dx = rooms[j].cx - rooms[i].cx; const dy = rooms[j].cy - rooms[i].cy; const dist = Math.sqrt(dx*dx + dy*dy); edges.push({i, j, dist, dx, dy}); } } edges.sort((a, b) => a.dist - b.dist); // Kruskal's algorithm for MST const parent = Array(rooms.length).fill(0).map((_, i) => i); function find(u) { if (parent[u] !== u) parent[u] = find(parent[u]); return parent[u]; } function union(u, v) { const ru = find(u), rv = find(v); if (ru !== rv) parent[rv] = ru; } const mstEdges = []; for (const e of edges) { if (find(e.i) !== find(e.j)) { union(e.i, e.j); mstEdges.push(e); } } // Add some extra edges for cycles const extraCount = Math.floor(edges.length * 0.15); const extraEdges = []; for (const e of edges) { if (!mstEdges.includes(e) && extraEdges.length < extraCount) { extraEdges.push(e); } } const allEdges = [...mstEdges, ...extraEdges]; // Create doors and carve corridors const corridors = []; // To track corridor paths for (const e of allEdges) { const roomA = rooms[e.i]; const roomB = rooms[e.j]; // Determine door sides const angle = Math.atan2(e.dy, e.dx); let sideA, sideB; if (Math.abs(angle) < Math.PI/4) { sideA = 3; // right sideB = 1; // left } else if (Math.abs(angle - Math.PI) < Math.PI/4 || Math.abs(angle + Math.PI) < Math.PI/4) { sideA = 1; // left sideB = 3; // right } else if (angle > 0) { sideA = 0; // top sideB = 2; // bottom } else { sideA = 2; // bottom sideB = 0; // top } // Place doors const doorA = placeDoor(roomA, sideA); const doorB = placeDoor(roomB, sideB); if (doorA && doorB) { doorOpen[doorA.y][doorA.x] = false; doorOrient[doorA.y][doorA.x] = doorA.orient; doorOpen[doorB.y][doorB.x] = false; doorOrient[doorB.y][doorB.x] = doorB.orient; doors.push(doorA, doorB); // Carve corridor between doors carveCorridor(grid, doorA.cx, doorA.cy, doorB.cx, doorB.cy, corridors); } } // Create walls around corridors for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { if (grid[y][x] === CORRIDOR) { for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { const nx = x + dx, ny = y + dy; if (nx >= 0 && nx < W && ny >= 0 && ny < H && grid[ny][nx] === VOID) { grid[ny][nx] = WALL; } } } } } } return {grid, rooms, doors, doorOpen, doorOrient, connected: true}; // Helper functions function placeDoor(room, side) { let x, y, orient, cx, cy; switch(side) { case 0: // top y = room.y - 1; x = room.x + Math.floor(rng() * room.w); orient = 'H'; cx = x + 0.5; cy = y + 0.5; break; case 1: // left x = room.x - 1; y = room.y + Math.floor(rng() * room.h); orient = 'V'; cx = x + 0.5; cy = y + 0.5; break; case 2: // bottom y = room.y + room.h; x = room.x + Math.floor(rng() * room.w); orient = 'H'; cx = x + 0.5; cy = y + 0.5; break; case 3: // right x = room.x + room.w; y = room.y + Math.floor(rng() * room.h); orient = 'V'; cx = x + 0.5; cy = y + 0.5; break; } if (x >= 0 && x < W && y >= 0 && y < H) { return {x, y, orient, cx, cy}; } return null; } function carveCorridor(grid, x1, y1, x2, y2, corridors) { // Simple straight line corridor const points = []; let x = Math.floor(x1), y = Math.floor(y1); points.push({x, y}); while (x !== Math.floor(x2) || y !== Math.floor(y2)) { const dx = Math.floor(x2) - x; const dy = Math.floor(y2) - y; if (Math.abs(dx) > Math.abs(dy)) { x += dx > 0 ? 1 : -1; } else { y += dy > 0 ? 1 : -1; } if (x >= 0 && x < W && y >= 0 && y < H) { if (grid[y][x] === VOID || grid[y][x] === WALL) { grid[y][x] = CORRIDOR; } points.push({x, y}); } } corridors.push(points); } } function renderBase() { // Create offscreen canvas for base rendering baseCanvas = document.createElement('canvas'); baseCanvas.width = W * TILE_SIZE; baseCanvas.height = H * TILE_SIZE; baseCtx = baseCanvas.getContext('2d'); // Create linear data array baseLinearData = new Uint8ClampedArray(W * H * 4); // Generate palette const hue = rng() * 360; const roomHue = (hue + 60) % 360; const corrHue = (hue + 180) % 360; // Render tiles for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { let r, g, b; switch (grid[y][x]) { case ROOM: [r, g, b] = hslToRgb(roomHue, 30, 20 + rng() * 10); break; case CORRIDOR: [r, g, b] = hslToRgb(corrHue, 20, 15 + rng() * 10); break; case WALL: [r, g, b] = hslToRgb(hue, 20, 10 + rng() * 10); break; default: r = g = b = 0; } const idx = (y * W + x) * 4; baseLinearData[idx] = Math.floor(srgbToLinear(r/255) * 255); baseLinearData[idx+1] = Math.floor(srgbToLinear(g/255) * 255); baseLinearData[idx+2] = Math.floor(srgbToLinear(b/255) * 255); baseLinearData[idx+3] = 255; } } // Put image data const imageData = new ImageData(baseLinearData, W, H); const tempCanvas = document.createElement('canvas'); tempCanvas.width = W; tempCanvas.height = H; const tempCtx = tempCanvas.getContext('2d'); tempCtx.putImageData(imageData, 0, 0); baseCtx.imageSmoothingEnabled = false; baseCtx.drawImage(tempCanvas, 0, 0, W, H, 0, 0, W * TILE_SIZE, H * TILE_SIZE); } function hslToRgb(h, s, l) { s /= 100; l /= 100; const k = n => (n + h/30) % 12; const a = s * Math.min(l, 1 - l); const f = n => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); return [f(0) * 255, f(8) * 255, f(4) * 255]; } function solveTorch(playerX, playerY, quality) { // Quality settings let S, rays, step, radiusTiles; switch(quality) { case 'Low': S = 3; rays = 900; step = 0.3; radiusTiles = 10; break; case 'Medium': S = 4; rays = 1300; step = 0.25; radiusTiles = 12; break; case 'High': S = 5; rays = 1800; step = 0.2; radiusTiles = 15; break; default: S = 4; rays = 1300; step = 0.25; radiusTiles = 12; } // Set up light buffer const bufferSize = Math.ceil(radiusTiles * 2 + 4) * TILE_SIZE * S; lightBuffer.width = bufferSize; lightBuffer.height = bufferSize; lightCtx.fillStyle = 'black'; lightCtx.fillRect(0, 0, bufferSize, bufferSize); // Torch position in subcell space const ox = (playerX + 0.5) * TILE_SIZE * S; const oy = (playerY + 0.5) * TILE_SIZE * S; // Cast rays for (let i = 0; i < rays; i++) { const angle = (i / rays) * 2 * Math.PI; const dx = Math.cos(angle); const dy = Math.sin(angle); let x = ox, y = oy; const maxSteps = Math.ceil(radiusTiles / step); for (let j = 0; j < maxSteps; j++) { x += dx * step * TILE_SIZE; y += dy * step * TILE_SIZE; // Convert to tile coordinates const tileX = Math.floor(x / (TILE_SIZE * S)); const tileY = Math.floor(y / (TILE_SIZE * S)); // Check bounds if (tileX < 0 || tileX >= W || tileY < 0 || tileY >= H) break; // Calculate distance const dist = Math.sqrt(((x - ox) / (TILE_SIZE * S))**2 + ((y - oy) / (TILE_SIZE * S))**2); if (dist > radiusTiles) break; // Corner occluder rule if (j > 0) { const prevTileX = Math.floor((x - dx * step * TILE_SIZE) / (TILE_SIZE * S)); const prevTileY = Math.floor((y - dy * step * TILE_SIZE) / (TILE_SIZE * S)); if (tileX !== prevTileX && tileY !== prevTileY) { const corner1Blocking = isBlocking(prevTileX, tileY); const corner2Blocking = isBlocking(tileX, prevTileY); if (corner1Blocking && corner2Blocking) break; } } // Check if blocked if (isBlocking(tileX, tileY)) { // If it's a door, only block if closed if (grid[tileY][tileX] === DOOR && doorOpen[tileY][tileX]) { // Light passes through open door } else { break; // Blocked by wall or closed door } } // Draw light point const intensity = Math.pow(1 / (dist*dist + EPS*EPS), P_EXPO); const alpha = Math.min(intensity, 1); lightCtx.fillStyle = `rgba(255, 200, 100, ${alpha})`; lightCtx.fillRect( x - S/2 + bufferSize/2 - playerX * TILE_SIZE * S, y - S/2 + bufferSize/2 - playerY * TILE_SIZE * S, S, S ); } } // Create seen mask const seenMask = new Uint8Array(W * H); for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { const dx = x - playerX; const dy = y - playerY; const dist = Math.sqrt(dx*dx + dy*dy); if (dist <= radiusTiles) { // Simple approximation if (Math.abs(dx) <= radiusTiles && Math.abs(dy) <= radiusTiles) { seenMask[y * W + x] = 1; } } } } return { buffer: lightBuffer, offsetX: bufferSize/2 - playerX * TILE_SIZE * S, offsetY: bufferSize/2 - playerY * TILE_SIZE * S, seenMask }; function isBlocking(x, y) { if (x < 0 || x >= W || y < 0 || y >= H) return true; const tile = grid[y][x]; return tile === WALL || tile === VOID || (tile === DOOR && !doorOpen[y][x]); } } function compose(mainCtx, baseCanvas, lightResult, exposure, viewRect, memory) { const {x: vx, y: vy, w: vw, h: vh} = viewRect; const zoom = parseInt($('zoom').value); // Set canvas size mainCanvas.width = vw * TILE_SIZE * zoom; mainCanvas.height = vh * TILE_SIZE * zoom; mainCtx.imageSmoothingEnabled = false; // Draw base mainCtx.drawImage( baseCanvas, vx * TILE_SIZE, vy * TILE_SIZE, vw * TILE_SIZE, vh * TILE_SIZE, 0, 0, vw * TILE_SIZE * zoom, vh * TILE_SIZE * zoom ); // Apply lighting mainCtx.globalCompositeOperation = 'multiply'; mainCtx.drawImage( lightResult.buffer, lightResult.offsetX + vx * TILE_SIZE * 4, lightResult.offsetY + vy * TILE_SIZE * 4, vw * TILE_SIZE * 4, vh * TILE_SIZE * 4, 0, 0, vw * TILE_SIZE * zoom, vh * TILE_SIZE * zoom ); // Apply memory mainCtx.globalCompositeOperation = 'lighter'; offscreenCanvas.width = vw * TILE_SIZE; offscreenCanvas.height = vh * TILE_SIZE; offscreenCtx.clearRect(0, 0, vw * TILE_SIZE, vh * TILE_SIZE); for (let y = 0; y < vh; y++) { for (let x = 0; x < vw; x++) { const gx = vx + x; const gy = vy + y; if (gx >= 0 && gx < W && gy >= 0 && gy < H) { const mem = memory[gy * W + gx]; if (mem > 0.01) { const alpha = mem * MEM_INTENSITY; offscreenCtx.fillStyle = `rgba(100, 100, 150, ${alpha})`; offscreenCtx.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE); } } } } mainCtx.drawImage( offscreenCanvas, 0, 0, vw * TILE_SIZE, vh * TILE_SIZE, 0, 0, vw * TILE_SIZE * zoom, vh * TILE_SIZE * zoom ); mainCtx.globalCompositeOperation = 'source-over'; // Draw doors for (const door of doors) { const screenX = door.x - vx; const screenY = door.y - vy; if (screenX >= 0 && screenX < vw && screenY >= 0 && screenY < vh) { const isOpen = doorOpen[door.y][door.orient] || false; mainCtx.fillStyle = isOpen ? 'brown' : 'saddlebrown'; mainCtx.fillRect( screenX * TILE_SIZE * zoom, screenY * TILE_SIZE * zoom, TILE_SIZE * zoom, TILE_SIZE * zoom ); } } // Draw player const screenX = player.x - vx; const screenY = player.y - vy; if (screenX >= 0 && screenX < vw && screenY >= 0 && screenY < vh) { mainCtx.fillStyle = 'red'; mainCtx.fillRect( screenX * TILE_SIZE * zoom + 4 * zoom, screenY * TILE_SIZE * zoom + 4 * zoom, 8 * zoom, 8 * zoom ); } } function render() { // Update view rectangle const vw = parseInt($('vw').value); const vh = parseInt($('vh').value); const halfVw = Math.floor(vw / 2); const halfVh = Math.floor(vh / 2); let viewX = player.x - halfVw; let viewY = player.y - halfVh; // Clamp to world bounds viewX = Math.max(0, Math.min(W - vw, viewX)); viewY = Math.max(0, Math.min(H - vh, viewY)); const viewRect = {x: viewX, y: viewY, w: vw, h: vh}; // Solve lighting const quality = $('lightQuality').value; const lightResult = solveTorch(player.x, player.y, quality); // Update memory with seen tiles for (let i = 0; i < W * H; i++) { if (lightResult.seenMask[i]) { memory[i] = 1.0; } } // Apply memory decay const now = performance.now(); const dt = now - lastLightUpdate; lastLightUpdate = now; if (dt > 0) { const decay = Math.pow(0.5, dt / MEM_HALFLIFE); for (let i = 0; i < memory.length; i++) { memory[i] *= decay; } } // Compose final image const exposure = parseFloat($('exposure').value); compose(mainCtx, baseCanvas, lightResult, exposure, viewRect, memory); } function handleKeyDown(e) { let dx = 0, dy = 0; switch(e.key) { case 'ArrowUp': case 'w': case 'W': dy = -1; break; case 'ArrowDown': case 's': case 'S': dy = 1; break; case 'ArrowLeft': case 'a': case 'A': dx = -1; break; case 'ArrowRight': case 'd': case 'D': dx = 1; break; case 'n': case 'N': $('regen').click(); return; case 'r': case 'R': $('respawn').click(); return; default: return; } e.preventDefault(); const newX = player.x + dx; const newY = player.y + dy; // Check if move is valid if (newX >= 0 && newX < W && newY >= 0 && newY < H) { const tile = grid[newY][newX]; let canMove = false; if (tile === ROOM || tile === CORRIDOR) { canMove = true; } else if (tile === DOOR) { if (doorOpen[newY][newX]) { canMove = true; } else { // Open door and move doorOpen[newY][newX] = true; canMove = true; } } if (canMove) { player.x = newX; player.y = newY; // Open adjacent doors for (let adx = -1; adx <= 1; adx++) { for (let ady = -1; ady <= 1; ady++) { if (Math.abs(adx) + Math.abs(ady) === 1) { const ax = player.x + adx; const ay = player.y + ady; if (ax >= 0 && ax < W && ay >= 0 && ay < H) { if (grid[ay][ax] === DOOR && !doorOpen[ay][ax]) { doorOpen[ay][ax] = true; } } } } } render(); } } } function exportImage() { const link = document.createElement('a'); link.download = 'dungeon.png'; link.href = mainCanvas.toDataURL('image/png'); link.click(); } // Start window.onload = init; </script> </body> </html> ```
```html <!DOCTYPE html> <meta charset="utf-8"> <title>Procedural Torch-lit Dungeon</title> <style> html,body{margin:0;height:100%;background:#000;color:#c8c8c8;font:14px/1.2 monospace;overflow:hidden} #ui{position:fixed;left:0;top:0;padding:4px;background:rgba(0,0,0,.6)} #ui>*{margin:2px} canvas{image-rendering:pixelated} </style> <div id="ui"> Seed <input id="seed" size="10"> Rooms <input id="rooms" type="number" min="3" max="40" value="18"> <button id="regen">Generate (N)</button> Zoom <input id="zoom" type="range" min="1" max="3" value="2"> Exposure <input id="expo" type="range" min="0.1" max="3" value="1" step="0.1"> Quality <select id="qual"> <option value="L">Low</option> <option value="M" selected>Medium</option> <option value="H">High</option> </select> </div> <canvas id="view"></canvas> <script> //──────────────────── RNG ──────────────────── function cyrb128(str){let h1=1779033703,h2=3144134277,h3=1013904242,h4=2773480762; for(let i=0;i<str.length;i++){let k=str.charCodeAt(i);h1=h2^Math.imul(h1^k,597399067); h2=h3^Math.imul(h2^k,2869860233);h3=h4^Math.imul(h3^k,951274213);h4=h1^Math.imul(h4^k,2716044179)} h1=Math.imul(h3^ (h1>>>18),597399067);h2=Math.imul(h4^(h2>>>22),2869860233); h3=Math.imul(h1^(h3>>>17),951274213);h4=Math.imul(h2^(h4>>>19),2716044179); return[(h1^h2^h3^h4)>>>0,(h2^h1)>>>0,(h3^h1)>>>0,(h4^h1)>>>0]} function mulberry32(a){return function(){a|=0; a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t^=t+Math.imul(t^t>>>7,61|t);return((t^t>>>14)>>>0)/4294967296}} function rngFactory(seed){return mulberry32(cyrb128(seed)[0])} //──────────────────── Constants ──────────────────── const TILE={VOID:0,WALL:1,ROOM:2,CORR:3,DOOR:4}; const DOOR_ORIENT={H:0,V:1}; const DIRS=[[0,-1],[1,0],[0,1],[-1,0]]; //──────────────────── Dungeon Generation ──────────────────── function generateDungeon(opts){ const {W,H,targetRooms,rmin,rmax,seed}=opts; const rng=rngFactory(seed); const grid=new Uint8Array(W*H).fill(TILE.VOID); const rooms=[]; function rnd(a,b){return Math.floor(rng()*(b-a+1))+a} // place rooms let tries=0; while(rooms.length<targetRooms && tries<targetRooms*30){ tries++; const w=rnd(rmin,rmax),h=rnd(rmin,rmax); const x=rnd(1,W-w-2),y=rnd(1,H-h-2); let ok=true; for(const r of rooms){ if(x-1<r.x+r.w+1 && x+w+1>r.x-1 && y-1<r.y+r.h+1 && y+h+1>r.y-1){ok=false;break} } if(!ok)continue; const room={x,y,w,h,cx:Math.floor(x+w/2),cy:Math.floor(y+h/2),doors:{}}; rooms.push(room); for(let yy=y;yy<y+h;yy++)for(let xx=x;xx<x+w;xx++)grid[yy*W+xx]=TILE.ROOM; } // walls around rooms for(let y=1;y<H-1;y++)for(let x=1;x<W-1;x++){ if(grid[y*W+x]===TILE.VOID){ for(const [dx,dy] of DIRS) if(grid[(y+dy)*W+x+dx]===TILE.ROOM){grid[y*W+x]=TILE.WALL;break} } } // Connectivity graph (MST) const edges=[]; for(let i=0;i<rooms.length;i++)for(let j=i+1;j<rooms.length;j++){ const a=rooms[i],b=rooms[j]; const d=(a.cx-b.cx)**2+(a.cy-b.cy)**2; edges.push({i,j,d}) } edges.sort((a,b)=>a.d-b.d); const parent=Array(rooms.length).fill(0).map((_,i)=>i); function find(v){return parent[v]===v?v:parent[v]=find(parent[v])} const graph=[]; for(const e of edges){ let a=find(e.i),b=find(e.j); if(a!==b){parent[a]=b;graph.push(e)} } // add extra edges for(const e of edges){ if(graph.includes(e))continue; if(rng()<0.13)graph.push(e) } // doors map for quick lookup const doorOpen=new Uint8Array(W*H).fill(0); const doorOrient=new Uint8Array(W*H).fill(0); function isDoorHere(x,y){return grid[y*W+x]===TILE.DOOR} function placeDoor(room,side,tx,ty,orient){ if(room.doors[side])return room.doors[side]; // reuse grid[ty*W+tx]=TILE.DOOR; doorOpen[ty*W+tx]=0;doorOrient[ty*W+tx]=orient; room.doors[side]={x:tx,y:ty,orient}; return room.doors[side]; } // for each graph edge place doors and corridors function carveCorridor(ax,ay,bx,by){ const openList=[[ax,ay]],came=new Map; const key=(x,y)=>y*W+x; const cost=new Map([[key(ax,ay),0]]); while(openList.length){ openList.sort((p,q)=>cost.get(key(p[0],p[1]))-cost.get(key(q[0],q[1]))); const [x,y]=openList.shift(); if(x===bx&&y===by)break; for(const [dx,dy] of DIRS){ const nx=x+dx,ny=y+dy; if(nx<0||ny<0||nx>=W||ny>=H)continue; if(grid[ny*W+nx]===TILE.ROOM)continue; // don't dig through rooms const k=key(nx,ny); const add=grid[ny*W+nx]===TILE.CORR?0:1; const nc=cost.get(key(x,y))+add; if(cost.has(k) && cost.get(k)<=nc)continue; cost.set(k,nc);came.set(k,[x,y]);openList.push([nx,ny]); } } // reconstruct let cx=bx,cy=by; while(!(cx===ax&&cy===ay)){ if(grid[cy*W+cx]===TILE.VOID)grid[cy*W+cx]=TILE.CORR; const p=came.get(key(cx,cy));if(!p)break;[cx,cy]=p; } } function sideDir(dx,dy){return Math.abs(dx)>Math.abs(dy)? (dx>0?'E':'W') : (dy>0?'S':'N')} for(const e of graph){ const A=rooms[e.i],B=rooms[e.j]; const sAB=sideDir(B.cx-A.cx,B.cy-A.cy); const sBA=sideDir(A.cx-B.cx,A.cy-B.cy); function doorPos(room,side){ switch(side){ case'N':return{tx:room.cx,ty:room.y-1,orient:DOOR_ORIENT.H}; case'S':return{tx:room.cx,ty:room.y+room.h,orient:DOOR_ORIENT.H}; case'W':return{tx:room.x-1,ty:room.cy,orient:DOOR_ORIENT.V}; case'E':return{tx:room.x+room.w,ty:room.cy,orient:DOOR_ORIENT.V}; } } const da=doorPos(A,sAB),db=doorPos(B,sBA); placeDoor(A,sAB,da.tx,da.ty,da.orient); placeDoor(B,sBA,db.tx,db.ty,db.orient); carveCorridor(da.tx,da.ty,db.tx,db.ty); } // walls along corridors for(let y=1;y<H-1;y++)for(let x=1;x<W-1;x++){ if(grid[y*W+x]===TILE.VOID){ for(const [dx,dy] of DIRS) if(grid[(y+dy)*W+x+dx]===TILE.CORR){grid[y*W+x]=TILE.WALL;break} } } return{W,H,grid,rooms,doorOpen,doorOrient}; } //──────────────────── Lighting ──────────────────── function solveLight(dun,player,qual){ const {W,H,grid,doorOpen}=dun; const {x:px,y:py}=player; const rays = qual==='L'?900:qual==='H'?1800:1300; const step=0.25,eps=0.1, radius=30; const lit=new Float32Array(W*H); function isBlock(tx,ty){ if(tx<0||ty<0||tx>=W||ty>=H)return true; const t=grid[ty*W+tx]; if(t===TILE.WALL||t===TILE.VOID)return true; if(t===TILE.DOOR && !doorOpen[ty*W+tx])return true; return false; } for(let i=0;i<rays;i++){ const ang=i*2*Math.PI/rays; let x=px+0.5,y=py+0.5,dx=Math.cos(ang)*step,dy=Math.sin(ang)*step,dist=0; let tx=Math.floor(x),ty=Math.floor(y); let ptx=tx,pty=ty; while(dist<radius){ x+=dx;y+=dy;dist+=step; tx=Math.floor(x);ty=Math.floor(y); if(tx===ptx&&ty===pty)continue; // corner occluder if(tx!==ptx && ty!==pty){ if(isBlock(tx,pty)&&isBlock(ptx,ty))break; } ptx=tx;pty=ty; if(isBlock(tx,ty))break; const L=1/((dist*dist)+(eps*eps)); const idx=ty*dun.W+tx; if(L>lit[idx])lit[idx]=L; } } return lit; } //──────────────────── Renderer ──────────────────── function srgb2lin(v){return Math.pow(v/255,2.2)} function lin2srgb(v){return Math.max(0,Math.min(1,Math.pow(v,1/2.2)))*255} function compose(viewCtx,dun,lit,mem,opts){ const {W,H,grid,doorOpen,doorOrient}=dun; const {camX,camY,vw,vh,tile,expo,memIntensity,player}=opts; viewCtx.clearRect(0,0,vw*tile,vh*tile); for(let sy=0;sy<vh;sy++)for(let sx=0;sx<vw;sx++){ const gx=camX+sx,gy=camY+sy; if(gx<0||gy<0||gx>=W||gy>=H)continue; const idx=gy*W+gx; const base = (()=>{ switch(grid[idx]){ case TILE.ROOM:return[140,140,140]; case TILE.CORR:return[110,110,110]; case TILE.WALL:return[60,60,60]; default:return[0,0,0]; } })(); const l=lit[idx],m=mem[idx]; if(l>0)mem[idx]=1; // update memory const I=Math.min(1,expo*l + memIntensity*m); if(I<=0)continue; const r=lin2srgb(srgb2lin(base[0])*I), g=lin2srgb(srgb2lin(base[1])*I), b=lin2srgb(srgb2lin(base[2])*I); viewCtx.fillStyle=`rgb(${r|0},${g|0},${b|0})`; viewCtx.fillRect(sx*tile,sy*tile,tile,tile); // doors if(grid[idx]===TILE.DOOR && (l>0||m>0.01)){ viewCtx.fillStyle=doorOpen[idx]? '#663': '#aa6'; if(doorOrient[idx]===DOOR_ORIENT.H) viewCtx.fillRect(sx*tile,sy*tile+tile*0.4,tile,tile*0.2); else viewCtx.fillRect(sx*tile+tile*0.4,sy*tile,tile*0.2,tile); } } // player viewCtx.fillStyle="#f22"; viewCtx.fillRect((player.x-camX)*tile+tile*0.25,(player.y-camY)*tile+tile*0.25,tile*0.5,tile*0.5); } //──────────────────── Game State ──────────────────── const canvas=document.getElementById('view'); const vctx=canvas.getContext('2d'); let dungeon,player,mem,lit; let viewport={w:25,h:18}; const tilePix=16; let seed="demo"; let quality='M'; function newDungeon(){ seed=document.getElementById('seed').value||Math.random().toString(36).slice(2); document.getElementById('seed').value=seed; dungeon=generateDungeon({ W:80,H:60,targetRooms:+document.getElementById('rooms').value, rmin:4,rmax:9,seed }); mem=new Float32Array(dungeon.W*dungeon.H); // pick player spawn (corridor deg>=2) for(let i=0;i<dungeon.grid.length;i++){ if(dungeon.grid[i]===TILE.CORR){ const x=i%dungeon.W,y=(i/dungeon.W)|0; let deg=0; for(const [dx,dy] of DIRS){ const t=dungeon.grid[(y+dy)*dungeon.W+x+dx]; if(t===TILE.CORR)deg++; } if(deg>=2){player={x,y};break} } } if(!player)player={x:dungeon.rooms[0].cx,y:dungeon.rooms[0].cy}; recomputeLight(); } function recomputeLight(){lit=solveLight(dungeon,player,quality)} //──────────────────── UI ──────────────────── document.getElementById('regen').onclick=()=>newDungeon(); document.getElementById('qual').onchange=e=>{quality=e.target.value;recomputeLight()} document.getElementById('zoom').oninput=e=>draw(); document.getElementById('expo').oninput=e=>draw(); document.addEventListener('keydown',e=>{ const key=e.key.toLowerCase(); if(key==='n'){newDungeon();return} const mv={w:[0,-1],arrowup:[0,-1],s:[0,1],arrowdown:[0,1],a:[-1,0],arrowleft:[-1,0],d:[1,0],arrowright:[1,0]}[key]; if(!mv)return; const [dx,dy]=mv; const tx=player.x+dx,ty=player.y+dy; if(tx<0||ty<0||tx>=dungeon.W||ty>=dungeon.H)return; const idx=ty*dungeon.W+tx; const t=dungeon.grid[idx]; if(t===TILE.WALL||t===TILE.VOID)return; if(t===TILE.DOOR && !dungeon.doorOpen[idx]){ dungeon.doorOpen[idx]=1; // auto open } if(t!==TILE.DOOR||dungeon.doorOpen[idx]){ player.x=tx;player.y=ty; // open adjacent QoL for(const [adx,ady] of DIRS){ const ax=player.x+adx,ay=player.y+ady; if(ax<0||ay<0||ax>=dungeon.W||ay>=dungeon.H)continue; const aidx=ay*dungeon.W+ax; if(dungeon.grid[aidx]===TILE.DOOR)dungeon.doorOpen[aidx]=1; } } recomputeLight(); draw(); }); let last=performance.now(); function draw(){ const now=performance.now(),dt=(now-last)/1000;last=now; // memory decay const decay=Math.pow(0.5,dt/20); for(let i=0;i<mem.length;i++)mem[i]*=decay; // camera const zoom=+document.getElementById('zoom').value; viewport.w=Math.floor(window.innerWidth/(tilePix*zoom)); viewport.h=Math.floor(window.innerHeight/(tilePix*zoom)); const camX=Math.max(0,Math.min(dungeon.W-viewport.w,player.x-Math.floor(viewport.w/2))); const camY=Math.max(0,Math.min(dungeon.H-viewport.h,player.y-Math.floor(viewport.h/2))); canvas.width=viewport.w*tilePix*zoom; canvas.height=viewport.h*tilePix*zoom; vctx.save(); vctx.scale(zoom,zoom); compose(vctx,dungeon,lit,mem,{ camX,camY,vw:viewport.w,vh:viewport.h,tile:tilePix, expo:+document.getElementById('expo').value, memIntensity:0.08, player}); vctx.restore(); requestAnimationFrame(draw); } newDungeon(); requestAnimationFrame(draw); </script> ```
Of course. Here is a single-file HTML/JS/CSS application that meets all your specifications. ```html <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Procedural Dungeon Renderer</title> <style> body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; display: flex; background-color: #1a1a1c; color: #e0e0e0; margin: 0; padding: 10px; height: calc(100vh - 20px); overflow: hidden; flex-wrap: wrap; } #main-container { display: flex; flex-direction: column; align-items: center; justify-content: center; flex-grow: 1; } canvas { background-color: #000; border: 1px solid #444; image-rendering: pixelated; image-rendering: -moz-crisp-edges; image-rendering: crisp-edges; } #controls { width: 280px; padding: 15px; background-color: #2a2a2e; border-left: 1px solid #444; overflow-y: auto; display: flex; flex-direction: column; gap: 15px; } .control-group { border: 1px solid #444; padding: 10px; border-radius: 4px; } .control-group h3 { margin-top: 0; margin-bottom: 10px; font-size: 1em; color: #b0c4de; } .control-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } .control-row label { font-size: 0.9em; } .control-row input[type="text"], .control-row input[type="number"], .control-row select { width: 100px; background: #333; color: #ddd; border: 1px solid #555; padding: 4px; border-radius: 3px; } .control-row input[type="range"] { flex-grow: 1; margin-left: 10px; } button { width: 100%; padding: 8px; background: #4a6fa5; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; } button:hover { background: #5a7fb5; } .seed-row button { width: auto; } #log { font-size: 0.8em; color: #888; margin-top: 10px; font-family: monospace; } </style> </head> <body> <div id="main-container"> <canvas id="gameCanvas"></canvas> <div id="log">Use WASD or Arrow Keys to move. Press N to regenerate.</div> </div> <div id="controls"> <div class="control-group"> <h3>Generation</h3> <div class="control-row" id="seed-row"> <label for="seed">Seed</label> <input type="text" id="seed" value="dungeon crawler"> <button onclick="APP.randomizeSeed()">🎲</button> </div> <div class="control-row"> <label for="mapWidth">Map W</label> <input type="number" id="mapWidth" value="80"> </div> <div class="control-row"> <label for="mapHeight">Map H</label> <input type="number" id="mapHeight" value="60"> </div> <div class="control-row"> <label for="numRooms">Rooms</label> <input type="number" id="numRooms" value="25"> </div> <div class="control-row"> <label for="minRoomSize">Min Room</label> <input type="number" id="minRoomSize" value="5"> </div> <div class="control-row"> <label for="maxRoomSize">Max Room</label> <input type="number" id="maxRoomSize" value="13"> </div> </div> <div class="control-group"> <h3>View</h3> <div class="control-row"> <label for="zoom">Zoom (x)</label> <input type="range" id="zoom" min="1" max="3" step="0.1" value="2"> </div> <div class="control-row"> <label for="exposure">Exposure</label> <input type="range" id="exposure" min="0.1" max="5" step="0.1" value="1.5"> </div> </div> <div class="control-group"> <h3>Lighting</h3> <div class="control-row"> <label for="quality">Quality</label> <select id="quality"> <option value="low">Low</option> <option value="medium" selected>Medium</option> <option value="high">High</option> <option value="ultra">Ultra</option> </select> </div> <div class="control-row"> <label for="falloff">Falloff</label> <select id="falloff"> <option value="0.8">Softest</option> <option value="0.9">Soft</option> <option value="1.0" selected>Physical</option> <option value="1.1">Hard</option> <option value="1.2">Hardest</option> </select> </div> </div> <div class="control-group"> <h3>Actions</h3> <button onclick="APP.regenerate()">Regenerate (N)</button> <button onclick="APP.respawnPlayer()" style="margin-top:8px">Respawn Player (R)</button> <button onclick="APP.exportPNG()" style="margin-top:8px">Export PNG</button> </div> </div> <script> 'use strict'; const TILE = { VOID: 0, WALL: 1, ROOM: 2, CORRIDOR: 3, DOOR: 4, }; const APP = { // --- State --- rng: null, dungeon: null, player: { x: 0, y: 0 }, config: {}, needsLightUpdate: true, lastFrameTime: 0, // --- Canvases & Buffers --- canvas: null, ctx: null, baseCanvas: null, // Pre-rendered sRGB base map baseLinearCanvas: null, // Pre-rendered linear RGB base map lightBuffer: null, // High-res light intensity buffer memoryBuffer: null, // Per-tile memory glow buffer // --- Utility: PRNG (cyrb128 -> mulberry32) --- cyrb128: (str) => { let h1 = 1779033703, h2 = 3144134277, h3 = 1013904242, h4 = 2773480762; for (let i = 0, k; i < str.length; i++) { k = str.charCodeAt(i); h1 = h2 ^ Math.imul(h1 ^ k, 597399067); h2 = h3 ^ Math.imul(h2 ^ k, 2869860233); h3 = h4 ^ Math.imul(h3 ^ k, 951274213); h4 = h1 ^ Math.imul(h4 ^ k, 2716044179); } h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067); h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233); h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213); h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179); return [(h1^h2^h3^h4)>>>0, (h2^h1)>>>0, (h3^h1)>>>0, (h4^h1)>>>0]; }, mulberry32: (a) => { return function() { var t = a += 0x6D2B79F5; t = Math.imul(t ^ t >>> 15, t | 1); t ^= t + Math.imul(t ^ t >>> 7, t | 61); return ((t ^ t >>> 14) >>> 0) / 4294967296; } }, // --- Utility: Color Conversion --- sRGBToLinear: (c) => (c <= 0.04045) ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4), linearToSRGB: (c) => (c <= 0.0031308) ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055, // --- Utility: Math & Geometry --- rectsIntersect: (r1, r2, padding) => (r1.x < r2.x + r2.w + padding && r1.x + r1.w + padding > r2.x && r1.y < r2.y + r2.h + padding && r1.y + r1.h + padding > r2.y), // --- Main Initialization --- init() { this.canvas = document.getElementById('gameCanvas'); this.ctx = this.canvas.getContext('2d'); this.baseCanvas = document.createElement('canvas'); this.baseLinearCanvas = document.createElement('canvas'); // Setup UI listeners document.getElementById('zoom').addEventListener('input', () => this.draw()); document.getElementById('exposure').addEventListener('input', () => this.draw()); document.getElementById('falloff').addEventListener('change', () => { this.needsLightUpdate = true; }); document.getElementById('quality').addEventListener('change', () => { this.needsLightUpdate = true; }); window.addEventListener('keydown', this.handleKeyDown.bind(this)); // Start the first generation this.regenerate(); this.lastFrameTime = performance.now(); requestAnimationFrame(this.gameLoop.bind(this)); }, // --- UI Actions --- randomizeSeed() { document.getElementById('seed').value = Math.random().toString(36).substring(2, 12); }, regenerate() { this.collectConfig(); this.logMessage(`Generating with seed: "${this.config.seed}"...`); const seed = this.cyrb128(this.config.seed); this.rng = this.mulberry32(seed[0]); const genStart = performance.now(); this.dungeon = this.generateDungeon(); if (!this.dungeon) return; // Generation failed this.memoryBuffer = new Float32Array(this.dungeon.W * this.dungeon.H); const buildStart = performance.now(); this.buildBaseCanvases(); this.respawnPlayer(); const genTime = (buildStart - genStart).toFixed(2); const buildTime = (performance.now() - buildStart).toFixed(2); this.logMessage(`Done! Gen: ${genTime}ms, Render: ${buildTime}ms`); }, respawnPlayer() { this.player = this.findSpawnPoint(); this.openAdjacentDoors(); this.needsLightUpdate = true; }, findSpawnPoint() { const { grid, W, H, rooms } = this.dungeon; let candidates = []; // Prefer corridor intersections for (let y = 1; y < H - 1; y++) { for (let x = 1; x < W - 1; x++) { if (grid[y * W + x] === TILE.CORRIDOR) { let neighbors = 0; if (grid[y * W + (x + 1)] === TILE.CORRIDOR) neighbors++; if (grid[y * W + (x - 1)] === TILE.CORRIDOR) neighbors++; if (grid[(y + 1) * W + x] === TILE.CORRIDOR) neighbors++; if (grid[(y - 1) * W + x] === TILE.CORRIDOR) neighbors++; if (neighbors >= 2) candidates.push({x, y}); } } } if (candidates.length > 0) return candidates[Math.floor(this.rng() * candidates.length)]; // Fallback to any corridor tile for (let i = 0; i < grid.length; i++) { if (grid[i] === TILE.CORRIDOR) return { x: i % W, y: Math.floor(i / W) }; } // Fallback to first room center if (rooms.length > 0) return { x: rooms[0].cx, y: rooms[0].cy }; return {x: 1, y: 1}; // Should not happen }, exportPNG() { const link = document.createElement('a'); link.download = `dungeon-${this.config.seed}-${Date.now()}.png`; link.href = this.canvas.toDataURL('image/png'); link.click(); }, // --- Configuration --- collectConfig() { this.config = { seed: document.getElementById('seed').value, W: parseInt(document.getElementById('mapWidth').value), H: parseInt(document.getElementById('mapHeight').value), targetRooms: parseInt(document.getElementById('numRooms').value), rmin: parseInt(document.getElementById('minRoomSize').value), rmax: parseInt(document.getElementById('maxRoomSize').value), zoom: parseFloat(document.getElementById('zoom').value), exposure: parseFloat(document.getElementById('exposure').value), quality: document.getElementById('quality').value, falloff: parseFloat(document.getElementById('falloff').value), // Static config padding: 2, tileSize: 16, memIntensity: 0.08, memHalfLife: 20000, // 20 seconds }; const qualitySettings = { low: { S: 3, rays: 900, step: 0.3 }, medium: { S: 4, rays: 1300, step: 0.25 }, high: { S: 5, rays: 1800, step: 0.25 }, ultra: { S: 6, rays: 2200, step: 0.2 }, }; this.config.light = qualitySettings[this.config.quality]; }, // --- Dungeon Generation --- generateDungeon() { const { W, H, targetRooms, rmin, rmax, padding } = this.config; const grid = new Uint8Array(W * H).fill(TILE.VOID); const rooms = []; const maxAttempts = targetRooms * 10; for (let i = 0; i < maxAttempts && rooms.length < targetRooms; i++) { const w = Math.floor(this.rng() * (rmax - rmin + 1) + rmin); const h = Math.floor(this.rng() * (rmax - rmin + 1) + rmin); const x = Math.floor(this.rng() * (W - w - 2)) + 1; const y = Math.floor(this.rng() * (H - h - 2)) + 1; const newRoom = { x, y, w, h, cx: x + Math.floor(w / 2), cy: y + Math.floor(h / 2) }; if (!rooms.some(r => this.rectsIntersect(r, newRoom, padding))) { rooms.push(newRoom); for (let ry = y; ry < y + h; ry++) { for (let rx = x; rx < x + w; rx++) { grid[ry * W + rx] = TILE.ROOM; } } } } // Place walls around rooms for (let y = 1; y < H - 1; y++) { for (let x = 1; x < W - 1; x++) { if (grid[y * W + x] === TILE.VOID) { if (grid[y * W + x - 1] === TILE.ROOM || grid[y * W + x + 1] === TILE.ROOM || grid[(y - 1) * W + x] === TILE.ROOM || grid[(y + 1) * W + x] === TILE.ROOM) { grid[y * W + x] = TILE.WALL; } } } } // Connectivity Graph (MST + extra edges) const edges = []; for (let i = 0; i < rooms.length; i++) { for (let j = i + 1; j < rooms.length; j++) { const dist = Math.hypot(rooms[i].cx - rooms[j].cx, rooms[i].cy - rooms[j].cy); edges.push({ u: i, v: j, weight: dist }); } } edges.sort((a,b) => a.weight - b.weight); const parent = Array.from({length: rooms.length}, (_, i) => i); const find = (i) => (parent[i] === i) ? i : (parent[i] = find(parent[i])); const union = (i, j) => { const rootI = find(i); const rootJ = find(j); if (rootI !== rootJ) { parent[rootI] = rootJ; return true; } return false; }; const mstEdges = []; const nonMstEdges = []; for (const edge of edges) { if (union(edge.u, edge.v)) { mstEdges.push(edge); } else { nonMstEdges.push(edge); } } const finalEdges = [...mstEdges]; const extraEdgeCount = Math.floor(nonMstEdges.length * 0.12); // ~12% for cycles for (let i = 0; i < extraEdgeCount; i++) { const randIdx = Math.floor(this.rng() * nonMstEdges.length); finalEdges.push(nonMstEdges.splice(randIdx, 1)[0]); } const doors = []; const doorMap = {}; // Using map for quick lookup during generation and play // Place Doors for (const edge of finalEdges) { const r1 = rooms[edge.u]; const r2 = rooms[edge.v]; this.placeDoorBetween(r1, r2, W, grid, doors, doorMap); this.placeDoorBetween(r2, r1, W, grid, doors, doorMap); } const isRoomWall = (x, y) => { if(x <= 0 || x >= W-1 || y <= 0 || y >= H-1) return false; for(let dy = -1; dy<=1; dy++){ for(let dx = -1; dx<=1; dx++){ if(dx === 0 && dy === 0) continue; if(grid[(y+dy)*W + (x+dx)] === TILE.ROOM) return true; } } return false; } // Carve Corridors (A*) for (const door of doors) { let pathFound = false; for(const otherDoor of doors) { if(door === otherDoor) continue; const start = { x: door.x + (door.orient === 'H' ? 0 : (door.x < W/2 ? -1 : 1)), y: door.y + (door.orient === 'V' ? 0 : (door.y < H/2 ? -1 : 1)) }; const endPos = { x: otherDoor.x + (otherDoor.orient === 'H' ? 0 : (otherDoor.x < W/2 ? -1 : 1)), y: otherDoor.y + (otherDoor.orient === 'V' ? 0 : (otherDoor.y < H/2 ? -1 : 1)) }; // Simplified connectivity; just connect to one other door // A better approach would be connecting doors from linked rooms in the graph // For this implementation, we just connect doors to a path until they hit *anything* if(grid[start.y * W + start.x] === TILE.CORRIDOR) continue; const path = this.AStar(start, W, H, grid, (x, y) => { const tile = grid[y*W+x]; return tile === TILE.CORRIDOR || tile === TILE.DOOR; }, (x, y) => { // Cost function const tile = grid[y*W+x]; if (tile === TILE.CORRIDOR) return 0.1; // Strongly prefer existing corridors if (tile === TILE.WALL && !isRoomWall(x,y)) return 5; // Can carve non-room walls if (tile === TILE.VOID) return 1; return Infinity; }); if (path) { for (const_pt of path) { if (grid[const_pt.y * W + const_pt.x] !== TILE.DOOR) { grid[const_pt.y * W + const_pt.x] = TILE.CORRIDOR; } } } } } // Final connectivity pass by carving paths between doors of connected rooms for(const edge of finalEdges){ const r1 = rooms[edge.u]; const r2 = rooms[edge.v]; const door1 = doors.find(d => d.room === r1 && d.linkedRoom === r2); const door2 = doors.find(d => d.room === r2 && d.linkedRoom === r1); if(!door1 || !door2) continue; // Should not happen with current logic const start = {x: door1.x + (door1.orient === 'V' ? (r2.cx > r1.cx ? 1 : -1) : 0), y: door1.y + (door1.orient === 'H' ? (r2.cy > r1.cy ? 1 : -1) : 0)}; const end = {x: door2.x + (door2.orient === 'V' ? (r1.cx > r2.cx ? 1 : -1) : 0), y: door2.y + (door2.orient === 'H' ? (r1.cy > r2.cy ? 1 : -1) : 0)}; const path = this.AStar(start, W, H, grid, (x, y) => x ===end.x && y === end.y, (x, y) => { const tile = grid[y*W+x]; if(tile === TILE.CORRIDOR) return 0.1; if(tile === TILE.WALL && !isRoomWall(x,y)) return 5; if(tile === TILE.VOID) return 1; return 1000; }, (x,y) => Math.abs(x-end.x) + Math.abs(y-end.y)); if(path){ for(const pt of path){ grid[pt.y * W + pt.x] = TILE.CORRIDOR; } } } // Place walls around corridors for (let y = 1; y < H - 1; y++) { for (let x = 1; x < W - 1; x++) { if (grid[y * W + x] === TILE.VOID) { if (grid[y*W+x-1] === TILE.CORRIDOR || grid[y*W+x+1] === TILE.CORRIDOR || grid[(y-1)*W+x] === TILE.CORRIDOR || grid[(y+1)*W+x] === TILE.CORRIDOR) { grid[y * W + x] = TILE.WALL; } } } } // Connectivity check const visited = new Array(rooms.length).fill(false); const q = [0]; visited[0] = true; let count = 1; while (q.length > 0) { const u = q.shift(); for (const edge of finalEdges) { if(edge.u === u && !visited[edge.v]) { visited[edge.v] = true; q.push(edge.v); count++; } else if(edge.v === u && !visited[edge.u]) { visited[edge.u] = true; q.push(edge.u); count++; } } } if (count !== rooms.length) { this.logMessage(`Error: Not all rooms are connected! (${count}/${rooms.length})`); return null; // Generation failed } return { W, H, grid, rooms, doors, doorMap, connected: true }; }, placeDoorBetween(r1, r2, W, grid, doors, doorMap) { const dx = r2.cx - r1.cx; const dy = r2.cy - r1.cy; let wallPoints = []; let orient; let side; if (Math.abs(dx) > Math.abs(dy)) { // Horizontal connection side = dx > 0 ? 'E' : 'W'; orient = 'V'; } else { // Vertical connection side = dy > 0 ? 'S' : 'N'; orient = 'H'; } // Check if a door already exists for this room on this side if (doors.some(d => d.room === r1 && d.side === side)) return; if (side === 'E') wallPoints = Array.from({length: r1.h}, (_,i) => ({x: r1.x + r1.w, y: r1.y + i})); if (side === 'W') wallPoints = Array.from({length: r1.h}, (_,i) => ({x: r1.x - 1, y: r1.y + i})); if (side === 'S') wallPoints = Array.from({length: r1.w}, (_,i) => ({x: r1.x + i, y: r1.y + r1.h})); if (side === 'N') wallPoints = Array.from({length: r1.w}, (_,i) => ({x: r1.x + i, y: r1.y - 1})); wallPoints = wallPoints.filter(p => grid[p.y * W + p.x] === TILE.WALL); if (wallPoints.length > 0) { const pt = wallPoints[Math.floor(this.rng() * wallPoints.length)]; const door = { x: pt.x, y: pt.y, orient, state: 'closed', room: r1, side, linkedRoom: r2 }; doors.push(door); if (!doorMap[pt.y]) doorMap[pt.y] = {}; doorMap[pt.y][pt.x] = door; grid[pt.y * W + pt.x] = TILE.DOOR; } }, AStar(start, W, H, grid, isGoal, getCost, getHeuristic = ()=>0) { const openSet = [{...start, g:0, f:getHeuristic(start.x, start.y)}]; const cameFrom = {}; const gScore = {}; gScore[`${start.y*W+start.x}`] = 0; while(openSet.length > 0){ openSet.sort((a,b) => a.f - b.f); const current = openSet.shift(); if(isGoal(current.x, current.y)){ const path = []; let curr = current; while(curr){ path.unshift({x: curr.x, y: curr.y}); curr = cameFrom[`${curr.y*W+curr.x}`]; } return path; } const neighbors = [{dx:0, dy:1}, {dx:0, dy:-1}, {dx:1, dy:0}, {dx:-1, dy:0}]; for(const n of neighbors){ const nx = current.x + n.dx; const ny = current.y + n.dy; if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue; const cost = getCost(nx, ny); if (cost === Infinity) continue; const tentative_gScore = gScore[`${current.y*W+current.x}`] + cost; const neighborKey = `${ny*W+nx}`; if(!gScore.hasOwnProperty(neighborKey) || tentative_gScore < gScore[neighborKey]){ cameFrom[neighborKey] = current; gScore[neighborKey] = tentative_gScore; const fScore = tentative_gScore + getHeuristic(nx, ny); if(!openSet.some(node => node.x === nx && node.y === ny)){ openSet.push({x: nx, y: ny, g: tentative_gScore, f: fScore}); } } } } return null; // No path }, // --- Base Layer Rendering --- buildBaseCanvases() { const { W, H, grid, rooms } = this.dungeon; const { tileSize } = this.config; this.baseCanvas.width = W * tileSize; this.baseCanvas.height = H * tileSize; const baseCtx = this.baseCanvas.getContext('2d'); const baseHue = this.rng() * 360; const palette = { room: `hsl(${baseHue}, 30%, 35%)`, corridor: `hsl(${(baseHue + 40) % 360}, 25%, 28%)`, wall: `hsl(${(baseHue + 180) % 360}, 10%, 20%)`, }; // Generate pseudo-random grunge for tiles const grungeCanvas = document.createElement('canvas'); grungeCanvas.width = tileSize; grungeCanvas.height = tileSize; const grungeCtx = grungeCanvas.getContext('2d'); const grungeData = grungeCtx.createImageData(tileSize, tileSize); for(let i=0; i<grungeData.data.length; i+=4){ const v = Math.floor(this.rng() * 50) + 20; grungeData.data[i] = grungeData.data[i+1] = grungeData.data[i+2] = 0; grungeData.data[i+3] = v; } grungeCtx.putImageData(grungeData, 0, 0); for(let y=0; y<H; y++){ for(let x=0; x<W; x++){ const tile = grid[y * W + x]; let color; switch(tile){ case TILE.ROOM: color = palette.room; break; case TILE.CORRIDOR: color = palette.corridor; break; case TILE.WALL: color = palette.wall; break; default: continue; } baseCtx.fillStyle = color; baseCtx.fillRect(x*tileSize, y*tileSize, tileSize, tileSize); baseCtx.drawImage(grungeCanvas, x*tileSize, y*tileSize); } } // Convert to linear sRGB canvas this.baseLinearCanvas.width = this.baseCanvas.width; this.baseLinearCanvas.height = this.baseCanvas.height; const baseLinearCtx = this.baseLinearCanvas.getContext('2d'); const baseImageData = baseCtx.getImageData(0,0, baseCtx.canvas.width, baseCtx.canvas.height); const linearImageData = baseLinearCtx.createImageData(baseImageData.width, baseImageData.height); for(let i=0; i < baseImageData.data.length; i+=4){ linearImageData.data[i] = this.sRGBToLinear(baseImageData.data[i] / 255) * 255; linearImageData.data[i+1] = this.sRGBToLinear(baseImageData.data[i+1] / 255) * 255; linearImageData.data[i+2] = this.sRGBToLinear(baseImageData.data[i+2] / 255) * 255; linearImageData.data[i+3] = baseImageData.data[i+3]; } baseLinearCtx.putImageData(linearImageData, 0, 0); }, // --- Lighting Calculation --- solveTorch() { if (!this.dungeon) return; const { W, H, grid, doorMap } = this.dungeon; const { S, rays, step } = this.config.light; const { falloff } = this.config; const radiusTiles = 20; // Fixed large radius; falloff handles the rest const eps = 0.1; const torchColor = { r: 1.0, g: 0.85, b: 0.6 }; const lightW = W * S; const lightH = H * S; if(!this.lightBuffer || this.lightBuffer.length !== lightW * lightH * 3){ this.lightBuffer = new Float32Array(lightW * lightH * 3); } else { this.lightBuffer.fill(0); } const isBlocking = (tx, ty) => { if (tx<0||ty<0||tx>=W||ty>=H) return true; const tile = grid[ty*W+tx]; if(tile === TILE.WALL) return true; if(tile === TILE.DOOR && doorMap[ty]?.[tx]?.state === 'closed') return true; return false; }; const torchXf = (this.player.x + 0.5); const torchYf = (this.player.y + 0.5); const startSubX = torchXf * S; const startSubY = torchYf * S; const maxDist = radiusTiles * S; for (let i = 0; i < rays; i++) { const angle = (i / rays) * 2 * Math.PI; const dx = Math.cos(angle) * step * S; const dy = Math.sin(angle) * step * S; let lastTx = Math.floor(torchXf); let lastTy = Math.floor(torchYf); for (let d = 0; d < maxDist; d++) { const subX = Math.floor(startSubX + dx * d); const subY = Math.floor(startSubY + dy * d); if (subX < 0 || subX >= lightW || subY < 0 || subY >= lightH) break; const tx = Math.floor(subX / S); const ty = Math.floor(subY / S); // Corner Occlusion Rule if (tx !== lastTx && ty !== lastTy) { if (isBlocking(tx, lastTy) && isBlocking(lastTx, ty)) { break; } } if (isBlocking(tx, ty)) break; const distTiles = Math.hypot(subX/S - torchXf, subY/S - torchYf); const intensity = Math.pow(1 / (distTiles * distTiles + eps * eps), falloff); const idx = (subY * lightW + subX) * 3; this.lightBuffer[idx] = intensity * torchColor.r; this.lightBuffer[idx+1] = intensity * torchColor.g; this.lightBuffer[idx+2] = intensity * torchColor.b; // Update seen mask for memory this.memoryBuffer[ty*W+tx] = 1.0; lastTx = tx; lastTy = ty; } } this.needsLightUpdate = false; }, // --- Main Game Loop & Drawing --- gameLoop(timestamp) { const dt = timestamp - this.lastFrameTime; this.lastFrameTime = timestamp; this.updateMemory(dt); if (this.needsLightUpdate) { this.solveTorch(); } this.draw(); requestAnimationFrame(this.gameLoop.bind(this)); }, updateMemory(dt) { if (!this.memoryBuffer) return; const decayFactor = Math.pow(0.5, dt / this.config.memHalfLife); for(let i=0; i<this.memoryBuffer.length; i++) { this.memoryBuffer[i] *= decayFactor; } }, draw() { if (!this.dungeon) return; this.collectConfig(); // Get latest zoom/exposure const { W, H, grid, doorMap } = this.dungeon; const { tileSize, zoom, exposure, memIntensity } = this.config; const { S } = this.config.light; // Setup canvas size based on container and zoom const container = document.getElementById('main-container'); const canvasSize = Math.min(container.clientWidth - 20, container.clientHeight - 40); this.canvas.width = canvasSize; this.canvas.height = canvasSize; const viewTilesW = this.canvas.width / tileSize / zoom; const viewTilesH = this.canvas.height / tileSize / zoom; const camX = Math.max(0, Math.min(W - viewTilesW, this.player.x - viewTilesW / 2)); const camY = Math.max(0, Math.min(H - viewTilesH, this.player.y - viewTilesH / 2)); this.ctx.imageSmoothingEnabled = false; this.ctx.fillStyle = 'black'; this.ctx.fillRect(0,0, this.canvas.width, this.canvas.height); // --- Composition Pass --- const destImageData = this.ctx.createImageData(this.canvas.width, this.canvas.height); const baseLinearCtx = this.baseLinearCanvas.getContext('2d'); const baseLinearData = baseLinearCtx.getImageData(0,0, this.baseLinearCanvas.width, this.baseLinearCanvas.height).data; const lightW = W * S; for(let py = 0; py < this.canvas.height; py++){ for(let px = 0; px < this.canvas.width; px++){ const worldX = camX + px / (tileSize * zoom); const worldY = camY + py / (tileSize * zoom); const tx = Math.floor(worldX); const ty = Math.floor(worldY); if (tx < 0 || tx>= W || ty < 0 || ty >= H) continue; const tileMem = this.memoryBuffer[ty*W+tx]; const subX = Math.floor(worldX * S); const subY = Math.floor(worldY * S); const lightIdx = (subY * lightW + subX) * 3; const torchR = this.lightBuffer[lightIdx] || 0; if (torchR > 0.001 || tileMem > 0.01) { const baseIdx = (Math.floor(worldY*tileSize) * this.baseLinearCanvas.width + Math.floor(worldX * tileSize)) * 4; const baseR = baseLinearData[baseIdx] / 255; const baseG = baseLinearData[baseIdx+1] / 255; const baseB = baseLinearData[baseIdx+2] / 255; const torchG = this.lightBuffer[lightIdx+1] || 0; const torchB = this.lightBuffer[lightIdx+2] || 0; const finalR = baseR * (exposure * torchR + memIntensity * tileMem); const finalG = baseG * (exposure * torchG + memIntensity * tileMem); const finalB = baseB * (exposure * torchB + memIntensity * tileMem); const destIdx = (py * this.canvas.width + px) * 4; destImageData.data[destIdx] = this.linearToSRGB(finalR) * 255; destImageData.data[destIdx+1] = this.linearToSRGB(finalG) * 255; destImageData.data[destIdx+2] = this.linearToSRGB(finalB) * 255; destImageData.data[destIdx+3] = 255; } } } this.ctx.putImageData(destImageData, 0, 0); // --- Draw Sprites --- this.ctx.save(); this.ctx.scale(zoom, zoom); this.ctx.translate(-camX * tileSize, -camY * tileSize); // Doors for (const door of this.dungeon.doors) { if (this.memoryBuffer[door.y * W + door.x] > 0.01 || this.lightBuffer[(Math.floor((door.y+0.5)*S) * lightW + Math.floor((door.x+0.5)*S)) * 3] > 0.001) { this.drawDoor(door); } } // Player if (this.player) { this.drawPlayer(); } this.ctx.restore(); }, drawDoor(door){ const {tileSize} = this.config; const x = door.x * tileSize; const y = door.y * tileSize; this.ctx.save(); this.ctx.translate(x + tileSize / 2, y + tileSize / 2); if (door.state === 'closed') { this.ctx.fillStyle = '#654321'; // Brown this.ctx.strokeStyle = '#4a2d14'; this.ctx.lineWidth = 2; if (door.orient === 'V') { this.ctx.fillRect(-tileSize * 0.1, -tileSize / 2, tileSize * 0.2, tileSize); this.ctx.strokeRect(-tileSize * 0.1, -tileSize / 2, tileSize * 0.2, tileSize); } else { // 'H' this.ctx.fillRect(-tileSize / 2, -tileSize * 0.1, tileSize, tileSize * 0.2); this.ctx.strokeRect(-tileSize / 2, -tileSize * 0.1, tileSize, tileSize * 0.2); } } else { // Open this.ctx.fillStyle = '#654321'; if (door.orient === 'V') { this.ctx.fillRect(-tileSize*0.1, -tileSize/2, tileSize*0.2, tileSize*0.2); this.ctx.fillRect(-tileSize*0.1, tileSize/2 - tileSize*0.2, tileSize*0.2, tileSize*0.2); } else { this.ctx.fillRect(-tileSize/2, -tileSize*0.1, tileSize*0.2, tileSize*0.2); this.ctx.fillRect(tileSize/2 - tileSize*0.2, -tileSize*0.1, tileSize*0.2, tileSize*0.2); } } this.ctx.restore(); }, drawPlayer(){ const {tileSize} = this.config; const x = this.player.x * tileSize; const y = this.player.y * tileSize; this.ctx.save(); this.ctx.translate(x + tileSize / 2, y + tileSize / 2); // Body this.ctx.fillStyle = '#88aaff'; this.ctx.beginPath(); this.ctx.arc(0, 0, tileSize * 0.3, 0, 2 * Math.PI); this.ctx.fill(); // Torch this.ctx.fillStyle = '#ffcc00'; this.ctx.beginPath(); this.ctx.arc(0, 0, tileSize * 0.1, 0, 2 * Math.PI); this.ctx.fill(); this.ctx.restore(); }, // --- Player Logic --- handleKeyDown(e) { if (e.key.toUpperCase() === 'N') { this.regenerate(); return; } if (e.key.toUpperCase() === 'R') { this.respawnPlayer(); return; } let dx = 0, dy = 0; switch (e.key) { case 'w': case 'ArrowUp': dy = -1; break; case 's': case 'ArrowDown': dy = 1; break; case 'a': case 'ArrowLeft': dx = -1; break; case 'd': case 'ArrowRight': dx = 1; break; default: return; // Not a movement key } e.preventDefault(); const nx = this.player.x + dx; const ny = this.player.y + dy; if (nx < 0 || nx >= this.dungeon.W || ny < 0 || ny >= this.dungeon.H) return; const { grid, doorMap } = this.dungeon; const targetTile = grid[ny * this.dungeon.W + nx]; const door = doorMap[ny]?.[nx]; if (targetTile === TILE.WALL || targetTile === TILE.VOID) return; if (door && door.state === 'closed') { door.state = 'open'; this.needsLightUpdate = true; } this.player.x = nx; this.player.y = ny; this.needsLightUpdate = true; this.openAdjacentDoors(); }, openAdjacentDoors() { const { x, y } = this.player; const { W, doorMap } = this.dungeon; const neighbors = [{dx:0,dy:1},{dx:0,dy:-1},{dx:1,dy:0},{dx:-1,dy:0}]; for (const n of neighbors) { const nx = x + n.dx; const ny = y + n.dy; const door = doorMap[ny]?.[nx]; if (door && door.state === 'closed') { door.state = 'open'; this.needsLightUpdate = true; } } }, logMessage(msg) { document.getElementById('log').textContent = msg; } }; document.addEventListener('DOMContentLoaded', () => APP.init()); </script> </body> </html> ```
```html <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0"/> <title>Procedural Dungeon with Torchlight</title> <style> body { margin: 0; overflow: hidden; background: #111; color: #eee; font-family: monospace; display: flex; flex-direction: column; height: 100vh; } #container { display: flex; flex: 1; } #game { flex: 1; display: flex; justify-content: center; align-items: center; padding: 10px; position: relative; } canvas { image-rendering: crisp-edges; border: 1px solid #333; } #ui { width: 280px; padding: 15px; box-sizing: border-box; background: #222; border-left: 1px solid #444; overflow-y: auto; } input, button, select { margin: 5px 0; display: block; width: 100%; } label { display: block; margin-top: 10px; } .slider-label { display: flex; justify-content: space-between; } </style> </head> <body> <div id="container"> <div id="game"> <canvas id="display"></canvas> </div> <div id="ui"> <h2>Dungeon Generator</h2> <label>Seed <button id="randomSeed">Random</button> <input id="seed" type="text" value="dungeon"/></label> <label>Map Size <input id="width" type="number" min="40" max="200" value="80"/> × <input id="height" type="number" min="20" max="100" value="50"/> </label> <label>Rooms <input id="numRooms" type="number" min="5" max="100" value="15"/> </label> <label>Room Min Size <input id="rmin" type="number" min="3" max="10" value="4"/> </label> <label>Room Max Size <input id="rmax" type="number" min="5" max="15" value="10"/> </label> <label>View (tiles) <div class="slider-label"> <span>W:</span><span id="vwVal">40</span> </div> <input id="viewW" type="range" min="10" max="100" value="40"/> <div class="slider-label"> <span>H:</span><span id="vhVal">25</span> </div> <input id="viewH" type="range" min="10" max="100" value="25"/> </label> <label>Zoom <select id="zoom"> <option value="1">1x</option> <option value="2" selected>2x</option> <option value="3">3x</option> </select> </label> <label>Lighting Quality <select id="quality"> <option value="low">Low (S=3, ~900 rays)</option> <option value="medium" selected>Medium (S=4, ~1300 rays)</option> <option value="high">High (S=5, ~1800 rays)</option> </select> </label> <label>Falloff Preset <select id="falloffPreset"> <option value="1.0">Standard (p=1.0)</option> <option value="1.2">Harder (p=1.2)</option> <option value="0.8">Softer (p=0.8)</option> </select> </label> <label>Exposure <input id="exposure" type="range" min="0.1" max="5.0" step="0.1" value="2.0"/> <span id="expVal">2.0</span> </label> <button id="regen">N - Regenerate</button> <button id="respawn">R - Respawn</button> <button id="export">Export PNG</button> <p><small>Move: WASD or Arrow Keys</small></p> </div> </div> <script type="module"> // Deterministic RNG function cyrb128(str) { let h1 = 1779033703, h2 = 3144134277, h3 = 1013904242, h4 = 2773480762; for (let i = 0; i < str.length; i++) { h1 = Math.imul(h1 ^ str.charCodeAt(i), 2654435761); h2 = Math.imul(h2 ^ str.charCodeAt(i), 1597334961); h3 = Math.imul(h3 ^ str.charCodeAt(i), 891269183); h4 = Math.imul(h4 ^ str.charCodeAt(i), 991236189); } h1 ^= h1 >>> 13; h2 ^= h2 >>> 13; h3 ^= h3 >>> 13; h4 ^= h4 >>> 13; h1 += h2; h1 += h3; h1 += h4; h2 += h1; h3 += h1; h4 += h1; return [h1 >>> 0, h2 >>> 0, h3 >>> 0, h4 >>> 0]; } function mulberry32(a) { return function() { let t = a += 0x6D2B79F5; t = Math.imul(t ^ t >>> 15, t | 1); t ^= t + Math.imul(t ^ t >>> 7, t | 61); return ((t ^ t >>> 14) >>> 0) / 4294967296; }; } // Constants const TILE_TYPES = { VOID: 0, WALL: 1, ROOM: 2, CORRIDOR: 3, DOOR: 4 }; const DOOR_ORIENTATION = { H: 0, V: 1 }; const TILE_PIXEL = 16; let SUBCELL_SCALE = 4; let NUM_RAYS = 1300; let MARCH_STEP = 0.25; let EPSILON = 0.1; // DOM Elements const canvas = document.getElementById('display'); const ctx = canvas.getContext('2d'); const seedInput = document.getElementById('seed'); const randomSeedBtn = document.getElementById('randomSeed'); const widthInput = document.getElementById('width'); const heightInput = document.getElementById('height'); const numRoomsInput = document.getElementById('numRooms'); const rminInput = document.getElementById('rmin'); const rmaxInput = document.getElementById('rmax'); const viewWInput = document.getElementById('viewW'); const viewHInput = document.getElementById('viewH'); const vwVal = document.getElementById('vwVal'); const vhVal = document.getElementById('vhVal'); const zoomSelect = document.getElementById('zoom'); const qualitySelect = document.getElementById('quality'); const falloffPresetSelect = document.getElementById('falloffPreset'); const exposureInput = document.getElementById('exposure'); const expVal = document.getElementById('expVal'); const regenBtn = document.getElementById('regen'); const respawnBtn = document.getElementById('respawn'); const exportBtn = document.getElementById('export'); // State let prng; let world = { W: 80, H: 50, grid: new Uint8Array(80 * 50), rooms: [], doors: [], doorOpen: [], doorOrient: [] }; let memory = new Float32Array(world.W * world.H); let player = { x: 0, y: 0 }; let view = { w: 40, h: 25, zoom: 2 }; let exposure = 2.0; let falloffP = 1.0; let halfLife = 20; // seconds let memIntensity = 0.08; let lastTime = 0; let needsLightUpdate = true; let baseCanvas = null; let baseCtx = null; let tileImages = {}; let lightingCanvas = null; let lightCtx = null; // Utilities function getIndex(x, y, w = world.W) { return y * w + x; } function isInside(x, y, w = world.W, h = world.H) { return x >= 0 && y >= 0 && x < w && y < h; } function lerp(a, b, t) { return a * (1 - t) + b * t; } function sRgbToLinear(c) { return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); } function linearToSRgb(c) { return c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; } // PRNG from seed function reseed(seedStr) { const seedState = cyrb128(seedStr); prng = mulberry32(seedState[0]); } // Clamp function clamp(val, min, max) { return Math.min(Math.max(val, min), max); } // Room class class Room { constructor(x, y, w, h) { this.x = x; this.y = y; this.w = w; this.h = h; this.cx = x + Math.floor(w / 2); this.cy = y + Math.floor(h / 2); } intersects(other) { return !(this.x >= other.x + other.w || this.x + this.w <= other.x || this.y >= other.y + other.h || this.y + this.h <= other.y); } distanceSq(other) { const dx = this.cx - other.cx; const dy = this.cy - other.cy; return dx * dx + dy * dy; } getWalls() { return { top: { x1: this.x, x2: this.x + this.w - 1, y: this.y - 1 }, bottom: { x1: this.x, x2: this.x + this.w - 1, y: this.y + this.h }, left: { y1: this.y, y2: this.y + this.h - 1, x: this.x - 1 }, right: { y1: this.y, y2: this.y + this.h - 1, x: this.x + this.w } }; } } // Generate dungeon function generateDungeon({ W, H, targetRooms, rmin, rmax, seed }) { reseed(seed); const grid = new Uint8Array(W * H); const rooms = []; const doors = []; const doorOpen = new Array(H).fill(0).map(() => new Array(W).fill(true)); const doorOrient = new Array(H).fill(0).map(() => new Array(W).fill(0)); // Place rooms const padding = 1; let attempts = 0; while (rooms.length < targetRooms && attempts < 10000) { const w = Math.floor(prng() * (rmax - rmin + 1)) + rmin; const h = Math.floor(prng() * (rmax - rmin + 1)) + rmin; const x = Math.floor(prng() * (W - w - 2 * padding)) + padding; const y = Math.floor(prng() * (H - h - 2 * padding)) + padding; const room = new Room(x, y, w, h); let overlaps = false; for (const r of rooms) { if (room.intersects(r)) { overlaps = true; break; } } if (!overlaps) { rooms.push(room); for (let iy = y; iy < y + h; iy++) { for (let ix = x; ix < x + w; ix++) { if (isInside(ix, iy, W, H)) { grid[getIndex(ix, iy, W)] = TILE_TYPES.ROOM; } } } } attempts++; } // Walls around rooms for (const room of rooms) { const { top, bottom, left, right } = room.getWalls(); for (let x = top.x1; x <= top.x2; x++) { if (isInside(x, top.y, W, H)) grid[getIndex(x, top.y, W)] = TILE_TYPES.WALL; } for (let x = bottom.x1; x <= bottom.x2; x++) { if (isInside(x, bottom.y, W, H)) grid[getIndex(x, bottom.y, W)] = TILE_TYPES.WALL; } for (let y = left.y1; y <= left.y2; y++) { if (isInside(left.x, y, W, H)) grid[getIndex(left.x, y, W)] = TILE_TYPES.WALL; } for (let y = right.y1; y <= right.y2; y++) { if (isInside(right.x, y, W, H)) grid[getIndex(right.x, y, W)] = TILE_TYPES.WALL; } } // Connectivity graph (MST) const n = rooms.length; const graph = Array(n).fill(null).map(() => []); if (n > 0) { const edges = []; for (let i = 0; i < n; i++) { for (let j = i + 1; j < n; j++) { edges.push({ i, j, d: rooms[i].distanceSq(rooms[j]) }); } } edges.sort((a, b) => a.d - b.d); const parent = Array(n).fill(-1); function find(x) { while (parent[x] >= 0) x = parent[x]; return x; } function union(x, y) { const rx = find(x), ry = find(y); if (rx !== ry) { if (parent[rx] < parent[ry]) { parent[rx] += parent[ry]; parent[ry] = rx; } else { parent[ry] += parent[rx]; parent[rx] = ry; } return true; } return false; } const mstEdges = []; for (const e of edges) { if (union(e.i, e.j)) { mstEdges.push(e); graph[e.i].push(e.j); graph[e.j].push(e.i); } } // Add some extra edges for cycles (~10-15% of non-MST edges) const extraCount = Math.floor(edges.length * 0.12) - mstEdges.length; for (const e of edges) { if (extraCount > 0 && graph[e.i].indexOf(e.j) === -1) { if (prng() < 0.5) continue; graph[e.i].push(e.j); graph[e.j].push(e.i); extraCount--; } } } // For each edge, place doors on both sides const sideDoors = new Array(rooms.length); for (let i = 0; i < rooms.length; i++) { sideDoors[i] = { top: null, bottom: null, left: null, right: null }; } for (let i = 0; i < rooms.length; i++) { for (const j of graph[i]) { if (i >= j) continue; const r1 = rooms[i], r2 = rooms[j]; // Determine which wall of r1 is closest to r2 let side = null; let dx = r2.cx - r1.cx; let dy = r2.cy - r1.cy; if (Math.abs(dx) > Math.abs(dy)) { side = dx > 0 ? 'right' : 'left'; } else { side = dy > 0 ? 'bottom' : 'top'; } // Reuse existing door if exists let doorPos = sideDoors[i][side]; if (!doorPos) { const walls = r1.getWalls(); const wall = walls[side]; let x, y; if (side === 'top' || side === 'bottom') { x = Math.floor(prng() * (wall.x2 - wall.x1 - 2) + wall.x1 + 1); y = wall.y; } else { y = Math.floor(prng() * (wall.y2 - wall.y1 - 2) + wall.y1 + 1); x = wall.x; } doorPos = { x, y }; sideDoors[i][side] = doorPos; } // Do the same for room j let side2 = null; dx = r1.cx - r2.cx; dy = r1.cy - r2.cy; if (Math.abs(dx) > Math.abs(dy)) { side2 = dx > 0 ? 'right' : 'left'; } else { side2 = dy > 0 ? 'bottom' : 'top'; } let doorPos2 = sideDoors[j][side2]; if (!doorPos2) { const walls2 = rooms[j].getWalls(); const wall2 = walls2[side2]; let x, y; if (side2 === 'top' || side2 === 'bottom') { x = Math.floor(prng() * (wall2.x2 - wall2.x1 - 2) + wall2.x1 + 1); y = wall2.y; } else { y = Math.floor(prng() * (wall2.y2 - wall2.y1 - 2) + wall2.y1 + 1); x = wall2.x; } doorPos2 = { x, y }; sideDoors[j][side2] = doorPos2; } // Record door between r1 and r2 doors.push({ x: doorPos.x, y: doorPos.y, orient: (side === 'top' || side === 'bottom') ? DOOR_ORIENTATION.H : DOOR_ORIENTATION.V, room1: i, room2: j }); doors.push({ x: doorPos2.x, y: doorPos2.y, orient: (side2 === 'top' || side2 === 'bottom') ? DOOR_ORIENTATION.H : DOOR_ORIENTATION.V, room1: j, room2: i }); // Set in grid grid[getIndex(doorPos.x, doorPos.y, W)] = TILE_TYPES.DOOR; doorOpen[doorPos.y][doorPos.x] = false; // closed doorOrient[doorPos.y][doorPos.x] = (side === 'top' || side === 'bottom') ? 1 : 0; grid[getIndex(doorPos2.x, doorPos2.y, W)] = TILE_TYPES.DOOR; doorOpen[doorPos2.y][doorPos2.x] = false; doorOrient[doorPos2.y][doorPos2.x] = (side2 === 'top' || side2 === 'bottom') ? 1 : 0; } } // Carve corridors function carvePath(x1, y1, x2, y2, passThroughExistent = true) { const openSet = []; const cameFrom = {}; const gScore = new Map(); const h = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y); const key = (x, y) => `${x},${y}`; const getKey = (node) => key(node.x, node.y); const start = { x: x1, y: y1 }; const goal = { x: x2, y: y2 }; openSet.push({ node: start, f: h(start, goal) }); gScore.set(getKey(start), 0); const neighbors = [[0, -1], [1, 0], [0, 1], [-1, 0]]; while (openSet.length) { openSet.sort((a, b) => a.f - b.f); const current = openSet.shift().node; if (current.x === goal.x && current.y === goal.y) { // Reconstruct path const path = []; let step = current; while (step) { path.unshift(step); const k = getKey(step); if (cameFrom[k]) step = cameFrom[k]; else step = null; } return path; } for (const [dx, dy] of neighbors) { const nx = current.x + dx, ny = current.y + dy; if (!isInside(nx, ny, W, H)) continue; if (grid[getIndex(nx, ny, W)] === TILE_TYPES.ROOM) continue; // Don't carve through room interiors if (grid[getIndex(nx, ny, W)] === TILE_TYPES.WALL) { // Check: is this wall adjacent to a room? If so, skip. let adjacentToRoom = false; for (const [adx, ady] of neighbors) { const ax = nx + adx, ay = ny + ady; if (isInside(ax, ay, W, H) && grid[getIndex(ax, ay, W)] === TILE_TYPES.ROOM) { adjacentToRoom = true; break; } } if (adjacentToRoom) continue; } let tentativeG = gScore.get(getKey(current)) + (grid[getIndex(nx, ny, W)] === TILE_TYPES.CORRIDOR && passThroughExistent ? 0.1 : 1.0); const neighbor = { x: nx, y: ny }; const keyN = getKey(neighbor); if (!gScore.has(keyN) || tentativeG < gScore.get(keyN)) { cameFrom[keyN] = current; gScore.set(keyN, tentativeG); const f = tentativeG + h(neighbor, goal); openSet.push({ node: neighbor, f }); } } } return null; } // For each pair that has a door, carve for (let i = 0; i < doors.length; i += 2) { if (i + 1 >= doors.length) break; const d1 = doors[i], d2 = doors[i + 1]; const path = carvePath(d1.x, d1.y, d2.x, d2.y); if (path) { for (const p of path) { if (grid[getIndex(p.x, p.y, W)] === TILE_TYPES.VOID) { grid[getIndex(p.x, p.y, W)] = TILE_TYPES.CORRIDOR; } } } } // Corridor side walls const tempGrid = new Uint8Array(grid); for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { if (grid[getIndex(x, y, W)] === TILE_TYPES.CORRIDOR) { const neighbors = [[0, -1], [1, 0], [0, 1], [-1, 0]]; for (const [dx, dy] of neighbors) { const nx = x + dx, ny = y + dy; if (isInside(nx, ny, W, H) && tempGrid[getIndex(nx, ny, W)] === TILE_TYPES.VOID) { tempGrid[getIndex(nx, ny, W)] = TILE_TYPES.WALL; } } } } } for (let i = 0; i < tempGrid.length; i++) { if (tempGrid[i] === TILE_TYPES.WALL && grid[i] === TILE_TYPES.CORRIDOR) { // Avoid overwriting } else if (grid[i] !== TILE_TYPES.ROOM && grid[i] !== TILE_TYPES.WALL) { grid[i] = tempGrid[i]; } } // Verify connectivity (BFS) let connected = true; const visited = new Set(); const queue = []; const startCell = { x: rooms[0].cx, y: rooms[0].cy }; queue.push(startCell); visited.add(getIndex(startCell.x, startCell.y)); const dirs = [[0, -1], [1, 0], [0, 1], [-1, 0]]; while (queue.length) { const cur = queue.shift(); for (const [dx, dy] of dirs) { const nx = cur.x + dx, ny = cur.y + dy; const idx = getIndex(nx, ny); if (!isInside(nx, ny) || visited.has(idx)) continue; const tile = grid[idx]; if (tile === TILE_TYPES.ROOM || tile === TILE_TYPES.CORRIDOR || (tile === TILE_TYPES.DOOR && doorOpen[ny][nx])) { visited.add(idx); queue.push({ x: nx, y: ny }); } } } // Check if all rooms are reachable for (const room of rooms) { const idx = getIndex(room.cx, room.cy); if (!visited.has(idx)) connected = false; } return { grid, rooms, doors, doorOpen, doorOrient, W, H, connected }; } // Build tileset from seed function buildTileset(seed) { reseed(seed + '-tiles'); const canvas = document.createElement('canvas'); canvas.width = TILE_PIXEL * 8; canvas.height = TILE_PIXEL; const ctx = canvas.getContext('2d'); // Palette in HSL const baseHue = (prng() * 360) | 0; const roomHue = (baseHue + 30 + prng() * 60) % 360; const corridorHue = (baseHue + prng() * 30) % 360; const wallHue = (baseHue + 200 + prng() * 40) % 360; function drawTile(x, color) { ctx.fillStyle = `hsl(${color.h}, ${color.s}%, ${color.l}%)`; ctx.fillRect(x * TILE_PIXEL, 0, TILE_PIXEL, TILE_PIXEL); // Add grunge ctx.save(); ctx.globalAlpha = 0.15; for (let i = 0; i < 4; i++) { const rx = (prng() * TILE_PIXEL) | 0; const ry = (prng() * TILE_PIXEL) | 0; const rsize = (prng() * 3) | 0 + 1; ctx.fillRect(x * TILE_PIXEL + rx, ry, rsize, rsize); } ctx.restore(); } drawTile(0, { h: 0, s: 0, l: 0 }); // VOID drawTile(1, { h: wallHue, s: 30, l: 28 }); // WALL drawTile(2, { h: roomHue, s: 20, l: 45 }); // ROOM drawTile(3, { h: corridorHue, s: 25, l: 40 }); // CORRIDOR drawTile(4, { h: wallHue, s: 30, l: 40 }); // DOOR drawTile(5, { h: wallHue, s: 40, l: 60 }); // DOOR OPEN drawTile(6, { h: 60, s: 100, l: 60 }); // PLAYER drawTile(7, { h: 40, s: 100, l: 50 }); // TORCH const images = { void: ctx.getImageData(0, 0, TILE_PIXEL, TILE_PIXEL), wall: ctx.getImageData(TILE_PIXEL, 0, TILE_PIXEL, TILE_PIXEL), room: ctx.getImageData(TILE_PIXEL * 2, 0, TILE_PIXEL, TILE_PIXEL), corridor: ctx.getImageData(TILE_PIXEL * 3, 0, TILE_PIXEL, TILE_PIXEL), doorH: ctx.getImageData(TILE_PIXEL * 4, 0, TILE_PIXEL, TILE_PIXEL), doorV: ctx.getImageData(TILE_PIXEL * 4, 0, TILE_PIXEL, TILE_PIXEL), doorOpenH: ctx.getImageData(TILE_PIXEL * 5, 0, TILE_PIXEL, TILE_PIXEL), doorOpenV: ctx.getImageData(TILE_PIXEL * 5, 0, TILE_PIXEL, TILE_PIXEL), player: ctx.getImageData(TILE_PIXEL * 6, 0, TILE_PIXEL, TILE_PIXEL), torch: ctx.getImageData(TILE_PIXEL * 7, 0, TILE_PIXEL, TILE_PIXEL), }; return images; } // Pre-render base layer function renderBaseToCanvas(dungeon, tiles) { const { W, H } = dungeon; const canvas = document.createElement('canvas'); canvas.width = W * TILE_PIXEL; canvas.height = H * TILE_PIXEL; const ctx = canvas.getContext('2d'); for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { const t = dungeon.grid[y * W + x]; let img; if (t === TILE_TYPES.VOID) img = tiles.void; else if (t === TILE_TYPES.WALL) img = tiles.wall; else if (t === TILE_TYPES.ROOM) img = tiles.room; else if (t === TILE_TYPES.CORRIDOR) img = tiles.corridor; else img = tiles.void; // fallback if (img) { ctx.putImageData(img, x * TILE_PIXEL, y * TILE_PIXEL); } } } // Draw doors (on top of underlying tile) for (const d of dungeon.doors) { const open = dungeon.doorOpen[d.y][d.x]; const img = d.orient === DOOR_ORIENTATION.H ? (open ? tiles.doorOpenH : tiles.doorH) : (open ? tiles.doorOpenV : tiles.doorV); ctx.putImageData(img, d.x * TILE_PIXEL, d.y * TILE_PIXEL); } return canvas; } // Solve torch lighting with line-of-sight function solveTorch(dungeon, playerXY, options) { const { W, H, grid, doorOpen } = dungeon; const { S, rays, step, radiusTiles, p, eps } = options; const width = (Math.ceil(radiusTiles * 2 * S)) | 0; const height = width; const buffer = new Float32Array(width * height); // linear intensity [0, 1] const seenMask = new Uint8Array(W * H); const half = radiusTiles * S; const ox = playerXY.x, oy = playerXY.y; const torchCenter = { x: ox, y: oy }; const distSq = (a, b) => (a.x - b.x) ** 2 + (a.y - b.y) ** 2; const isBlocking = (x, y) => { if (!isInside(x, y, W, H)) return true; const t = grid[getIndex(x, y, W)]; if (t === TILE_TYPES.WALL) return true; if (t === TILE_TYPES.DOOR && !doorOpen[y][x]) return true; return false; }; const angleStep = (Math.PI * 2) / rays; for (let i = 0; i < rays; i++) { const angle = i * angleStep; const dx = Math.cos(angle); const dy = Math.sin(angle); let x = ox, y = oy; let dTotal = 0; let prevX = Math.floor(x * S), prevY = Math.floor(y * S); while (dTotal < radiusTiles) { x += dx * step; y += dy * step; dTotal += step; const tx = Math.floor(x * S), ty = Math.floor(y * S); const worldX = Math.floor(x), worldY = Math.floor(y); const idx = getIndex(tx + half, ty + half, width); // Corner occlusion check if (tx !== prevX && ty !== prevY) { const cx1 = isBlocking(tx, prevY); const cx2 = isBlocking(prevX, ty); if (cx1 && cx2) break; } // If out of bounds if (tx < -half || tx >= width - half || ty < -half || ty >= height - half) break; // If blocked by wall or closed door if (isBlocking(worldX, worldY)) break; // Compute falloff const d = dTotal; const intensity = Math.pow(1 / (d * d + eps * eps), p); if (intensity > buffer[idx]) buffer[idx] = intensity; // Mark world tile as seen if (isInside(worldX, worldY, W, H)) { seenMask[getIndex(worldX, worldY, W)] = 1; } prevX = tx; prevY = ty; } } return { samplePix(px, py) { const x = Math.floor((px - ox + radiusTiles) * S); const y = Math.floor((py - oy + radiusTiles) * S); if (x < 0 || y < 0 || x >= width || y >= height) return 0; return buffer[getIndex(x, y, width)]; }, seenMask }; } // Compose final image function compose(viewCanvas, baseCanvas, sampler, exposure, region, memory, dungeon, memIntensity, tiles, zoom) { const { x: rx, y: ry, w: rw, h: rh } = region; const ctx = viewCanvas.getContext('2d'); const scaledTile = TILE_PIXEL * zoom; const wTiles = Math.ceil(rw / TILE_PIXEL); const hTiles = Math.ceil(rh / TILE_PIXEL); const viewImageData = ctx.createImageData(rw, rh); const data = viewImageData.data; const baseCtx = baseCanvas.getContext('2d'); const baseData = baseCtx.getImageData(rx, ry, rw, rh).data; const centerX = rx + rw / 2; const centerY = ry + rh / 2; for (let y = 0; y < rh; y++) { for (let x = 0; x < rw; x++) { const i = (y * rw + x) * 4; const worldX = rx + x, worldY = ry + y; const tx = Math.floor(worldX / TILE_PIXEL), ty = Math.floor(worldY / TILE_PIXEL); const tileType = isInside(tx, ty, dungeon.W, dungeon.H) ? dungeon.grid[getIndex(tx, ty, dungeon.W)] : TILE_TYPES.VOID; const worldTx = tx, worldTy = ty; const memoryVal = isInside(worldTx, worldTy, dungeon.W, dungeon.H) ? memory[getIndex(worldTx, worldTy, dungeon.W)] : 0; const lit = sampler.samplePix(worldX / TILE_PIXEL, worldY / TILE_PIXEL); const visible = lit > 0.001 || memoryVal > 0.001; // Only lighting and base let r, g, b, a; if (!visible && tileType !== TILE_TYPES.VOID) { r = g = b = a = 0; } else { // Base color const br = sRgbToLinear(baseData[i] / 255); const bg = sRgbToLinear(baseData[i + 1] / 255); const bb = sRgbToLinear(baseData[i + 2] / 255); const alpha = baseData[i + 3] / 255; // Torches are warm const torchR = sRgbToLinear(1.0), torchG = sRgbToLinear(0.8), torchB = sRgbToLinear(0.5); const torchContrib = exposure * (torchR * lit); const memContrib = memIntensity * memoryVal; r = br * (torchContrib + memContrib); g = bg * (torchContrib + memContrib); b = bb * (torchContrib + memContrib); a = alpha; r = clamp(linearToSRgb(r), 0, 1) * 255; g = clamp(linearToSRgb(g), 0, 1) * 255; b = clamp(linearToSRgb(b), 0, 1) * 255; a = a * 255; } data[i] = r; data[i + 1] = g; data[i + 2] = b; data[i + 3] = a; // Redraw door and player sprites if visible if (visible) { if (tileType === TILE_TYPES.DOOR) { const open = dungeon.doorOpen[ty][tx]; const orient = dungeon.doorOrient[ty][tx]; const imgData = open ? (orient === DOOR_ORIENTATION.H ? tiles.doorOpenH : tiles.doorOpenV) : (orient === DOOR_ORIENTATION.H ? tiles.doorH : tiles.doorV); const spriteX = x - (x % TILE_PIXEL), spriteY = y - (y % TILE_PIXEL); // This would be async, so we skip for now; better to pre-blit later } } } } ctx.putImageData(viewImageData, 0, 0); // Now blit overlay sprites with proper scaling ctx.save(); ctx.scale(zoom, zoom); const drawSprite = (img, tx, ty) => { const sx = Math.floor(tx * TILE_PIXEL - rx); const sy = Math.floor(ty * TILE_PIXEL - ry); if (sx >= -TILE_PIXEL && sy >= -TILE_PIXEL && sx < rw && sy < rh) { ctx.putImageData(img, sx, sy); } }; // Draw doors for (const d of dungeon.doors) { const tx = d.x, ty = d.y; const visible = sampler.samplePix(tx + 0.5, ty + 0.5) > 0.001 || memory[getIndex(tx, ty, dungeon.W)] > 0.005; if (visible) { const open = dungeon.doorOpen[ty][tx]; const img = open ? (d.orient === DOOR_ORIENTATION.H ? tiles.doorOpenH : tiles.doorOpenV) : (d.orient === DOOR_ORIENTATION.H ? tiles.doorH : tiles.doorV); drawSprite(img, tx, ty); } } // Draw player drawSprite(tiles.player, player.x, player.y); ctx.restore(); } // Reset and resize offscreen buffers function resizeOffscreens() { const { W, H } = world; const radiusTiles = Math.ceil(Math.hypot(view.w, view.h) * 0.5 * view.zoom) + 4; lightingCanvas = new OffscreenCanvas( Math.ceil(radiusTiles * 2 * SUBCELL_SCALE), Math.ceil(radiusTiles * 2 * SUBCELL_SCALE) ); lightCtx = lightingCanvas.getContext('2d'); baseCanvas = renderBaseToCanvas(world, tileImages); baseCtx = baseCanvas.getContext('2d'); memory = new Float32Array(world.W * world.H); needsLightUpdate = true; } // Find spawn point function findSpawn() { // Prefer corridor with ≥2 neighbors const dirs = [[0, -1], [1, 0], [0, 1], [-1, 0]]; for (let y = 0; y < world.H; y++) { for (let x = 0; x < world.W; x++) { const i = getIndex(x, y); if (world.grid[i] === TILE_TYPES.CORRIDOR) { let count = 0; for (const [dx, dy] of dirs) { const nx = x + dx, ny = y + dy; if (isInside(nx, ny) && world.grid[getIndex(nx, ny)] !== TILE_TYPES.WALL && !(world.grid[getIndex(nx, ny)] === TILE_TYPES.DOOR && !world.doorOpen[ny][nx])) { count++; } } if (count >= 2) return { x, y }; } } } // Fallback to room center for (const room of world.rooms) { return { x: room.cx, y: room.cy }; } // First passable for (let y = 0; y < world.H; y++) { for (let x = 0; x < world.W; x++) { const t = world.grid[getIndex(x, y)]; if (t === TILE_TYPES.ROOM || t === TILE_TYPES.CORRIDOR) { return { x, y }; } } } return { x: 0, y: 0 }; } // Regenerate function regenerate() { const config = { W: parseInt(widthInput.value), H: parseInt(heightInput.value), targetRooms: parseInt(numRoomsInput.value), rmin: parseInt(rminInput.value), rmax: parseInt(rmaxInput.value), seed: seedInput.value }; world = generateDungeon(config); player = findSpawn(); memory = new Float32Array(world.W * world.H); tileImages = buildTileset(seedInput.value); resizeOffscreens(); needsLightUpdate = true; } // Open door at (x,y) function openDoorAt(x, y) { if (isInside(x, y, world.W, world.H) && world.grid[getIndex(x, y)] === TILE_TYPES.DOOR) { world.doorOpen[y][x] = true; needsLightUpdate = true; } } // Open adjacent doors function openAdjacentDoors() { const dirs = [[0, -1], [1, 0], [0, 1], [-1, 0]]; for (const [dx, dy] of dirs) { const x = player.x + dx, y = player.y + dy; openDoorAt(x, y); } } // Handle movement function tryMove(dx, dy) { const nx = player.x + dx, ny = player.y + dy; if (!isInside(nx, ny, world.W, world.H)) return; const idx = getIndex(nx, ny, world.W); const tile = world.grid[idx]; if (tile === TILE_TYPES.WALL || tile === TILE_TYPES.VOID) return; if (tile === TILE_TYPES.DOOR && !world.doorOpen[ny][nx]) { openDoorAt(nx, ny); } const passable = (tile === TILE_TYPES.ROOM || tile === TILE_TYPES.CORRIDOR || (tile === TILE_TYPES.DOOR && world.doorOpen[ny][nx])); if (passable) { player.x = nx; player.y = ny; openAdjacentDoors(); needsLightUpdate = true; } } // Game loop function gameLoop(timestamp) { const dt = lastTime ? (timestamp - lastTime) / 1000 : 0; lastTime = timestamp; // Decay memory if (dt > 0) { const decayFactor = Math.pow(0.5, dt / halfLife); for (let i = 0; i < memory.length; i++) { memory[i] *= decayFactor; if (memory[i] < 0.001) memory[i] = 0; } } // Recompute lighting if needed if (needsLightUpdate) { const radiusTiles = Math.ceil(Math.hypot(view.w, view.h) * 0.5 * view.zoom) + 4; const torchResult = solveTorch(world, player, { S: SUBCELL_SCALE, rays: NUM_RAYS, step: MARCH_STEP, radiusTiles, p: falloffP, eps: EPSILON }); // Update memory for seen tiles for (let y = 0; y < world.H; y++) { for (let x = 0; x < world.W; x++) { if (torchResult.seenMask[getIndex(x, y)]) { memory[getIndex(x, y)] = 1.0; } } } needsLightUpdate = false; // Render from torchResult const { w, h, zoom } = view; const tileArea = TILE_PIXEL * zoom; const displayWidth = w * tileArea; const displayHeight = h * tileArea; canvas.width = displayWidth; canvas.height = displayHeight; const cx = Math.floor(player.x * TILE_PIXEL - displayWidth / 2); const cy = Math.floor(player.y * TILE_PIXEL - displayHeight / 2); const clampX = clamp(cx, 0, baseCanvas.width - displayWidth); const clampY = clamp(cy, 0, baseCanvas.height - displayHeight); compose( canvas, baseCanvas, torchResult, exposure, { x: clampX, y: clampY, w: displayWidth, h: displayHeight }, memory, world, memIntensity, tileImages, zoom ); } requestAnimationFrame(gameLoop); } // Controls randomSeedBtn.addEventListener('click', () => { seedInput.value = Math.random().toString(36).substr(2, 9); }); viewWInput.addEventListener('input', () => { view.w = parseInt(viewWInput.value); vwVal.textContent = view.w; }); viewHInput.addEventListener('input', () => { view.h = parseInt(viewHInput.value); vhVal.textContent = view.h; }); zoomSelect.addEventListener('change', () => { view.zoom = parseFloat(zoomSelect.value); }); qualitySelect.addEventListener('change', () => { const q = qualitySelect.value; if (q === 'low') { SUBCELL_SCALE = 3; NUM_RAYS = 900; MARCH_STEP = 0.3; } else if (q === 'medium') { SUBCELL_SCALE = 4; NUM_RAYS = 1300; MARCH_STEP = 0.25; } else if (q === 'high') { SUBCELL_SCALE = 5; NUM_RAYS = 1800; MARCH_STEP = 0.2; } }); falloffPresetSelect.addEventListener('change', () => { falloffP = parseFloat(falloffPresetSelect.value); }); exposureInput.addEventListener('input', () => { exposure = parseFloat(exposureInput.value); expVal.textContent = exposure; }); regenBtn.addEventListener('click', regenerate); respawnBtn.addEventListener('click', () => { player = findSpawn(); needsLightUpdate = true; }); exportBtn.addEventListener('click', () => { const link = document.createElement('a'); link.download = 'dungeon.png'; link.href = canvas.toDataURL(); link.click(); }); document.addEventListener('keydown', (e) => { switch (e.key) { case 'w': case 'W': case 'ArrowUp': e.preventDefault(); tryMove(0, -1); break; case 's': case 'S': case 'ArrowDown': e.preventDefault(); tryMove(0, 1); break; case 'a': case 'A': case 'ArrowLeft': e.preventDefault(); tryMove(-1, 0); break; case 'd': case 'D': case 'ArrowRight': e.preventDefault(); tryMove(1, 0); break; case 'n': case 'N': e.preventDefault(); regenerate(); break; case 'r': case 'R': e.preventDefault(); player = findSpawn(); needsLightUpdate = true; break; } }); // Init regenerate(); requestAnimationFrame(gameLoop); </script> </body> </html> ```
```html <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <title>Procedural Dungeon Torch Explorer</title> <meta name="viewport" content="width=800, initial-scale=1, shrink-to-fit=no" /> <style> html, body { background: #111; margin: 0; padding: 0; color: #eee; font: 14px sans-serif; user-select: none; } #ui { margin: 10px; font-size: 14px; } label { margin-right: 0.8em; white-space: nowrap; } canvas { background: #000; display: block; outline: 2px solid #282828; box-shadow: 0 2px 14px #000b; margin: 14px auto 8px auto; image-rendering: crisp-edges; image-rendering: pixelated; } .slider-group { margin-bottom: 4px; } #desc { max-width:700px; margin:2em auto 1em auto; color:#aaa; font-size:13px;} #howto { color: #ccc; font-size:13px; } #ui input, #ui select { margin-right:8px;} #genbtns button {margin-left:2px;} </style> </head> <body> <div id="ui"> <label>Seed: <input type="text" id="seed" size="10"></label> <label>Map: <input type="number" id="mapW" min="14" max="96" style="width:3.4em">×<input type="number" id="mapH" min="14" max="96" style="width:3.4em"></label> <label>Rooms: <input type="number" id="rooms" min="2" max="50" style="width:3em"></label> <label> Size: <input type="number" id="rmin" min="3" max="16" style="width:2.7em">–<input type="number" id="rmax" min="3" max="20" style="width:2.7em"> </label><br> <label>View: <input type="number" id="vw" min="8" max="48" style="width:2.7em">×<input type="number" id="vh" min="8" max="48" style="width:2.7em"></label> <label>Zoom: <select id="zoom"> <option value="1">1×</option> <option value="1.5">1.5×</option> <option value="2">2×</option> <option value="2.5">2.5×</option> <option value="3">3×</option> </select> </label> <label>Exposure: <input type="range" id="exposure" min="0.03" max="2.2" step="0.01" value="0.96" style="width:4em"><span id="vExp">0.96</span></label> <label> Quality: <select id="quality"> <option value="0">Low</option> <option value="1" selected>Medium</option> <option value="2">High</option> </select> </label> <label>Falloff: <select id="falloff"> <option value="1">Soft (1.0)</option> <option value="0.85">Softer (0.85)</option> <option value="1.2">Harder (1.2)</option> </select> </label> <span id="genbtns"> <button id="regen" title="Regenerate map (N)">N</button> <button id="randseed" title="Randomize + regenerate map">🎲</button> <button id="respawn" title="Safe respawn (R)">R</button> <button id="export" title="Download PNG of current view">📸</button> </span> </div> <canvas id="view" width="800" height="600"></canvas> <div id="howto"> <b>WASD / arrows</b>: move<br> <b>N</b>: regenerate map, <b>R</b>: safe respawn, <b>PNG</b>: export view<br> Closed doors: block light & movement. Step into/touch to auto-open.<br> Light: pure inverse-square, no flat region, raymarch w/corner-occluder, high res by default.<br> <b>Explore!</b> Areas lit by torch fade to memory after you leave.<br> </div> <script> // ========== // PRNGs (cyrb128/mulberry32) // ========== function cyrb128(str) { let h1 = 1779033703, h2 = 3144134277; let h3 = 1013904242, h4 = 2773480762; for(let i = 0, k; i < str.length; i++) { k = str.charCodeAt(i); h1 = h2 ^ Math.imul(h1 ^ k, 597399067); h2 = h3 ^ Math.imul(h2 ^ k, 2869860233); h3 = h4 ^ Math.imul(h3 ^ k, 951274213); h4 = h1 ^ Math.imul(h4 ^ k, 2716044179); } h1 = Math.imul(h3 ^ (h1>>>18), 597399067); h2 = Math.imul(h4 ^ (h2>>>22), 2869860233); h3 = Math.imul(h1 ^ (h3>>>17), 951274213); h4 = Math.imul(h2 ^ (h4>>>19), 2716044179); return [(h1^h2^h3^h4)>>>0, h2>>>0, h3>>>0, h4>>>0]; } function mulberry32(a) { return function() { let t = (a += 0x6d2b79f5); t = Math.imul(t ^ t>>>15, t | 1); t ^= t + Math.imul(t ^ t>>>7, t | 61); return ((t ^ t>>>14) >>> 0) / 4294967296; } } // ========== // CONSTANTS // ========== const TILE = { VOID:0, WALL:1, ROOM:2, CORRIDOR:3, DOOR:4 }; const DNAME = [" ", "#", ".", "=", "+"]; const DIRS = [ [0,-1],[1,0],[0,1],[-1,0] ]; const DIR4 = DIRS.length; const OPP = [2,3,0,1]; // opposite dirs const QUALITY=[ {S:3, rays:900, step:0.28}, {S:4, rays:1300, step:0.25}, {S:5, rays:1800, step:0.22} ]; const DOORH=0, DOORV=1; const DOOR_OPEN=1, DOOR_CLOSED=0; // For palette/tileset noise function lerp(a,b,t){return a+(b-a)*t;} function clamp(x,a,b){return Math.max(a,Math.min(b,x));} function mod(a,b){return ((a%b)+b)%b;} // ========== // HSL->sRGB, LinearSpace etc // ========== function hsl2rgbLinear(h,s,l) { // s/l=[0,1]; h=[0,1] let q=l<0.5?l*(1+s):l+s-l*s; let p=2*l-q; let rgb=[0,0,0]; for(let i=0;i<3;i++){ let t=mod(h+(i-1)/3,1); let c=(t<1/6)?p+(q-p)*6*t: (t<.5)?q: (t<2/3)?p+(q-p)*6*(2/3-t):p; rgb[i]=c; } // Convert sRGB->linear return rgb.map(s=>s<=0.04045?s/12.92:Math.pow((s+0.055)/1.055,2.4)); } function rgbLinear2srgb(rgb) { return rgb.map(l=>l<=0.0031308?l*12.92:1.055*Math.pow(l,1/2.4)-0.055); } function rgbToCss(rgb) { return `rgb(${rgb.map(x=>Math.round(clamp(x,0,1)*255)).join(",")})`; } // ========== // Palette + Tileset // ========== function buildPalette(seed) { // Deterministic, derives from seed let [s0,s1,s2,s3] = cyrb128('palette:'+seed); let prng = mulberry32(s0^s1^s2^s3); // Main H let baseH = prng()*0.8 + 0.15; // Room/corridor deltas let dRoom = (prng()-0.5)*0.16, dCorr = (prng()-0.5)*0.12; let wallH = mod(baseH+0.06,1); let roomH = mod(baseH+dRoom,1); let corrH = mod(baseH+dCorr,1); // Grays for wall/memory let wall = hsl2rgbLinear(wallH, lerp(0.08,0.28,prng()), 0.19+prng()*0.08 ); let room = hsl2rgbLinear(roomH, lerp(0.22,0.44,prng()), 0.31+prng()*0.09 ); let corridor = hsl2rgbLinear(corrH, lerp(0.22,0.34,prng()), 0.22+prng()*0.10 ); let door = hsl2rgbLinear(baseH, lerp(0.18,0.25,prng()), 0.14+prng()*0.04); let mem = [0.58,0.62,0.49]; // muted return { wall, room, corridor, door, mem }; } function buildTileset(seed, tile) { // For tile edge: stone pattern noise; door sprites vertical/horiz. // All tiles in linear sRGB. let palette = buildPalette(seed); let [s0,s1,s2,s3]=cyrb128('tileset:'+seed); let prng = mulberry32(s0^s1^s2^s3); let canv = document.createElement('canvas'); canv.width=canv.height=tile*6; let ctx=canv.getContext('2d'); let img={}; function renderGrunge(color,extra,offx=0,offy=0) { ctx.clearRect(0,0,tile,tile); ctx.save(); // base ctx.fillStyle = rgbToCss(color.map(x=>lerp(x,1,prng()*0.07+.03))); ctx.fillRect(0,0,tile,tile); // noisy grains for (let i=0;i<tile*tile*0.32;++i) { let x = prng()*tile|0, y=prng()*tile|0; ctx.fillStyle = rgbToCss(color.map(xi=>lerp(xi,1,prng()*0.2-0.07))); ctx.globalAlpha=0.12+prng()*0.09; ctx.fillRect(x,y,1,1); } ctx.globalAlpha=1; if(extra)extra(ctx,prng,offx,offy); ctx.restore(); let imgd = ctx.getImageData(0,0,tile,tile); // Convert sRGB->linear let px = new Float32Array(tile*tile*3); for (let i=0,j=0;i<imgd.data.length;i+=4,++j) { for (let c=0;c<3;++c) { px[j*3+c]=imgd.data[i+c]/255; px[j*3+c]=px[j*3+c]<=0.04045?px[j*3+c]/12.92:Math.pow((px[j*3+c]+0.055)/1.055,2.4); } } return px; } img.wall = renderGrunge(palette.wall,null); img.room = renderGrunge(palette.room,null); img.corridor = renderGrunge(palette.corridor,null); // Door closed (vertical/horiz): dark stripe, handles function drawDoor(ctx,rand,h,v) { ctx.fillStyle="#191411"; let m=tile*0.15, n=tile*0.7; if (v) ctx.fillRect(tile/2-m,2,m*2,tile-4); else ctx.fillRect(2,tile/2-m,tile-4,m*2); ctx.strokeStyle="#ffe58a"; ctx.lineWidth=1.4; if(v) ctx.strokeRect(tile/2-n/2, tile/2-1.2, n,2.4); else ctx.strokeRect(tile/2-1.2, tile/2-n/2, 2.4, n); // Handles if (v) for(let t=-1;t<=1;t+=2) ctx.beginPath(),ctx.arc(tile/2+m*t*.82,tile/2,1.5,0,6.3),ctx.fillStyle="#f7dd66",ctx.fill(); else for(let t=-1;t<=1;t+=2) ctx.beginPath(),ctx.arc(tile/2,tile/2+m*t*.82,1.5,0,6.3),ctx.fillStyle="#f7dd66",ctx.fill(); } img.doorH = renderGrunge(palette.door,(ctx,rand)=>drawDoor(ctx,rand,true,false)); img.doorV = renderGrunge(palette.door,(ctx,rand)=>drawDoor(ctx,rand,false,true)); // Door open: open to room/corridor, offset. function drawOpen(ctx,rand,h,v) { ctx.save(); ctx.globalAlpha=0.65; drawDoor(ctx,rand,h,v); ctx.restore(); // add a gap ctx.globalCompositeOperation="destination-out"; if(v) ctx.fillRect(tile/2-2,3,5,tile-6); else ctx.fillRect(3,tile/2-2,tile-6,5); ctx.globalCompositeOperation="source-over"; } img.doorOpenH = renderGrunge(palette.door,(ctx,rand)=>drawOpen(ctx,rand,true,false)); img.doorOpenV = renderGrunge(palette.door,(ctx,rand)=>drawOpen(ctx,rand,false,true)); // Player ctx.clearRect(0,0,tile,tile); ctx.save(); ctx.translate(tile/2,tile/2); ctx.fillStyle="#ef2"; ctx.beginPath(); ctx.arc(0,2,tile*0.37,0,6.3);ctx.fill(); ctx.globalAlpha=0.26; ctx.fillStyle="#ff5"; ctx.beginPath();ctx.arc(0,2,tile*0.29,0,6.3);ctx.fill(); ctx.globalAlpha=1.0; ctx.fillStyle="#a51"; ctx.beginPath();ctx.arc(0,5.3,tile*0.10,0,6.3);ctx.fill(); ctx.strokeStyle="#763";ctx.lineWidth=tile*0.17;ctx.globalAlpha=0.9; ctx.beginPath();ctx.arc(0,3.8,tile*0.035,0,6.3);ctx.stroke(); ctx.restore(); let pd = ctx.getImageData(0,0,tile,tile); let px = new Float32Array(tile*tile*3); for (let i=0,j=0; i<pd.data.length; i+=4,++j){ for (let c=0;c<3;++c) { px[j*3+c]=pd.data[i+c]/255; px[j*3+c]=px[j*3+c]<=0.04045?px[j*3+c]/12.92:Math.pow((px[j*3+c]+0.055)/1.055,2.4); } } img.player=px; // Torch ctx.clearRect(0,0,tile,tile); ctx.save(); ctx.translate(tile/2,tile/2+2); ctx.fillStyle="#a23";ctx.fillRect(-1,-7,2,7); ctx.globalAlpha=0.9; ctx.beginPath();ctx.arc(0,-7,3.5,0,6.3); ctx.fillStyle="#f1c146";ctx.fill(); ctx.globalAlpha=0.7; ctx.beginPath();ctx.arc(0,-8,4.2,0,6.3);ctx.fillStyle="#ffcd50";ctx.fill(); ctx.globalAlpha=1.0; ctx.restore(); let tdat = ctx.getImageData(0,0,tile,tile), tpx = new Float32Array(tile*tile*3); for (let i=0,j=0; i<tdat.data.length; i+=4,++j){ for (let c=0;c<3;++c) { tpx[j*3+c]=tdat.data[i+c]/255; tpx[j*3+c]=tpx[j*3+c]<=0.04045?tpx[j*3+c]/12.92:Math.pow((tpx[j*3+c]+0.055)/1.055,2.4); } } img.torch = tpx; img.palette=palette; img.tile=tile; return img; } // ========== // Dungeon Generation // ========== function generateDungeon({W,H,targetRooms,rmin,rmax,seed}) { // Seeded RNG let [s0,s1,s2,s3]=cyrb128(seed); let prng = mulberry32(s0^s1^s2^s3); let grid = new Uint8Array(W*H).fill(TILE.VOID); // Room struct: {x0,y0,x1,y1,cx,cy,doors:[]} let rooms = []; let taken = []; // main grid for overlap check for(let i=0;i<W*H;i++) taken.push(0); function rectCollide(ax0,ay0,ax1,ay1, bx0,by0,bx1,by1) { return !(ax1<=bx0 || ax0>=bx1 || ay1<=by0 || ay0>=by1); } for (let tries=0,rc=0; rc<targetRooms && tries<targetRooms*50; ++tries) { let rw = rmin+(prng()*(rmax-rmin+1))|0; let rh = rmin+(prng()*(rmax-rmin+1))|0; let rx = 3 + ((W-rw-6)*prng())|0; let ry = 3 + ((H-rh-6)*prng())|0; let x0=rx, y0=ry, x1=rx+rw, y1=ry+rh; // Padding: +1 let fail=false; for(let i=0;i<rooms.length;i++) { if (rectCollide(x0-2,y0-2,x1+2,y1+2, rooms[i].x0,rooms[i].y0,rooms[i].x1,rooms[i].y1)) {fail=true;break;} } if (fail) continue; // OK! Place room for(let y=y0; y<y1; ++y) for(let x=x0; x<x1; ++x) taken[y*W+x]=1; rooms.push({x0,y0,x1,y1, cx:((x0+x1-1)/2)|0, cy:((y0+y1-1)/2)|0, doors:[-1,-1,-1,-1]}); ++rc; } // Paint rooms rooms.forEach(r=>{ for(let y=r.y0;y<r.y1;++y) for(let x=r.x0;x<r.x1;++x) grid[y*W+x]=TILE.ROOM; }); // Room->wall ring: any VOID touching ROOM for(let y=1;y<H-1;++y) for(let x=1;x<W-1;++x) if (grid[y*W+x]===TILE.VOID && ([0,1,2,3].some(d=>{ let nx=x+DIRS[d][0], ny=y+DIRS[d][1]; return grid[ny*W+nx]===TILE.ROOM; }))) grid[y*W+x]=TILE.WALL; // --- Build connectivity graph (nodes: room centers) --- let nodes=rooms.map((r,i)=>({i,cx:r.cx, cy:r.cy, x0:r.x0, y0:r.y0, x1:r.x1, y1:r.y1})); // Build possible conn edges: (i,j, dist) let edges=[]; for(let i=0;i<nodes.length;i++) for(let j=i+1;j<nodes.length;j++) edges.push({i,j, weight:Math.hypot(nodes[i].cx-nodes[j].cx, nodes[i].cy-nodes[j].cy)}); // Kruskal's MST: let parent = n=>uf[n]===n?n:(uf[n]=parent(uf[n])); let uf=[]; for(let i=0;i<nodes.length;i++)uf[i]=i; edges.sort((a,b)=>a.weight-b.weight); let mst=[]; for(let edge of edges){ let a=parent(edge.i), b=parent(edge.j); if(a!==b) { uf[a]=b; mst.push(edge); } } // Add sparse extra edges for loops: 10-15% of non-MST edges let extraE = Math.max(1,Math.round((edges.length-mst.length)*lerp(0.11,0.17,prng()))); let extras=[]; let left = edges.filter(e=>!mst.includes(e)); for(let i=0; i<extraE && left.length>0; ++i){ let ix = (prng()*left.length)|0; extras.push(left[ix]); left.splice(ix,1); } let connEdges = mst.concat(extras); // --- Place doors --- // For each graph edge (A–B), place one door per room, on side facing the other let doors=[]; let doorTile = new Uint8Array(W*H).fill(0); let doorOpen = Array.from({length:H},()=>Array(W).fill(0)); let doorOrient = Array.from({length:H},()=>Array(W).fill(-1)); function chooseDoor(room, dir) { // 0=up,1=right,2=down,3=left // Find a wall-tile in the wall ring, in the desired dir // Keep one per side per room if (room.doors[dir]>=0) return room.doors[dir]; let x0=room.x0, y0=room.y0, x1=room.x1, y1=room.y1; let candidates=[]; switch(dir) { case 0: // up for(let x=x0;x<x1;++x) if (doorTile[(y0-1)*W+x]===0 && grid[(y0-1)*W+x]===TILE.WALL) candidates.push({x,y:y0-1}); break; case 1: // right for(let y=y0;y<y1;++y) if (doorTile[y*W+x1]===0 && grid[y*W+x1]===TILE.WALL) candidates.push({x:x1, y}); break; case 2://down for(let x=x0;x<x1;++x) if (doorTile[(y1)*W+x]===0 && grid[(y1)*W+x]===TILE.WALL) candidates.push({x,y:y1}); break; case 3://left for(let y=y0;y<y1;++y) if (doorTile[y*W+x0-1]===0 && grid[y*W+x0-1]===TILE.WALL) candidates.push({x:x0-1,y}); break; } if (!candidates.length) return null; let c = candidates[(prng()*candidates.length)|0]; doors.push({x:c.x, y:c.y, orientation:(dir%2?DOORV:DOORH), state:DOOR_CLOSED}); grid[c.y*W+c.x]=TILE.DOOR; doorTile[c.y*W+c.x]=1; doorOpen[c.y][c.x]=0; doorOrient[c.y][c.x]=dir%2?DOORV:DOORH; room.doors[dir]=doors.length-1; return doors.length-1; } for(let edge of connEdges.concat([])) { let A=rooms[edge.i], B=rooms[edge.j]; let dx = Math.sign(B.cx-A.cx), dy = Math.sign(B.cy-A.cy); let AD = dx==1?1 : dx==-1?3 : dy==1?2 : 0; let BD = dx==-1?1 : dx==1?3 : dy==-1?2 : 0; // Place door per room side; if present, reuse let a = chooseDoor(A,AD); let b = chooseDoor(B,BD); // Record edge->doors edge.doorA=a; edge.doorB=b; } // --- Carve corridors (BFS/A*) between door pairs --- // For merging, prefer stepping onto already CORRIDOR function isPassableCorr(x,y) { if (x<0||y<0||x>=W||y>=H) return false; let t=grid[y*W+x]; if (t===TILE.WALL||t===TILE.VOID) return true; if (t===TILE.CORRIDOR) return true; // Don't allow carving through rooms. return false; } function pathAstar(ax,ay,bx,by) { let open=[{x:ax,y:ay,g:0,h:Math.abs(ax-bx)+Math.abs(ay-by),prev:null}]; let seen={}; while(open.length&&open.length<1600) { open.sort((a,b)=>(a.g+a.h)-(b.g+b.h)); let cur=open.shift(); let key=cur.x+","+cur.y; if(key in seen)continue; seen[key]=cur; if(cur.x===bx&&cur.y===by) { let path=[]; while(cur.prev) path.push([cur.x,cur.y]),cur=cur.prev; path.reverse(); return path; } for(let d=0;d<DIR4;++d) { let nx=cur.x+DIRS[d][0], ny=cur.y+DIRS[d][1]; if(nx<0||ny<0||nx>=W||ny>=H) continue; let t=grid[ny*W+nx]; let cost = t===TILE.CORRIDOR?0.38:1; if(t===TILE.VOID||t===TILE.WALL||t===TILE.CORRIDOR) { if (t===TILE.WALL) { // Can only cut walls that aren't adjacent to room! let roomTouch=false; for(let dx=-1;dx<=1;dx++) for(let dy=-1;dy<=1;dy++) if(Math.abs(dx)+Math.abs(dy)==1) { let tx=nx+dx, ty=ny+dy; if(tx>=0&&ty>=0&&tx<W&&ty<H) if(grid[ty*W+tx]===TILE.ROOM) roomTouch=true; } if(roomTouch)continue; } open.push({x:nx,y:ny,g:cur.g+cost,h:Math.abs(nx-bx)+Math.abs(ny-by),prev:cur}); } } } return null; } for(let edge of connEdges) { let a = doors[edge.doorA], b=doors[edge.doorB]; let entry = [a.x,a.y], exit=[b.x,b.y]; // Move to outside of door let ao = (edge.doorA>=0)?a.orientation:-1, bo=(edge.doorB>=0)?b.orientation:-1; let ax=entry[0]+(ao===DOORH?0:ao===DOORV?0:-1), ay=entry[1]+(ao===DOORH?0:ao===DOORV?-1:0); let bx=exit[0]+(bo===DOORH?0:bo===DOORV?0:1), by=exit[1]+(bo===DOORH?0:bo===DOORV?1:0); let path = pathAstar(ax,ay,bx,by); if (path) for(let [x,y] of path) { if(grid[y*W+x]!==TILE.ROOM) grid[y*W+x]=TILE.CORRIDOR; } } // Corridor walls: any void touching corridor for(let y=1;y<H-1;++y) for(let x=1;x<W-1;++x) if (grid[y*W+x]===TILE.VOID && ([0,1,2,3].some(d=>{ let nx=x+DIRS[d][0], ny=y+DIRS[d][1]; return grid[ny*W+nx]===TILE.CORRIDOR; }))) grid[y*W+x]=TILE.WALL; // Check that every room is connected to every other let flood = (x,y,seen=new Set()) => { let q=[[x,y]]; while(q.length) { let [cx,cy]=q.pop(),k=cx+","+cy;if(seen.has(k))continue;seen.add(k); for(let d=0;d<4;++d){ let nx=cx+DIRS[d][0], ny=cy+DIRS[d][1]; if(nx<0||ny<0||nx>=W||ny>=H)continue; let t=grid[ny*W+nx]; // Passable = ROOM/CORRIDOR/DOOR if(t===TILE.ROOM||t===TILE.CORRIDOR||t===TILE.DOOR) q.push([nx,ny]); } } return seen; } let allConnected=true, proto=flood(rooms[0].cx,rooms[0].cy); for (let i=1;i<rooms.length;++i) { if(!proto.has(rooms[i].cx+","+rooms[i].cy)) allConnected=false; } return { grid, rooms, doors, doorOpen, doorOrient, W, H, connected:allConnected }; } // ========== // Safe spawn point: prefer corridor of deg>=2; else room center; else fallback // ========== function getSpawn(dungeon) { let {W,H,grid} = dungeon; let candidates=[]; for(let y=1;y<H-1;++y) for(let x=1;x<W-1;++x) if(grid[y*W+x]===TILE.CORRIDOR) { let deg=0; for(let d=0;d<4;++d) { let nx=x+DIRS[d][0], ny=y+DIRS[d][1]; if(grid[ny*W+nx]!==TILE.WALL&&grid[ny*W+nx]!==TILE.VOID) deg++; } if(deg>=2) candidates.push({x,y}); } if(candidates.length) return candidates[(Math.random()*candidates.length)|0]; // fallback to room center if(dungeon.rooms.length>0) return {x:dungeon.rooms[0].cx, y:dungeon.rooms[0].cy}; // fallback for(let y=1;y<H-1;++y) for(let x=1;x<W-1;++x) if(grid[y*W+x]===TILE.CORRIDOR||grid[y*W+x]===TILE.ROOM) return {x,y}; return {x:1,y:1}; } // ========== // Base Map Render // ========== function renderBaseToCanvas(dungeon, tiles, tile) { let {W,H,grid} = dungeon; let cv = document.createElement('canvas'); cv.width=W*tile; cv.height=H*tile; // Compose as RGB32F (linear!) let px = new Float32Array(W*H*tile*tile*3); let ctx = cv.getContext('2d'); for(let y=0;y<H;++y) for(let x=0;x<W;++x) { let o=(y*W+x)*(tile*tile*3); let src; switch(grid[y*W+x]) { case TILE.WALL: src=tiles.wall; break; case TILE.ROOM: src=tiles.room; break; case TILE.CORRIDOR: src=tiles.corridor; break; default: src=null; } if(!src) continue; for(let j=0;j<tile;++j) for(let i=0;i<tile;++i) { let ti=j*tile+i; for(let c=0;c<3;++c) px[o+ti*3+c]=src[ti*3+c]; } } // For PNG saves, render also to canvas: let id = ctx.createImageData(W*tile,H*tile); for(let i=0;i<px.length/3;++i){ let rgb = rgbLinear2srgb([px[i*3],px[i*3+1],px[i*3+2]]); id.data[i*4+0]=rgb[0]*255; id.data[i*4+1]=rgb[1]*255; id.data[i*4+2]=rgb[2]*255; id.data[i*4+3]=255; } ctx.putImageData(id,0,0); return {canvas:cv,linear:px}; } // ========== // Torch LOS + Physically Accurate Light // ========== function solveTorch(dungeon, player, opts) { // Ray-marched LOS (subcell SxS grid), corner occluder, blocking walls/doors // opts: {tile, S, rays, step, radiusTiles, p, eps} let {W,H,grid,doorOpen} = dungeon; let S=opts.S, rays=opts.rays, step=opts.step, radius=opts.radiusTiles, p=opts.p, eps=opts.eps; let scrW=W*S, scrH=H*S; let buff = new Float32Array(scrW*scrH); let seenMask = new Uint8Array(W*H); // Torch pos in world coords (subtile) let ox = (player.x+0.5)*S, oy=(player.y+0.5)*S; let PI2 = Math.PI*2; let dirs = []; for(let i=0;i<rays;++i){ let a=i/rays*PI2; dirs.push([Math.cos(a),Math.sin(a)]); } let isBlocking = (x,y) => { if(x<0||y<0||x>=W||y>=H) return true; let t=grid[y*W+x]; if(t===TILE.WALL||t===TILE.VOID) return true; if(t===TILE.DOOR && !doorOpen[y][x]) return true; return false; }; let mark = (sx,sy,ld) => { if(sx<0||sy<0||sx>=scrW||sy>=scrH)return; buff[sy*scrW+sx]=Math.max(buff[sy*scrW+sx],ld); // Also mark whole tile as seen let tx=(sx/S)|0, ty=(sy/S)|0; if(tx>=0&&ty>=0&&tx<W&&ty<H) seenMask[ty*W+tx]=1; }; for(let i=0;i<rays;++i){ let dx=dirs[i][0], dy=dirs[i][1]; let l=0, limit=radius*S; let px=ox, py=oy; let txprev=(px/S)|0, typrev=(py/S)|0; let stopped=false; while(l<=limit && !stopped){ let d = l/S; let li = Math.pow(1/(d*d + eps*eps),p); // lose if we hit edge let sx=Math.round(px), sy=Math.round(py); mark(sx,sy,li); l+=step*S; let nx=px+dx*step*S, ny=py+dy*step*S; let tx=(nx/S)|0, ty=(ny/S)|0; // Check for blocking/corner if( isBlocking(tx,ty)) break; let dxcell = tx-txprev, dycell=ty-typrev; if(Math.abs(dxcell)+Math.abs(dycell)==2) if (isBlocking(tx,typrev)&&isBlocking(txprev,ty)) break; // corner occluder px=nx; py=ny; txprev=tx;typrev=ty; } } return { samplePix: function(px,py) { if(px<0||py<0||px>=scrW||py>=scrH)return 0.0; return buff[py*scrW+px]; }, seenMask }; } // ========== // Compose final lighting + view // ========== function compose(viewCanvas, base, baseLinear, lightSampler, exposure, region, memory, dungeon, tile, memIntensity, tiles, zoom, player, opts) { // region {px,py,w,h} in tile let ctx = viewCanvas.getContext('2d'); ctx.setTransform(1,0,0,1,0,0); ctx.clearRect(0,0,viewCanvas.width,viewCanvas.height); let S=opts.S; // Paint lit region, then overlay doors/player/sprites let {W,H,grid,doorOpen,doorOrient} = dungeon; let pal = tiles.palette; let tw=tile,th=tile; let buff = ctx.createImageData(region.w*tw,region.h*th); let sub = S; for(let ty=0;ty<region.h;++ty) for(let tx=0;tx<region.w;++tx) { let gx=region.px+tx, gy=region.py+ty; // For each tile: average subpixel lights let baseo = ((gy*W+gx)*(tw*th*3)); for(let py=0;py<th;++py) for(let px=0;px<tw;++px){ let outid = ((ty*th+py)*region.w*tw + tx*tw + px); // World px let spx = ((gx*tw+px)/tw*S)|0, spy = ((gy*th+py)/th*S)|0; let l = lightSampler.samplePix(spx,spy); let mem = memory[gy*W+gx]||0; let rgb = [0,0,0]; if ((gx<0||gy<0||gx>=W||gy>=H)||grid[gy*W+gx]===TILE.VOID) rgb=[0,0,0]; else { // baseLinear = Float32 RGB let basex = baseLinear[baseo + (py*tw+px)*3 + 0]; let basey = baseLinear[baseo + (py*tw+px)*3 + 1]; let basez = baseLinear[baseo + (py*tw+px)*3 + 2]; for(let c=0;c<3;++c) rgb[c] = basex*exposure*l + memIntensity*mem*pal.mem[c]; } let srgb = rgbLinear2srgb(rgb); buff.data[outid*4+0]=clamp(srgb[0],0,1)*255; buff.data[outid*4+1]=clamp(srgb[1],0,1)*255; buff.data[outid*4+2]=clamp(srgb[2],0,1)*255; buff.data[outid*4+3]=255; } } // Draw base layer ctx.putImageData(buff,0,0); // Overlay doors, player, torch if (tile is lit or memory>e) let eps=1/255; for(let ty=0;ty<region.h;++ty) for(let tx=0;tx<region.w;++tx) { let gx=region.px+tx, gy=region.py+ty; if(gx<0||gy<0||gx>=W||gy>=H) continue; let tiletype = grid[gy*W+gx]; let mem = memory[gy*W+gx]; let subx = ((gx*tw)), suby=((gy*th)); // If this tile is lit or in memory let vis = false; // estimate: if any of SxS lightSampler[sx,sy]>0.015 for(let sy=0;sy<S&&!vis;++sy) for(let sx=0; sx<S&&!vis; ++sx) if(lightSampler.samplePix(gx*S+sx,gy*S+sy)>0.015) vis=true; if(mem>eps) vis=true; if(tiletype===TILE.DOOR&&vis) { let open = dungeon.doorOpen[gy][gx]; let orient = dungeon.doorOrient[gy][gx]; let img; if (open){ img = orient===DOORH?tiles.doorOpenH:tiles.doorOpenV; } else { img = orient===DOORH?tiles.doorH:tiles.doorV; } // Blit from float32 image let imdata = ctx.createImageData(tw,th); for(let i=0; i<tw*th; ++i) { let rgb = rgbLinear2srgb([img[i*3],img[i*3+1],img[i*3+2]]); imdata.data[i*4+0]=rgb[0]*255; imdata.data[i*4+1]=rgb[1]*255; imdata.data[i*4+2]=rgb[2]*255; imdata.data[i*4+3]=255; } ctx.putImageData(imdata, subx-region.px*tw, suby-region.py*th); } // Player if(player.x===gx&&player.y===gy && vis) { let img = tiles.player; let imdata = ctx.createImageData(tw,th); for(let i=0; i<tw*th; ++i) { let rgb = rgbLinear2srgb([img[i*3],img[i*3+1],img[i*3+2]]); imdata.data[i*4+0]=rgb[0]*255; imdata.data[i*4+1]=rgb[1]*255; imdata.data[i*4+2]=rgb[2]*255; imdata.data[i*4+3]=255; } ctx.putImageData(imdata, subx-region.px*tw, suby-region.py*th); // Torch icon let imgT = tiles.torch; let imdataT = ctx.createImageData(tw,th); for(let i=0; i<tw*th; ++i) { let rgb = rgbLinear2srgb([imgT[i*3],imgT[i*3+1],imgT[i*3+2]]); imdataT.data[i*4+0]=rgb[0]*255; imdataT.data[i*4+1]=rgb[1]*255; imdataT.data[i*4+2]=rgb[2]*255; imdataT.data[i*4+3]=180; } ctx.putImageData(imdataT, subx-region.px*tw, suby-region.py*th); } } // Scale/crop as view window ctx.save(); ctx.setTransform(zoom,0,0,zoom,0,0); } // ========== // Door ops // ========== function openDoorAt(dungeon, x, y) { if(x<0||y<0||x>=dungeon.W||y>=dungeon.H) return false; if(dungeon.grid[y*dungeon.W+x]!==TILE.DOOR) return false; if(dungeon.doorOpen[y][x]) return false; dungeon.doorOpen[y][x]=1; // Also set state in doors[] for(let d of dungeon.doors) if(d.x===x&&d.y===y)d.state=1; return true; } function openAdjacentDoors(dungeon, px,py) { for(let d=0;d<4;++d){ let nx=px+DIRS[d][0], ny=py+DIRS[d][1]; if(nx<0||ny<0||nx>=dungeon.W||ny>=dungeon.H) continue; let t=dungeon.grid[ny*dungeon.W+nx]; if(t===TILE.DOOR && !dungeon.doorOpen[ny][nx]) openDoorAt(dungeon, nx,ny); } } // ========== // Memory buffer, decay // ========== function createMemory(W,H) { return new Float32Array(W*H); } function decayMemory(mem,dt,halfLife) { let decayFactor = Math.pow(0.5, dt/halfLife); for(let i=0;i<mem.length;++i) mem[i]*=decayFactor; } // Apply new visible seenMask (1=newly seen, 0=no change) function updateMemory(mem, seenMask) { for(let i=0;i<mem.length;++i) if(seenMask[i]) mem[i]=1.0; } // ========================================= // UI/MAIN LOGIC: Camera, controls // ========================================= const DEF = { seed: "dungeonseed", mapW: 42, mapH: 32, rooms: 8, rmin: 5, rmax: 10, vw: 24, vh: 17, tile: 24, zoom: 2, exposure: 0.96, quality: 1, // index: 0=low,1=med,2=high falloff: 1.0, // p exponent memIntensity:0.09, memHalfLife: 20, // seconds }; let params = Object.assign({},DEF); let viewCanvas = document.getElementById('view'); let memory, dungeon, tiles, base, player, lastMove=0, camera={x:0,y:0}; let lightSol; let animReq; let updating=false; let region={px:0,py:0,w:0,h:0}; function regenerate(seed) { updating=true; // Read UI for (let k in DEF) if (document.getElementById(k)) { let v=document.getElementById(k).value; if (typeof DEF[k]==="number") params[k]=Number(v); else params[k]=v||DEF[k]; } params.rmin = Math.max(3,Math.min(params.rmin,params.rmax)); params.mapW = Math.max(14, Math.min(96, params.mapW)); params.mapH = Math.max(14, Math.min(96, params.mapH)); params.rooms = Math.max(2, Math.min(50, params.rooms)); params.tile = Math.max(12, Math.min(36, params.tile)); document.getElementById('seed').value=params.seed; // Dungeon gen dungeon = generateDungeon({ W: params.mapW, H: params.mapH, targetRooms: params.rooms, rmin: params.rmin, rmax: params.rmax, seed: params.seed }); // Tileset tiles=buildTileset(params.seed, params.tile); // Pre-render base base=renderBaseToCanvas(dungeon, tiles, params.tile); // Memory buffer memory=createMemory(params.mapW, params.mapH); // Player spawn player=getSpawn(dungeon); // Clamp camera camera.x=player.x; camera.y=player.y; // Init doors dungeon.doors.forEach(d=>{dungeon.doorOpen[d.y][d.x]=d.state;}); updateTorch(); updating=false; } function updateTorch() { let q=QUALITY[params.quality]; let halfVw = 0.5*params.vw, halfVh = 0.5*params.vh; let rad = Math.sqrt(halfVw*halfVw + halfVh*halfVh) + 4; // Always solve over the whole map for memory, but region is camera let opts = {tile:params.tile, S:q.S, rays:q.rays, step:q.step, radiusTiles:rad, p:params.falloff, eps:0.1}; lightSol = solveTorch(dungeon, player, opts); updateMemory(memory, lightSol.seenMask); redraw(); } function redraw() { let q=QUALITY[params.quality]; let sceneW=params.vw, sceneH=params.vh; let tw=params.tile, th=params.tile; // Clamp camera: camera.x=clamp(camera.x,Math.floor(sceneW/2),dungeon.W-Math.ceil(sceneW/2)); camera.y=clamp(camera.y,Math.floor(sceneH/2),dungeon.H-Math.ceil(sceneH/2)); // Calculate camera region let px=Math.round(camera.x-sceneW/2), py=Math.round(camera.y-sceneH/2); px=clamp(px,0,Math.max(0,dungeon.W-sceneW)); py=clamp(py,0,Math.max(0,dungeon.H-sceneH)); region={px,py,w:sceneW,h:sceneH}; // Compose scene compose(viewCanvas, base.canvas, base.linear, lightSol, params.exposure, region, memory, dungeon, params.tile, params.memIntensity, tiles, params.zoom, player, q); } // Player movement & controls function move(dx,dy) { let nx=player.x+dx, ny=player.y+dy; if(nx<0||ny<0||nx>=dungeon.W||ny>=dungeon.H)return false; let t=dungeon.grid[ny*dungeon.W+nx]; if(t===TILE.WALL||t===TILE.VOID) return false; if(t===TILE.DOOR && !dungeon.doorOpen[ny][nx]) { openDoorAt(dungeon,nx,ny); player.x=nx; player.y=ny; } else if (t===TILE.DOOR && dungeon.doorOpen[ny][nx]) { player.x=nx; player.y=ny; } else if(t===TILE.ROOM||t===TILE.CORRIDOR) { player.x=nx; player.y=ny; } else return false; openAdjacentDoors(dungeon, player.x, player.y); camera.x=player.x; camera.y=player.y; updateTorch(); return true; } // Animation: memory decay let last = performance.now()/1000; function loop() { let now=performance.now()/1000, dt=now-last; last=now; decayMemory(memory, dt, params.memHalfLife); redraw(); animReq = requestAnimationFrame(loop); } // ========== // UI event hooks & page UI // ========== function setupUI() { // Inputs for(let k in DEF) if(document.getElementById(k)) document.getElementById(k).value=DEF[k]; document.getElementById('vExp').innerText=params.exposure.toFixed(2); // Exposure slider document.getElementById('exposure').addEventListener('input',e=>{ params.exposure = Number(e.target.value); document.getElementById('vExp').innerText=params.exposure.toFixed(2); redraw(); }); // Map resize/regen ["seed","mapW","mapH","rooms","rmin","rmax","vw","vh", "zoom","quality","falloff"].forEach(k=>{ if(document.getElementById(k)) document.getElementById(k).addEventListener('change',e=>{ params[k]=(typeof DEF[k]==="number")?Number(e.target.value):e.target.value; if(k==="zoom") params.zoom=Number(document.getElementById('zoom').value); regenerate(params.seed); }); }); // Regen btns document.getElementById('regen').onclick=()=>regenerate(params.seed); document.getElementById('randseed').onclick=()=>{ params.seed = Math.random().toString(36).slice(2,10); document.getElementById('seed').value=params.seed; regenerate(params.seed); }; document.getElementById('respawn').onclick=()=>{ player=getSpawn(dungeon); camera.x=player.x; camera.y=player.y; updateTorch(); }; // Export PNG document.getElementById('export').onclick=()=>{ // Capture only view window (not full base) let pngcanvas=document.createElement('canvas'); let w=region.w*params.tile, h=region.h*params.tile; pngcanvas.width=w; pngcanvas.height=h; let srcctx = viewCanvas.getContext('2d'); let data=srcctx.getImageData(0,0,w,h); let dst=pngcanvas.getContext('2d'); dst.putImageData(data,0,0); let link=document.createElement('a'); link.download = `dungeon_${params.seed}_${player.x}_${player.y}.png`; link.href=pngcanvas.toDataURL('image/png'); link.click(); }; // Keyboard controls (WASD/Arrows) window.addEventListener('keydown',e=>{ if(updating)return; let k = e.key.toLowerCase(); let dx=0,dy=0; if (k==='arrowup'||k==='w') dy=-1; else if(k==='arrowdown'||k==='s')dy=1; else if(k==='arrowleft'||k==='a')dx=-1; else if(k==='arrowright'||k==='d')dx=1; else if(k==='n'){regenerate(params.seed); return;} else if(k==='r'){player=getSpawn(dungeon); camera.x=player.x; camera.y=player.y; updateTorch();return;} if(dx||dy) { move(dx,dy); e.preventDefault(); } }); viewCanvas.tabIndex=0; // focus viewCanvas.addEventListener('click',()=>viewCanvas.focus()); } window.onload = function () { setupUI(); regenerate(params.seed); if(animReq) cancelAnimationFrame(animReq); last=performance.now()/1000; animReq = requestAnimationFrame(loop); }; </script> </body> </html> ``` **Features/Controls:** - **WASD/Arrow keys**: Move. Touch/step into closed doors to open. - **N**: Regenerate map (using current params/seed). - **R**: Respawn at safe spawn. - **🎲**: Randomize seed & regenerate. - **PNG 📸**: Export current viewport as PNG. - **UI controls top**: All map/dungeon/render/lighting options. **Torch Light:** - Pure inverse-square, properly raymarched at subcell scale with no flat region. - Memory fog decays (tunable half-life), area remains faint after you leave. - No diagonal light leaks (corner-occluder). - Larger viewports/zoom/quality possible via settings. **Everything is deterministic per seed.** **No external libraries.**
```html <!DOCTYPE html> <html> <head> <title>Procedural Dungeon</title> <style> body { margin:0; display:flex; flex-direction:column; background:#000; color:#fff; font-family:sans-serif; } #ui { padding:8px; background:#222; display:flex; flex-wrap:wrap; gap:4px; } label { font-size:12px; } input[type=text],input[type=number],select { width:60px; font-size:12px; } canvas { display:block; margin:auto; } </style> </head> <body> <div id="ui"> <label>Seed:<input id="seed" type="text" value="dungeon1"></label> <label>WxH:<input id="w" type="number" value="40"><input id="h" type="number" value="40"></label> <label>Rooms:<input id="rooms" type="number" value="10"></label> <label>Room Min/Max:<input id="rmin" type="number" value="3"><input id="rmax" type="number" value="6"></label> <select id="quality"><option value="3">Low</option><option value="4" selected>Med</option><option value="5">High</option></select> <button onclick="generate()">Generate</button> </div> <canvas id="canvas"></canvas> <script> const tileTypes = { VOID:0, WALL:1, ROOM:2, CORRIDOR:3, DOOR:4 }; const dir4 = [[0,-1],[-1,0],[0,1],[1,0]]; function cyrb128(str) { let h1 = 1779033703, h2 = 3144134277, h3 = 1013904242, h4 = 277348076; for (let i=0; i<str.length; i++) { h1 += h2; h1 = (h1 & 0xFFFFFFFF) + h2 * 9 | 0; h2 = (h2 >>> 14) + h2 * 15 | 0; h3 ^= h4; h4 = h3 * 23 | 0; h3 ^= str.charCodeAt(i); h4 = h3 ^ h4; } return [h1>>>0, h2>>>0, h3>>>0, h4>>>0]; } function mulberry32(a) { return () => { let t = a += 0x6D2B79F5; t = Math.imul(t ^ t >>> 15, t | 1); t ^= t + Math.mul(t ^ t >>> 7, t | 61); return (t >>> 0) / 4294967296; } } function range(rmin, rmax, rng) { return Math.floor(rng()*(rmax-rmin+1)) + rmin; } class Dungeon { constructor(W, H) { this.W = W; this.H = H; this.grid = new Uint8Array(W*H); this.rooms = []; this.doors = []; this.doorOpen = new Array(H).fill(0).map(() => new Array(W).fill(0)); this.doorOrient = new Array(H).fill(0).map(() => new Array(W).fill(0)); } idx(x, y) { return x + y * this.W; } get(x, y) { return this.grid[x + y * this.W]; } set(x, y, t) { this.grid[x + y * this.W] = t; } generate(rng) { const padding = 2; let attempts = 0; const targetRooms = +document.getElementById("rooms").value; const rmin = +document.getElementById("rmin").value; const rmax = +document.getElementById("rmax").value; // Place rooms while (this.rooms.length < targetRooms && attempts++ < 1000) { const w = range(rmin, rmax, rng); const h = range(rmin, rmax, rng); const x = range(1, this.W - w - 1, rng); const y = range(1, this.H - h - 1, rng); const rect = {x, y, w, h, cx: x+w/2|0, cy: y+h/2|0}; let overlap = false; for (const r of this.rooms) { if (!(rect.x + padding >= r.x + r.w || rect.x + rect.w - padding <= r.x || rect.y + padding >= r.y + r.h || rect.y + rect.h - padding <= r.y)) { overlap = true; break; } } if (!overlap) { this.rooms.push(rect); for (let iy=rect.y; iy<rect.y+rect.h; iy++) for (let ix=rect.x; ix<rect.x+rect.w; ix++) this.set(ix, iy, tileTypes.ROOM); } } if (this.rooms.length < 2) return false; // Walls around rooms for (let y=0; y<this.H; y++) for (let x=0; x<this.W; x++) { if (this.get(x, y) !== tileTypes.ROOM) continue; for (const [dx, dy] of dir4) { const nx = x+dx, ny = y+dy; if (nx>=0 && ny>=0 && nx<this.W && ny<this.H && this.get(nx, ny) === tileTypes.VOID) this.set(nx, ny, tileTypes.WALL); } } // MST connectivity const edges = []; for (let i=0; i<this.rooms.length; i++) for (let j=i+1; j<this.rooms.length; j++) { const dx = this.rooms[i].cx - this.rooms[j].cx; const dy = this.rooms[i].cy - this.rooms[j].cy; edges.push({i, j, dist: dx*dx + dy*dy}); } edges.sort((a,b) => a.dist - b.dist); const parent = [...Array(this.rooms.length).keys()]; function find(x) { return parent[x] === x ? x : find(parent[x]); } function union(x, y) { parent[find(x)] = find(y); } const mst = []; for (const e of edges) { if (find(e.i) !== find(e.j)) { union(e.i, e.j); mst.push(e); } } const extraEdges = edges.filter(e => !mst.some(me => (me.i == e.i && me.j == e.j) || (me.i == e.j && me.j == e.i)) ).sort(() => rng() - 0.5).slice(0, edges.length * 0.15); // Place doors const dir4xy = [[0,-1], [-1,0], [0,1], [1,0]]; for (const {i,j} of [...mst, ...extraEdges]) { const r1 = this.rooms[i], r2 = this.rooms[j]; const dir = [r2.cx - r1.cx, r2.cy - r1.cy]; const dirNorm = dir.map(v => v ? Math.sign(v) : 0); // Get facing direction for each room let [dx1, dy1] = dir4xy.find(([dx, dy]) => { const face = dx * dirNorm[0] + dy * dirNorm[1]; return face === -1; }) || [0,0]; let [dx2, dy2] = dir4xy.find(([dx, dy]) => { const face = dx * -dirNorm[0] + dy * -dirNorm[1]; return face === -1; }) || [0,0]; // Existing doors for this orientation in room1 let room1Doors = this.doors.filter(d => { const dr = dir4xy.find(([dx, dy]) => { const face = dx * dx1 + dy * dy1; return face === -1; }); return d.x + dx1 == r1.x + (d.orient == 'V' ? 0 : r1.w/2) && d.y + dy1 == r1.y + (d.orient == 'H' ? 0 : r1.h/2); }); let door1; if (room1Doors.length) door1 = room1Doors[0]; else { const dox = r1.x + dx1*(r1.w/2); const doy = r1.y + dy1*(r1.h/2); // Check existing doors nearby (prevent multiple per side) door1 = this.doors.find(d => (d.x - dox) + (d.y - doy) == 0) || {x: dox, y: doy, orient: dx1 != 0 ? 'H' : 'V', open: true}; this.doors.push(door1); this.set(door1.x, door1.y, tileTypes.DOOR); } // Similarly for room 2 let door2; const room2Doors = this.doors.filter(d => { const dr = dir4xy.find(([dx, dy]) => { const face = dx * dx2 + dy * dy2; return face === -1; }); return d.x + dx2 == r2.x + (d.orient == 'V' ? 0 : r2.w/2) && d.y + dy2 == r2.y + (d.orient == 'H' ? 0 : r2.h/2); }); if (room2Doors.length) door2 = room2Doors[0]; else { const dox = r2.x + dx2*(r2.w/2); const doy = r2.y + dy2*(r2.h/2); door2 = this.doors.find(d => (d.x - dox) + (d.y - doy) == 0) || {x: dox, y: doy, orient: dx2 != 0 ? 'H' : 'V', open: true}; this.doors.push(door2); this.set(door2.x, door2.y, tileTypes.DOOR); } } // Corridor carving between doors using BFS const corridorSteps = 0.25; function bresenhamPath(start, end) { const path = []; let x0 = start[0], y0 = start[1]; let x1 = end[0], y1 = end[1]; const dx = Math.abs(x1 - x0), dy = Math.abs(y1 - y0); const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1; let err = dx - dy; while (true) { path.push([x0, y0]); if (x0 === x1 && y0 === y1) break; const e2 = 2*err; if (e2 > -dy) {err -= dy; x0 += sx;} if (e2 < dx) {err += dx; y0 += sy;} } return path; } for (const {i,j} of [...mst, ...extraEdges]) { const r1 = this.rooms[i], r2 = this.rooms[j]; const door1 = this.doors.find(d => { const dx = d.x - r1.cx, dy = d.y - r1.cy; return dx && Math.abs(dx) >= r1.w/2 || dy && Math.abs(dy) >= r1.h/2; }); const door2 = this.doors.find(d => { const dx = d.x - r2.cx, dy = d.y - r2.cy; return dx && Math.abs(dx) >= r2.w/2 || dy && Math.abs(dy) >= r2.h/2; }); if (!door1 || !door2) continue; const path = bresenhamPath([door1.x, door1.y], [door2.x, door2.y]); for (const [x,y] of path) { if (x<0 || y<0 || x>=this.W || y>=this.H) continue; const cell = this.get(x,y); if (cell === tileTypes.VOID || cell === tileTypes.DOOR) { this.set(x,y, tileTypes.CORRIDOR); // Add walls after for (const [dx, dy] of dir4) { const nx = x+dx, ny = y+dy; if (nx>=0 && ny>=0 && nx<this.W && ny<this.H && this.get(nx, ny) === tileTypes.VOID) this.set(nx, ny, tileTypes.WALL); } } } } return true; } } // Main state let canvas, ctx, state = { tile: 32, zoom: 1, seed: "dungeon1", S: 4, rays: 1300, radiusTiles: 20, p: 1.0, eps: 0.1, exposure: 2.0, memIntensity: 0.08, halfLife: 20*1000, memory: new Float32Array(40*40), playerPos: [0,0] }; function resetMemory() { state.memory.fill(0); } function generate() { const W = +document.getElementById("w").value; const H = +document.getElementById("h").value; state.seed = document.getElementById("seed").value; const seed = state.seed; const rng = mulberry32(cyrb128(seed).reduce((acc, val) => acc + val, 0) & 0xFFFFFFFF); const dungeon = new Dungeon(W, H); while (!dungeon.generate(rng)) { // Regenerate if rooms failed } state.dungeon = dungeon; state.memory = new Float32Array(W*H); resetMemory(); // Find spawn position for (let y=0; y<H; y++) for (let x=0; x<W; x++) { const type = dungeon.get(x, y); if ((type === tileTypes.CORRIDOR) || (type === tileTypes.ROOM)) { let passableNeigh = 0; dir4.forEach(([dx, dy]) => { const type = dungeon.get(x+dx, y+dy); if (type === tileTypes.CORRIDOR || (type === tileTypes.DOOR && dungeon.doorOpen[y+dy]?.[x+dx])) { passableNeigh++; } }); if (passableNeigh >= 2) { state.playerPos = [x, y]; return; } } } for (let room of dungeon.rooms) { state.playerPos = [room.x + room.w/2, room.y + room.h/2]; break; } } function drawBase() { const dungeon = state.dungeon; const W = dungeon.W, H = dungeon.H; const canvas = document.createElement("canvas"); canvas.width = W * state.tile; canvas.height = H * state.tile; const ctx = canvas.getContext('2d'); // Generate procedural colors from seed const colorRng = mulberry32(cyrb128(state.seed).reduce((acc, val) => acc + val, 0) & 0xFFFFFFFF); const roomHue = 20 + Math.floor(colorRng()*10)%360; const corridorHue = 120 + Math.floor(colorRng()*90)%360; function fillTile(x, y, r, g, b) { ctx.fillStyle = `rgb(${r},${g},${b})`; ctx.fillRect(x*state.tile, y*state.tile, state.tile, state.tile); } for (let y=0; y<H; y++) { for (let x=0; x<W; x++) { const type = dungeon.get(x, y); switch(type) { case tileTypes.ROOM: fillTile(x, y, Math.floor(30 + 10*colorRng()), Math.floor(30 + 10*colorRng()), Math.floor(40 + 10*colorRng()) ); break; case tileTypes.CORRIDOR: fillTile(x, y, Math.floor(20 + 10*colorRng()), Math.floor(20 + 10*colorRng()), Math.floor(20 + 10*colorRng()) ); break; case tileTypes.WALL: fillTile(x, y, Math.floor(50 + 20*colorRng()), Math.floor(50 + 20*colorRng()), Math.floor(50 + 20*colorRng()) ); break; case tileTypes.DOOR: fillTile(x, y, Math.floor(100 + 30*colorRng()), Math.floor(80 + 20*colorRng()), Math.floor(60 + 20*colorRng()) ); break; default: fillTile(x, y, 0, 0, 0); } } } // Doors for (const door of dungeon.doors) { const x = door.x, y = door.y; if (dungeon.doorOpen[y]?.[x]) { ctx.strokeStyle = "#00ff00"; ctx.strokeRect(x*state.tile, y*state.tile, state.tile, state.tile); } else { ctx.fillStyle = "#00ffff"; ctx.fillRect(x*state.tile, y*state.tile, state.tile, state.tile); } } return canvas; } function solveTorch(player) { const dungeon = state.dungeon; const W = dungeon.W, H = dungeon.H; const tileSize = state.tile; const S = state.S; const rays = state.rays; const radius = state.radiusTiles * tileSize; const grid = new Uint8Array(S * tileSize * S * tileSize * W * H); // Calculate lighting mask let seen = new Uint8Array(dungeon.W * dungeon.H); // Cast rays const torchX = player[0] + 0.5, torchY = player[1] + 0.25; const ox = torchX * tileSize; const oy = torchY * tileSize; const samples = 5*S; const lightBuffer = new Float32Array(W*S, H*S); for (let r=0; r<rays; r++) { const a = (r / rays) * 2 * Math.PI; const dx = Math.cos(a), dy = Math.sin(a); let x = ox, y = oy; let d = 0; let lastX = -1, lastY = -1; while (d < radius) { d += 1/S; x = ox + dx * d * tileSize; y = oy + dy * d * tileSize; let sx = Math.floor(x / tileSize), sy = Math.floor(y / tileSize); if (sx < 0 || sy < 0 || sx >= W || sy >= H) break; let tx = Math.floor(x * S), ty = Math.floor(y * S); const tile = dungeon.get(sx, sy); const door = dungeon.doors.some(d => d.x == sx && d.y == sy && !d.open); if (tile === tileTypes.WALL || (door)) break; // Corner occlusion check if (lastX !== -1) { const nx = Math.floor((x) * S), ny = Math.floor((y) * S); if ((nx !== lastX || ny !== lastY) && dx*nx*dy != 0) { // Check corner occlusion const bx = Math.floor((x - dx * tileSize/S) / tileSize), by = Math.floor((y - dy * tileSize/S) / tileSize); const dirX = sx != bx ? [-1, 0] : [0, sy != by ? -1 : 0]; if (dungeon.get(sx + dirX[0], sy + dirX[1]) > tileTypes.VOID && dungeon.get(sx + dirX[0], sy + dirX[1]) > tileTypes.VOID) { break; } } } lastX = tx; lastY = ty; // Calculate light intensity const dist = d; const intensity = Math.pow(1 / (dist*dist + state.eps), state.p); lightBuffer[tx + ty * W*S] = Math.max(intensity, lightBuffer[tx + ty * W*S]); // Mark memory seen[sx + sy * W] = 1; } } return { samplePix: (x, y) => { const sx = Math.floor(x * S), sy = Math.floor(y * S); return lightBuffer[sx + sy * W*S]; }, seen }; } function updateMemory(seenMask) { for (let i=0; i<state.memory.length; i++) { state.memory[i] *= Math.pow(0.5, delta / state.halfLife); } for (let x=0; x<state.dungeon.W; x++) { for (let y=0; y<state.dungeon.H; y++) { const idx = x + y * state.dungeon.W; if (seenMask?.(x, y)) state.memory[idx] = 1; } } } let prevTime = 0, delta = 0; function animate(time) { delta = time - prevTime; prevTime = time; if (delta > 100) delta = 100; updateMemory(state.seenMask); // Clear canvas canvas.width = window.innerHeight * 0.6; canvas.height = window.innerHeight * 0.6; const vw = Math.floor(canvas.width / state.tile / state.zoom); const vh = Math.floor(canvas.height / state.tile / state.zoom); // Draw view const baseCanvas = drawBase(); const baseCtx = baseCanvas.getContext('2d'); const torchCanvas = document.createElement("canvas"); torchCanvas.width = canvas.width; torchCanvas.height = canvas.height; const torchCtx = torchCanvas.getContext('2d'); // Composite lighting const offsetX = Math.max(0, state.playerPos[0] - vw/2); const offsetY = Math.max(0, state.playerPos[1] - vh/2); for (let y=0; y<vh; y++) { for (let x=0; x<vw; x++) { const gx = offsetX + x, gy = offsetY + y; if (gx < 0 || gy < 0 || gx >= state.dungeon.W || gy >= state.dungeon.H) continue; const color = baseCtx.getImageData(gx*state.tile, gy*state.tile, 1, 1).data; let light = state.lightSampler?.samplePix(gx, gy) || 0; light = Math.min(1, light * state.exposure); const r = Math.min(255, Math.floor(color[0] * light)); const g = Math.min(255, Math.floor(color[1] * light)); const b = Math.min(255, Math.floor(color[2] * light)); torchCtx.fillStyle = `rgb(${r},${g},${b})`; torchCtx.fillRect(x*state.tile*state.zoom, y*state.tile*state.zoom, state.tile*state.zoom, state.tile*state.zoom); } } // Draw player const px = state.playerPos[0], py = state.playerPos[1]; ctx.fillStyle = "#00ff00"; ctx.fillRect((px-offsetX)*state.tile*state.zoom, (py-offsetY)*state.tile*state.zoom, state.tile*state.zoom, state.tile*state.zoom); // Draw doors for (const door of state.dungeon.doors) { const dx = door.x, dy = door.y; if (state.dungeon.doorOpen[dy][dx]) ctx.fillStyle = "#00ffff"; else ctx.fillStyle = "#ff0000"; ctx.fillRect((dx-offsetX)*state.tile*state.zoom, (dy-offsetY)*state.tile*state.zoom, state.tile*state.zoom, state.tile*state.zoom); } prevTime = time; requestAnimationFrame(animate); } // Keyboard movement document.addEventListener('keydown', e => { if (!state.dungeon) return; const [x, y] = state.playerPos; const keys = {"ArrowUp":0, "w":0, "ArrowLeft":1, "a":1, "ArrowDown":2, "s":2, "ArrowRight":3, "d":3}; if (e.key in keys) { const [dx, dy] = dir4[keys[e.key]]; const nx = x+dx, ny = y+dy; if (nx < 0 || ny < 0 || nx >= state.dungeon.W || ny >= state.dungeon.H) return; const type = state.dungeon.get(nx, ny); const door = state.dungeon.doors.find(d => d.x == nx && d.y == ny); if (type === tileTypes.ROOM || type === tileTypes.CORRIDOR || (door && state.dungeon.doorOpen[ny][nx])) { state.playerPos = [nx, ny]; state.lightSampler = solveTorch([nx, ny]); } else if (door && !state.dungeon.doorOpen[ny][nx]) { state.dungeon.doorOpen[ny][nx] = true; state.playerPos = [nx, ny]; state.lightSampler = solveTorch([nx, ny]); } state.seenMask = solveTorch(state.playerPos).seen; } }); // Initialize canvas = document.getElementById('canvas'); ctx = canvas.getContext('2d'); generate(); requestAnimationFrame(animate); </script> </body> </html> ```
```html <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <title>Procedural Dungeon Torch</title> <style> body{margin:0;background:#000;color:#ddd;font-family:sans-serif;overflow:hidden} #ui{position:absolute;top:0;left:0;z-index:1000;background:#111a;padding:8px;font-size:12px;display:grid;gap:4px;grid-template-columns:auto 1fr} label{display:contents} input,button,select{background:#222;border:1px solid #444;color:#eee;padding:2px 4px} #canvas{position:absolute;top:0;left:0} </style> </head> <body> <canvas id="canvas"></canvas> <div id="ui"> <label>Seed <input id="seed" value="torch42" /></label> <button id="regen">Regenerate N</button> <label>W <input id="wIn" type="number" value="32" min="20" max="96"/></label> <label>H <input id="hIn" type="number" value="24" min="20" max="72"/></label> <label>Rooms <input id="rmCnt" type="number" value="5" min="3" max="15"/></label> <label>Room min <input id="rmMin" type="number" value="4" min="3" max="10"/></label> <label>Room max <input id="rmMax" type="number" value="8" min="4" max="12"/></label> <button id="spawnBtn">Respawn R</button> </div> <script> const TILE=24,Z=2,HALF_LIFE=30; // RNG (cyrb128 -> mulberry32) function rng(seedStr){ for(var i=0,h=[0,0,0,0],k;i<seedStr.length;i++){ k=i&3;h[k]=(h[k]<<5)+(h[k]^seedStr.charCodeAt(i))+seedStr.charCodeAt(i); } let a=h[0],b=h[1],c=h[2],d=h[3]; return () => {a|=0;b|=0;c|=0;d|=0;a+=b|0;b+=c|0;c+=d|0;d+=a|0;a=(a>>>13|a<<19);return (a>>>0)/0x100000000;} } /////////////////////////////////////////////////////////////////// // 1) GENERATE DUNGEON /////////////////////////////////////////////////////////////////// function generateDungeon({W,H,targetRooms,rmin,rmax,rng}){ // Simple discrete grid const VOID=0,WALL=1,ROOM=2,COR=3,DOOR=4; const grid = new Uint8Array(W*H).fill(VOID); const rooms=[]; const doors=[]; // {x,y,orient,state:boolean} function oob(x,y){return x<0||y<0||x>=W||y>=H;} function idx(x,y){return y*W+x;} // 1.A attempt random rooms for(let tries=0;tries<targetRooms*20 && rooms.length<targetRooms;tries++){ const rw=rmin+Math.floor(rng()*(rmax-rmin+1)); const rh=rmin+Math.floor(rng()*(rmax-rmin+1)); const rx=Math.floor(rng()*(W-rw-2))+1; const ry=Math.floor(rng()*(H-rh-2))+1; // check overlap let ok=true; for(const r of rooms){ if(!(rx+rw<r.x||rx>r.x+r.w||ry+rh<r.y||ry>r.y+r.h)){ok=false;break} } if(ok){ for(let dy=0;dy<rh;dy++){ for(let dx=0;dx<rw;dx++){ grid[idx(rx+dx,ry+dy)]=ROOM; } } rooms.push({x:rx,y:ry,w:rw,h:rh,floor:ROOM}); } } if(rooms.length===0) {rooms.push({x:1,y:1,w:3,h:3,floor:ROOM});} // 1.B walls adjacent to room for(const r of rooms){ for(let y=r.y-1;y<=r.y+r.h;y++){ for(let x=r.x-1;x<=r.x+r.w;x++){ if(oob(x,y))continue; if(grid[idx(x,y)]===VOID) grid[idx(x,y)]=WALL; } } } // 2. connectivity graph MST + extra const n=rooms.length; const edges=[]; for(let i=0;i<n;i++){ const ra=rooms[i]; const cxa=ra.x+ra.w/2,cya=ra.y+ra.h/2; for(let j=i+1;j<n;j++){ const rb=rooms[j]; const cxb=rb.x+rb.w/2,cyb=rb.y+rb.h/2; const dist=Math.abs(cxa-cxb)+Math.abs(cya-cyb); edges.push({i,j,dist}); } } edges.sort((a,b)=>a.dist-b.dist); const dsu=[...Array(n)].map((_,i)=>i); const find=i=>dsu[i]===i?i:(dsu[i]=find(dsu[i])); const conn=[]; for(const e of edges){ const a=find(e.i),b=find(e.j); if(a!==b){dsu[a]=b;conn.push(e);} } const extra=Math.floor(edges.length*0.15); for(let i=0;i<extra;i++){ const r = Math.floor(rng()*edges.length); if(!conn.includes(edges[r])) conn.push(edges[r]); } // 3. build corridors and doors const doorSet=new Set(); for(const e of conn){ const [ra,rb]=[rooms[e.i],rooms[e.j]]; const cxa=Math.floor(ra.x+ra.w/2),cya=Math.floor(ra.y+ra.h/2); const cxb=Math.floor(rb.x+rb.w/2),cyb=Math.floor(rb.y+rb.h/2); let px=0,py=0; const pts=[]; // place door on side of room facing the other room, one per function addDoor(rx,ry,rw,rh,side){ let x,y,orient; if(side==='left'){ x=rx-1; y=Math.floor(cya); }else if(side==='right'){ x=rx+rw; y=Math.floor(cya); }else if(side==='top'){ x=Math.floor(cxa); y=ry-1; }else if(side==='bottom'){ x=Math.floor(cxa); y=ry+rh; } const k=`${x},${y}`; if(doorSet.has(k)) return; else doorSet.add(k); oriented = side==='left'||side==='right'?1:2; grid[idx(x,y)]=DOOR; doors.push({x,y,orient:oriented,state:false}); return {x,y}; } if(cxa<cxb){px=addDoor(ra.x,ra.y,ra.w,ra.h,'right');} else{px=addDoor(ra.x,ra.y,ra.w,ra.h,'left');} if(cya<cyb){py=addDoor(rb.x,rb.y,rb.w,rb.h,'bottom');} else{py=addDoor(rb.x,rb.y,rb.w,rb.h,'top');} // A* corridor carve between px & py const aStar=(sx,sy,tx,ty)=>{ const f=a=>(Math.abs(a.x-tx)+Math.abs(a.y-ty)); const q=[{x:sx,y:sy,g:0,h:f({x:sx,y:sy})}]; const best={}; best[idx(sx,sy)]=0; const dir=[[1,0],[-1,0],[0,1],[0,-1]]; let step=0; while(q.length && step<10000){ step++; q.sort((a,b)=>a.g+a.h-(b.g+b.h)); const cur=q.shift(); if(cur.x===tx && cur.y===ty) break; for(const [dx,dy] of dir){ const nx=cur.x+dx,ny=cur.y+dy; if(oob(nx,ny))continue; const cell=grid[idx(nx,ny)]; if(cell===VOID||cell===WALL){ const ng=cur.g+1; if( best[idx(nx,ny)]===undefined || ng < best[idx(nx,ny)]){ best[idx(nx,ny)]=ng; if(cell===VOID||cell===WALL) grid[idx(nx,ny)]=COR; q.push({x:nx,y:ny,g:ng,h:f({x:nx,y:ny})}); } } } } }; if(px&&py) aStar(px.x,px.y,py.x,py.y); } // wall adjacent to corridors for(let y=0;y<H;y++){ for(let x=0;x<W;x++){ if(grid[idx(x,y)]===COR){ for(const [dx,dy] of [[1,0],[-1,0],[0,1],[0,-1]]){ const nx=x+dx,ny=y+dy; if(!oob(nx,ny)&&grid[idx(nx,ny)]===VOID) grid[idx(nx,ny)]=WALL; } } } } // spawn let sp={x:0,y:0},found=false; for(const r of rooms){ const cx=Math.floor(r.x+r.w/2),cy=Math.floor(r.y+r.h/2); if(grid[idx(cx,cy)]===ROOM){sp={x:cx,y:cy};found=true;break;} } if(!found){ for(let y=1;y<H-1;y++){ for(let x=1;x<W-1;x++){ if(grid[idx(x,y)]===ROOM||grid[idx(x,y)]===COR){sp={x,y}; found=true; break;} } if(found)break; } } return {grid,rooms,doors,sp,W,H}; } /////////////////////////////////////////////////////////////////// // 2) RENDER BASICS + PALETTE /////////////////////////////////////////////////////////////////// function buildPalette(seed,rng){ const h1=Math.floor(rng()*360); const wall=[40,10,25]; // muted brown const room=[h1,20,60]; const cor=[h1,30,45]; const door=[200,80,50]; const voidCol=[0,0,0]; const memory=[30,30,60]; function hsl([h,s,l]){ // srgb clamped s/=100; l/=100; const c=(1-Math.abs(2*l-1))*s, x=c*(1-Math.abs((h/60)%2-1)), m=l-c/2; let r,g,b; if(h>=0&&h<60){r=c;g=x;b=0} else if(h>=60&&h<120){r=x;g=c;b=0} else if(h>=120&&h<180){r=0;g=c;b=x} else if(h>=180&&h<240){r=0;g=x;b=c} else if(h>=240&&h<300){r=x;g=0;b=c} else{r=c;g=0;b=x} return [Math.min(255,Math.round((r+m)*255)),Math.min(255,Math.round((g+m)*255)),Math.min(255,Math.round((b+m)*255))]; } return { VOID:hsl(voidCol), WALL:hsl(wall), ROOM:hsl(room), CORRIDOR:hsl(cor), DOOR:hsl(door), MEMORY:hsl(memory), TORCH:(()=>{const tmp=hsl([h1+50,80,95]);return [tmp[0]/255,tmp[1]/255,tmp[2]/255]}), eps:.1 }; } /////////////////////////////////////////////////////////////////// // 3) LIGHTING (simple los) /////////////////////////////////////////////////////////////////// function solveTorch(dungeon,playerXY,{W,H}){ const {x:px,y:py}=playerXY; const seen=new Array(W*H).fill(false); const dirs=[[1,0],[-1,0],[0,1],[0,-1],[1
# Procedural Dungeon with Torch Lighting Here's a complete HTML/JS implementation of a procedurally generated dungeon with torch-based line-of-sight lighting: ```html <!DOCTYPE html> <html> <head> <title>Procedural Dungeon with Torch Lighting</title> <style> body { margin: 0; padding: 0; overflow: hidden; background-color: #000; display: flex; flex-direction: column; align-items: center; font-family: Arial, sans-serif; } canvas { display: block; image-rendering: pixelated; } #ui { color: white; padding: 10px; display: flex; gap: 20px; } .control-group { display: flex; flex-direction: column; gap: 5px; } button, input, select { font-size: 14px; } </style> </head> <body> <div id="ui"> <div class="control-group"> <div>Seed: <input id="seedInput" type="text" value="dungeon"></div> <button id="regenerateBtn">Generate (N)</button> <button id="respawnBtn">Respawn (R)</button> </div> <div class="control-group"> <label>Size: <input id="widthInput" type="number" min="20" max="100" value="40"> × <input id="heightInput" type="number" min="20" max="100" value="40"></label> <label>Rooms: <input id="roomCountInput" type="number" min="5" max="30" value="10"></label> <label>Room size: <input id="roomMinInput" type="number" min="3" max="8" value="3">-<input id="roomMaxInput" type="number" min="5" max="12" value="8"></label> </div> <div class="control-group"> <label>View: <input id="viewWidth" type="number" min="10" max="40" value="20"> × <input id="viewHeight" type="number" min="10" max="40" value="15"></label> <label>Zoom: <input id="zoomInput" type="number" min="1" max="5" step="0.5" value="2"></label> <label>Exposure: <input id="exposureInput" type="number" min="0.1" max="5" step="0.1" value="1.5"></label> </div> <div class="control-group"> <label>Light Quality: <select id="qualityInput"> <option value="low">Low</option> <option value="medium" selected>Medium</option> <option value="high">High</option> </select> </label> <label>Memory: <input id="memoryInput" type="number" min="0" max="0.5" step="0.01" value="0.08"></label> <label>Memory Half-life: <input id="halfLifeInput" type="number" min="1" max="60" value="20">s</label> </div> </div> <canvas id="gameCanvas"></canvas> <script> // Constants const TILE_SIZE = 16; const TILE_VOID = 0; const TILE_WALL = 1; const TILE_ROOM = 2; const TILE_CORRIDOR = 3; const TILE_DOOR = 4; const DOOR_CLOSED = 0; const DOOR_OPEN = 1; const ORIENT_H = 0; const ORIENT_V = 1; // Game state let dungeon = null; let tileset = null; let player = { x: 0, y: 0 }; let memory = null; let lastFrameTime = 0; let seedStr = "dungeon"; // UI elements const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); const seedInput = document.getElementById('seedInput'); const regenerateBtn = document.getElementById('regenerateBtn'); const respawnBtn = document.getElementById('respawnBtn'); const widthInput = document.getElementById('widthInput'); const heightInput = document.getElementById('heightInput'); const roomCountInput = document.getElementById('roomCountInput'); const roomMinInput = document.getElementById('roomMinInput'); const roomMaxInput = document.getElementById('roomMaxInput'); const viewWidth = document.getElementById('viewWidth'); const viewHeight = document.getElementById('viewHeight'); const zoomInput = document.getElementById('zoomInput'); const exposureInput = document.getElementById('exposureInput'); const qualityInput = document.getElementById('qualityInput'); const memoryInput = document.getElementById('memoryInput'); const halfLifeInput = document.getElementById('halfLifeInput'); // Lighting quality presets const QUALITY_PRESETS = { low: { S: 3, rays: 900, step: 0.3 }, medium: { S: 4, rays: 1300, step: 0.25 }, high: { S: 5, rays: 1800, step: 0.2 } }; // Colors in linear RGB (0-1) const FLOOR_COLOR = { r: 0.6, g: 0.5, b: 0.4 }; const WALL_COLOR = { r: 0.3, g: 0.25, b: 0.2 }; const ROOM_COLOR = { r: 0.5, g: 0.45, b: 0.4 }; const CORRIDOR_COLOR = { r: 0.45, g: 0.4, b: 0.35 }; const DOOR_COLOR = { r: 0.4, g: 0.35, b: 0.3 }; const PLAYER_COLOR = { r: 0.8, g: 0.3, b: 0.1 }; const TORCH_COLOR = { r: 1.0, g: 0.7, b: 0.4 }; // Noise texture for variation const noiseTexture = new Uint8Array(16 * 16 * 3); for (let i = 0; i < noiseTexture.length; i++) { noiseTexture[i] = Math.floor(Math.random() * 10 + 245); } // Utility functions function cyrb128(str) { let h1 = 1779033703, h2 = 3144134277, h3 = 1013904242, h4 = 2773480762; for (let i = 0; i < str.length; i++) { const k = str.charCodeAt(i); h1 = h2 ^ Math.imul(h1 ^ k, 597399067); h2 = h3 ^ Math.imul(h2 ^ k, 2869860233); h3 = h4 ^ Math.imul(h3 ^ k, 951274213); h4 = h1 ^ Math.imul(h4 ^ k, 2716044179); } h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067); h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233); h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213); h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179); return [(h1^h2^h3^h4)>>>0, (h2^h1)>>>0, (h3^h1)>>>0, (h4^h1)>>>0]; } function mulberry32(a) { return function() { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; } function createRNG(seed) { const s = cyrb128(seed); return mulberry32(s[0]); } function rectsOverlap(x1, y1, w1, h1, x2, y2, w2, h2) { return x1 < x2 + w2 && x1 + w1 > x2 && y1 < y2 + h2 && y1 + h1 > y2; } function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } // Tile types function isPassable(grid, x, y, doorOpen) { if (x < 0 || y < 0 || x >= grid[0].length || y >= grid.length) return false; const tile = grid[y][x]; if (tile === TILE_ROOM || tile === TILE_CORRIDOR) return true; if (tile === TILE_DOOR) return doorOpen[y][x] === DOOR_OPEN; return false; } function isBlocking(grid, x, y, doorOpen) { if (x < 0 || y < 0 || x >= grid[0].length || y >= grid.length) return true; const tile = grid[y][x]; if (tile === TILE_WALL) return true; if (tile === TILE_DOOR) return doorOpen[y][x] === DOOR_CLOSED; return false; } // Dungeon generation function generateDungeon(params) { const W = parseInt(params.W); const H = parseInt(params.H); const targetRooms = parseInt(params.targetRooms); const rmin = parseInt(params.rmin); const rmax = parseInt(params.rmax); // Initialize grid with VOID const grid = new Array(H); for (let y = 0; y < H; y++) { grid[y] = new Array(W).fill(TILE_VOID); } // Initialize doors const doorOpen = new Array(H); const doorOrient = new Array(H); for (let y = 0; y < H; y++) { doorOpen[y] = new Array(W).fill(DOOR_CLOSED); doorOrient[y] = new Array(W).fill(ORIENT_H); } const doors = []; const rooms = []; let connected = false; const rng = createRNG(params.seed); // Phase 1: Room placement let attempts = 0; const maxAttempts = targetRooms * 50; while (rooms.length < targetRooms && attempts < maxAttempts) { const w = Math.floor(rng() * (rmax - rmin + 1)) + rmin; const h = Math.floor(rng() * (rmax - rmin + 1)) + rmin; const x = Math.floor(rng() * (W - w - 2)) + 1; const y = Math.floor(rng() * (H - h - 2)) + 1; // Check for overlap with padding let overlap = false; for (const room of rooms) { if (rectsOverlap( x - 1, y - 1, w + 2, h + 2, room.x - 1, room.y - 1, room.w + 2, room.h + 2 )) { overlap = true; break; } } if (!overlap) { // Add room for (let ry = y; ry < y + h; ry++) { for (let rx = x; rx < x + w; rx++) { grid[ry][rx] = TILE_ROOM; } } rooms.push({ x, y, w, h, cx: Math.floor(x + w / 2), cy: Math.floor(y + h / 2) }); } attempts++; } // Phase 2: Add walls around rooms for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { if (grid[y][x] === TILE_VOID) { // Check adjacent cells for rooms const adj = [ y > 0 && grid[y-1][x] === TILE_ROOM, y < H-1 && grid[y+1][x] === TILE_ROOM, x > 0 && grid[y][x-1] === TILE_ROOM, x < W-1 && grid[y][x+1] === TILE_ROOM ]; if (adj.some(c => c)) { grid[y][x] = TILE_WALL; } } } } // Phase 3: Build connectivity graph with MST and extra edges if (rooms.length < 2) { return { grid, rooms, doors, doorOpen, doorOrient, W, H, connected: rooms.length > 0 }; } // Minimum spanning tree with Kruskal's algorithm let edges = []; for (let i = ㅂ0; i < rooms.length; i++) { for (let j = i + 1; j < rooms.length; j++) { const dx = rooms[i].cx - rooms[j].cx; const dy = rooms[i].cy - rooms[j].cy; const dist = dx * dx + dy * dy; edges.push({ i, j, dist }); } } edges.sort((a, b) => a.dist - b.dist); const parent = new Array(rooms.length); for (let i = 0; i < parent.length; i++) parent[i] = i; function find(u) { if (parent[u] !== u) parent[u] = find(parent[u]); return parent[u]; } function union(u, v) { const rootU = find(u); const rootV = find(v); if (rootU !== rootV) { parent[rootV] = rootU; return true; } return false; } const mstEdges = []; for (const edge of edges) { if (union(edge.i, edge.j)) { mstEdges.push(edge); if (mstEdges.length === rooms.length - 1) break; } } // Add some extra edges (10-15% of non-MST edges) const extraEdgesCount = Math.floor(Math.max(0, edges.length - mstEdges.length) * (0.1 + rng() * 0.05)); const extraEdges = []; const remainingEdges = edges.slice(mstEdges.length); for (let i = 0; i < extraEdgesCount && i < remainingEdges.length; i++) { extraEdges.push(remainingEdges[i]); } connected = true; // Phase 4: Place doors and make corridors function getRoomFacing(roomA, roomB) { // Returns which side of roomA is facing roomB (0=top, 1=right, 2=bottom, 3=left) const dx = roomB.cx - roomA.cx; const dy = roomB.cy - roomA.cy; if (Math.abs(dx) > Math.abs(dy)) { return dx > 0 ? 1 : 3; } else { return dy > 0 ? 2 : 0; } } const roomExits = rooms.map(() => [null, null, null, null]); // Tracks doors per side function findOrCreateDoor(roomIdx, side) { // Check if this room already has a door on this side if (roomExits[roomIdx][side] !== null) { return roomExits[roomIdx][side]; } // Need to create a new door const room = rooms[roomIdx]; let x, y; if (side === 0) { // Top y = room.y - 1; x = Math.floor(room.x + room.w / 2); doorOrient[y][x] = ORIENT_H; } else if (side === 1) { // Right y = Math.floor(room.y + room.h / 2); x = room.x + room.w; doorOrient[y][x] = ORIENT_V; } else if (side === 2) { // Bottom y = room.y + room.h; x = Math.floor(room.x + room.w / 2); doorOrient[y][x] = ORIENT_H; } else { // Left y = Math.floor(room.y + room.h / 2); x = room.x - 1; doorOrient[y][x] = ORIENT_V; } // Make sure the door position is valid if (x < 0 || y < 0 || x >= W || y >= H) return null; doors.push({ x, y }); grid[y][x] = TILE_DOOR; doorOpen[y][x] = DOOR_CLOSED; roomExits[roomIdx][side] = { x, y }; return { x, y }; } // Process all edges (MST + extras) const allEdges = [...mstEdges, ...extraEdges]; for (const edge of allEdges) { const roomA = edge.i; const roomB = edge.j; // Find which sides face each other const sideA = getRoomFacing(rooms[roomA], rooms[roomB]); const sideB = getRoomFacing(rooms[roomB], rooms[roomA]); // Get or create doors const doorA = findOrCreateDoor(roomA, sideA); const doorB = findOrCreateDoor(roomB, sideB); if (!doorA || !doorB) continue; // Connect the doors with a corridor (Lee algorithm/BFS) const queue = [{ x: doorA.x, y: doorA.y, prev: null }]; const visited = new Set(); visited.add(`${doorA.x},${doorA.y}`); let found = null; while (queue.length > 0 && !found) { const current = queue.shift(); // Check if we reached the target if (current.x === doorB.x && current.y === doorB.y) { found = current; break; } // Try all 4 directions const directions = [ { dx: 0, dy: -1 }, { dx: 1, dy: 0 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 } ]; for (const dir of directions) { const nx = current.x + dir.dx; const ny = current.y + dir.dy; if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue; const key = `${nx},${ny}`; if (visited.has(key)) continue; // Allowed moves: // 1. Existing corridor or door // 2. Void (not adjacent to rooms) // 3. Non-room walls (walls not adjacent to rooms) let canMove = false; if ( grid[ny][nx] === TILE_CORRIDOR || (grid[ny][nx] === TILE_DOOR && (doorOpen[ny][nx] === DOOR_OPEN || (nx === doorB.x && ny === doorB.y))) ) { canMove = true; } else if (grid[ny][nx] === TILE_VOID) { // Check if any adjacent is a room canMove = true; for (const d of directions) { const ax = nx + d.dx; const ay = ny + d.dy; if (ax >= 0 && ay >= 0 && ax < W && ay < H && grid[ay][ax] === TILE_ROOM) { canMove = false; break; } } } else if (grid[ny][nx] === TILE_WALL) { // Check if it's a room wall let isRoomWall = false; for (const d of directions) { const ax = nx + d.dx; const ay = ny + d.dy; if (ax >= 0 && ay >= 0 && ax < W && ay < H && grid[ay][ax] === TILE_ROOM) { isRoomWall = true; break; } } canMove = !isRoomWall; } if (canMove) { visited.add(key); queue.push({ x: nx, y: ny, prev: current }); } } } // If path found, carve the corridor if (found) { // Backtrack to mark the path let current = found.prev; // Skip the door itself while (current && current.prev) { if (grid[current.y][current.x] !== TILE_DOOR) { grid[current.y][current.x] = TILE_CORRIDOR; } current = current.prev; } } } // Phase 5: Add walls around corridors for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { if (grid[y][x] === TILE_VOID) { // Check adjacent cells for corridors const adj = [ y > 0 && grid[y-1][x] === TILE_CORRIDOR, y < H-1 && grid[y+1][x] === TILE_CORRIDOR, x > 0 && grid[y][x-1] === TILE_CORRIDOR, x < W-1 && grid[y][x+1] === TILE_CORRIDOR ]; if (adj.some(c => c)) { grid[y][x] = TILE_WALL; } } } } return { grid, rooms, doors, doorOpen, doorOrient, W, H, connected }; } // Rendering function buildTileset(seed, tileSize) { const rng = createRNG(seed); // Create a canvas for the tile set const canvas = document.createElement('canvas'); canvas.width = tileSize * 8; // We'll store different tile types in a tileset canvas.height = tileSize; const ctx = canvas.getContext('2d'); // Background color ctx.fillStyle = '#000'; ctx.fillRect(0, 0, canvas.width, canvas.height); // Room floor ctx.fillStyle = '#8a7b68'; ctx.fillRect(0, 0, tileSize, tileSize); // Corridor floor ctx.fillStyle = '#7a6b58'; ctx.fillRect(tileSize, 0, tileSize, tileSize); // Wall ctx.fillStyle = '#4c3f32'; ctx.fillRect(tileSize*2, 0, tileSize, tileSize); // Closed horizontal door ctx.fillStyle = '#5c4f42'; ctx.fillRect(tileSize*3, 0, tileSize, tileSize); ctx.fillStyle = '#3c2f22'; ctx.fillRect(tileSize*3 + Math.floor(tileSize/4), Math.floor(tileSize/4), Math.floor(tileSize/2), Math.floor(tileSize/2)); // Closed vertical door ctx.fillStyle = '#5c4f42'; ctx.fillRect(tileSize*4, 0, tileSize, tileSize); ctx.fillStyle = '#3c2f22'; ctx.fillRect(tileSize*4 + Math.floor(tileSize/4), Math.floor(tileSize/4), Math.floor(tileSize/2), Math.floor(tileSize/2)); // Open horizontal door ctx.fillStyle = '#8a7b68'; ctx.fillRect(tileSize*5, 0, tileSize, tileSize); // Open vertical door ctx.fillStyle = '#8a7b68'; ctx.fillRect(tileSize*6, 0, tileSize, tileSize); // Player ctx.fillStyle = '#d44c1a'; ctx.beginPath(); ctx.arc(tileSize*7 + tileSize/2, tileSize/2, tileSize/3, 0, Math.PI*2); ctx.fill(); // Add some noise to walls and floors for (let y = 0; y < tileSize; y++) { for (let x = 0; x < canvas.width; x++) { const r = Math.floor(rng() * 15 + 240); const pixel = ctx.getImageData(x, y, 1, 1).data; // Only add noise to floor and walls, not entities if (x < tileSize * 7) { pixel[0] = Math.floor(pixel[0] * r / 255); pixel[1] = Math.floor(pixel[1] * r / 255); pixel[2] = Math.floor(pixel[2] * r / 255); ctx.putImageData(new ImageData(pixel, 1, 1), x, y); } } } return { roomTile: { x: 0, y: 0, w: tileSize, h: tileSize }, corridorTile: { x: tileSize, y: 0, w: tileSize, h: tileSize }, wallTile: { x: tileSize*2, y: 0, w: tileSize, h: tileSize }, doorClosedH: { x: tileSize*3, y: 0, w: tileSize, h: tileSize }, doorClosedV: { x: tileSize*4, y: 0, w: tileSize, h: tileSize }, doorOpenH: { x: tileSize*5, y: 0, w: tileSize, h: tileSize }, doorOpenV: { x: tileSize*6, y: 0, w: tileSize, h: tileSize }, playerTile: { x: tileSize*7, y: 0, w: tileSize, h: tileSize }, canvas: canvas }; } function renderBaseToCanvas(dungeon, tiles, tileSize) { const canvas = document.createElement('canvas'); canvas.width = dungeon.W * tileSize; canvas.height = dungeon.H * tileSize; const ctx = canvas.getContext('2d'); // Draw the base tiles for (let y = 0; y < dungeon.H; y++) { for (let x = 0; x < dungeon.W; x++) { let tile; switch (dungeon.grid[y][x]) { case TILE_ROOM: tile = tiles.roomTile; break; case TILE_CORRIDOR: tile = tiles.corridorTile; break; case TILE_WALL: tile = tiles.wallTile; break; case TILE_DOOR: if (dungeon.doorOpen[y][x] === DOOR_OPEN) { tile = dungeon.doorOrient[y][x] === ORIENT_H ? tiles.doorOpenH : tiles.doorOpenV; } else { tile = dungeon.doorOrient[y][x] === ORIENT_H ? tiles.doorClosedH : tiles.doorClosedV; } break; default: continue; // VOID remains black } ctx.drawImage( tiles.canvas, tile.x, tile.y, tile.w, tile.h, x * tileSize, y * tileSize, tileSize, tileSize ); } } return canvas; } // Lighting function solveTorch(dungeon, playerXY, params) { const { tileSize, S, rays, step, radiusTiles, p=1.0, eps=0.1 } = params; const radiusPixels = radiusTiles * tileSize; const radius2 = radiusPixels * radiusPixels; const eps2 = eps * eps; // Create a high-res buffer for lighting const subcellSize = tileSize * S; const bufW = Math.ceil(dungeon.W * tileSize / subcellSize) + 2; const bufH = Math.ceil(dungeon.H * tileSize / subcellSize) + 2; const lightBuffer = new Float32Array(bufW * bufH).fill(0); const seenMask = new Uint8Array(dungeon.W * dungeon.H).fill(0); const ox = playerXY.x * tileSize + tileSize/2; const oy = playerXY.y * tileSize + tileSize/2; // Generate stratified points in a circle const samples = []; const rings = Math.ceil(Math.sqrt(rays / Math.PI)); for (let r = 0; r < rings; r++) { const radius = (r + 0.5) / rings; const circumference = 2 * Math.PI * radius; const pointsOnRing = Math.max(1, Math.floor(rays * circumference / (rings * rings))); for (let i = 0; i < pointsOnRing; i++) { const angle = (i + Math.random()) * 2 * Math.PI / pointsOnRing; const dx = radius * Math.cos(angle); const dy = radius * Math.sin(angle); samples.push({ dx, dy }); } } // Ray march each sample for (const sample of samples) { const stepX = sample.dx * step; const stepY = sample.dy * step; let px = ox; let py = oy; let tx_prev = Math.floor(px / tileSize); let ty_prev = Math.floor(py / tileSize); // Calculate distance for inverse square falloff const maxRayLength = radiusPixels * 1.1; while (true) { px += stepX; py += stepY; // World bounds check if (px < 0 || py < 0 || px >= dungeon.W * tileSize || py >= dungeon.H * tileSize) { break; } // Current tile const tx = Math.floor(px / tileSize); const ty = Math.floor(py / tileSize); // Distance check (physical falloff) const dx = px - ox; const dy = py - oy; const distance2 = dx*dx + dy*dy; if (distance2 > radius2) { break; } // Check if we moved diagonally and need to test for corner occlusion if (tx !== tx_prev && ty !== ty_prev) { // Check if both "orthogonal" adjacent tiles are blocking const orth1Blocking = isBlocking(dungeon.grid, tx, ty_prev, dungeon.doorOpen); const orth2Blocking = isBlocking(dungeon.grid, tx_prev, ty, dungeon.doorOpen); if (orth1Blocking && orth2Blocking) { break; // Corner is fully occluded } } // Check if current tile blocks light if (isBlocking(dungeon.grid, tx, ty, dungeon.doorOpen)) { break; } // Light calculation const distance = Math.sqrt(distance2); const intensity = Math.pow(1 / (distance2 / (tileSize*tileSize) + eps2), p); // Mark this subcell const subX = Math.floor(px / subcellSize); const subY = Math.floor(py / subcellSize); if (subX >= 0 && subY >= 0 && subX < bufW && subY < bufH) { const idx = subY * bufW + subX; lightBuffer[idx] = Math.max(lightBuffer[idx], intensity); } // Mark this tile as seen seenMask[ty * dungeon.W + tx] = 1; tx_prev = tx; ty_prev = ty; } } // Function to sample light at a pixel function samplePix(px, py) { const subX = Math.floor(px / subcellSize); const subY = Math.floor(py / subcellSize); if (subX < 0 || subY < 0 || subX >= bufW || subY >= bufH) { return 0; } return lightBuffer[subY * bufW + subX]; } return { samplePix, seenMask }; } // Main game rendering function compose(outputCanvas, baseCanvas, baseLinear, sampler, exposure, viewRegion, memory, dungeon, tileSize, memIntensity, tiles, zoom) { const ctx = outputCanvas.getContext('2d'); const { px, py, w, h } = viewRegion; // Clear the output ctx.fillStyle = '#000'; ctx.fillRect(0, 0, outputCanvas.width, outputCanvas.height); // Calculate visible area in base canvas coordinates const viewX = Math.max(0, px); const viewY = Math.max(0, py); const viewW = Math.min(w, baseCanvas.width - viewX); const viewH = Math.min(h, baseCanvas.height - viewY); if (viewW <= 0 || viewH <= 0) return; // Draw the base with lighting const outputImageData = ctx.createImageData(viewW, viewH); const baseImageData = baseCanvas.getContext('2d').getImageData(viewX, viewY, viewW, viewH).data; for (let y = 0; y < viewH; y++) { for (let x = 0; x < viewW; x++) { const baseIdx = (y * viewW + x) * 4; const outputIdx = (y * viewW + x) * 4; // World coordinates const wx = Math.floor((viewX + x) / tileSize); const wy = Math.floor((viewY + y) / tileSize); if (wx < 0 || wy < 0 || wx >= dungeon.W || wy >= dungeon.H) { continue; // Outside world bounds } // Sample the lighting at this pixel const light = sampler(viewX + x, viewY + y); // Get memory value for this tile const mem = wy < dungeon.H && wx < dungeon.W ? memory[wy * dungeon.W + wx] : 0; // Get base color (convert from sRGB) let r = baseImageData[baseIdx] / 255; let g = baseImageData[baseIdx+1] / 255; let b = baseImageData[baseIdx+2] / 255; // Convert base color to linear space (approximate sRGB to linear) r = r <= 0.04045 ? r / 12.92 : Math.pow((r + 0.055) / 1.055, 2.4); g = g <= 0.04045 ? g / 12.92 : Math.pow((g + 0.055) / 1.055, 2.4); b = b <= 0.04045 ? b / 12.92 : Math.pow((b + 0.055) / 1.055, 2.4); // Apply lighting and memory const litIntensity = exposure * light; const memoryIntensity = memIntensity * mem; // Combine (in linear space) r *= (litIntensity + memoryIntensity); g *= (litIntensity + memoryIntensity); b *= (litIntensity + memoryIntensity); // Convert back to sRGB and clamp r = r <= 0.0031308 ? 12.92 * r : 1.055 * Math.pow(r, 1/2.4) - 0.055; g = g <= 0.0031308 ? 12.92 * g : 1.055 * Math.pow(g, 1/2.4) - 0.055; b = b <= 0.0031308 ? 12.92 * b : 1.055 * Math.pow(b, 1/2.4) - 0.055; // Clamp and store outputImageData.data[outputIdx] = Math.floor(clamp(r, 0, 1) * 255); outputImageData.data[outputIdx+1] = Math.floor(clamp(g, 0, 1) * 255); outputImageData.data[outputIdx+2] = Math.floor(clamp(b, 0, 1) * 255); outputImageData.data[outputIdx+3] = 255; // Alpha } } // Draw the lit base ctx.putImageData(outputImageData, 0, 0); // Draw the player const playerCanvas = document.createElement('canvas'); playerCanvas.width = tileSize; playerCanvas.height = tileSize; const playerCtx = playerCanvas.getContext('2d'); playerCtx.drawImage( tiles.canvas, tiles.playerTile.x, tiles.playerTile.y, tiles.playerTile.w, tiles.playerTile.h, 0, 0, tileSize, tileSize ); const lightAtPlayer = sampler(player.x * tileSize + tileSize/2, player.y * tileSize + tileSize/2); if (lightAtPlayer > 0 || (memory[player.y * dungeon.W + player.x] > 0.01)) { // Multiply player by light const playerData = playerCtx.getImageData(0, 0, tileSize, tileSize).data; for (let i = 0; i < playerData.length; i += 4) { if (playerData[i+3] > 0) { // Non-transparent const lightValue = exposure * lightAtPlayer + memoryIntensity; playerData[i] = Math.floor(playerData[i] * lightValue); playerData[i+1] = Math.floor(playerData[i+1] * lightValue); playerData[i+2] = Math.floor(playerData[i+2] * lightValue); } } playerCtx.putImageData(new ImageData(playerData, tileSize, tileSize), 0, 0); ctx.drawImage( playerCanvas, (player.x * tileSize - viewX) * zoom, (player.y * tileSize - viewY) * zoom, tileSize * zoom, tileSize * zoom ); } } // Game loop function gameLoop(timestamp) { if (!lastFrameTime) lastFrameTime = timestamp; const deltaTime = (timestamp - lastFrameTime) / 1000; lastFrameTime = timestamp; // Update memory decay if (memory && dungeon) { const decayFactor = Math.pow(0.5, deltaTime / parseFloat(halfLifeInput.value)); for (let i = 0; i < memory.length; i++) { memory[i] *= decayFactor; } } // Get current view parameters const viewW = parseInt(viewWidth.value); const viewH = parseInt(viewHeight.value); const zoom = parseFloat(zoomInput.value); // Set canvas size based on view canvas.width = viewW * TILE_SIZE * zoom; canvas.height = viewH * TILE_SIZE * zoom; if (dungeon && tileset) { // Calculate view region (centered on player, clamped to world) const centerX = player.x * TILE_SIZE; const centerY = player.y * TILE_SIZE; const halfW = Math.floor(viewW * TILE_SIZE / 2); const halfH = Math.floor(viewH * TILE_SIZE / 2); let viewX = centerX - halfW; let viewY = centerY - halfH; // Clamp to world bounds viewX = Math.max(0, Math.min(viewX, dungeon.W * TILE_SIZE - viewW * TILE_SIZE)); viewY = Math.max(0, Math.min(viewY, dungeon.H * TILE_SIZE - viewH * TILE_SIZE)); // Solve lighting const quality = QUALITY_PRESETS[qualityInput.value]; const lightRadius = Math.max(viewW, viewH) / 2 + 5; const { samplePix, seenMask } = solveTorch(dungeon, player, { tileSize: TILE_SIZE, S: quality.S, rays: quality.rays, step: quality.step, radiusTiles: lightRadius, p: 1.0, eps: 0.1 }); // Update memory with currently seen tiles for (let y = 0; y < dungeon.H; y++) { for (let x = 0; x < dungeon.W; x++) { if (seenMask[y * dungeon.W + x] > 0) { memory[y * dungeon.W + x] = 1.0; } } } // Composite the view compose( canvas, baseCanvas, baseLinear, samplePix, parseFloat(exposureInput.value), { px: viewX, py: viewY, w: viewW * TILE_SIZE, h: viewH * TILE_SIZE }, memory, dungeon, TILE_SIZE, parseFloat(memoryInput.value), tileset, zoom ); } requestAnimationFrame(gameLoop); } // Player movement function movePlayer(dx, dy) { if (!dungeon) return false; const nx = player.x + dx; const ny = player.y + dy; if (!isPassable(dungeon.grid, nx, ny, dungeon.doorOpen)) { // Check if it's a closed door if (nx >= 0 && ny >= 0 && nx < dungeon.W && ny < dungeon.H && dungeon.grid[ny][nx] === TILE_DOOR && dungeon.doorOpen[ny][nx] === DOOR_CLOSED) { // Auto-open the door dungeon.doorOpen[ny][nx] = DOOR_OPEN; // Also open adjacent doors (QoL) openAdjacentDoors({ x: nx, y: ny }); return true; } return false; } // Valid move player.x = nx; player.y = ny; // Auto-open adjacent doors openAdjacentDoors(player); return true; } function openAdjacentDoors(pos) { const directions = [ { x: 0, y: -1 }, { x: 1, y: 0 }, { x: 0, y: 1 }, { x: -1, y: 0 } ]; for (const dir of directions) { const nx = pos.x + dir.x; const ny = pos.y + dir.y; if (nx >= 0 && ny >= 0 && nx < dungeon.W && ny < dungeon.H && dungeon.grid[ny][nx] === TILE_DOOR && dungeon.doorOpen[ny][nx] === DOOR_CLOSED) { dungeon.doorOpen[ny][nx] = DOOR_OPEN; } } } // Input handling document.addEventListener('keydown', (e) => { if (e.key === 'ArrowUp' || e.key === 'w' || e.key === 'W') { movePlayer(0, -1); e.preventDefault(); } else if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') { movePlayer(1, 0); e.preventDefault(); } else if (e.key === 'ArrowDown' || e.key === 's' || e.key === 'S') { movePlayer(0, 1); e.preventDefault(); } else if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') { movePlayer(-1, 0); e.preventDefault(); } else if (e.key === 'n' || e.key === 'N') { generateNewDungeon(); e.preventDefault(); } else if (e.key === 'r' || e.key === 'R') { respawnPlayer(); e.preventDefault(); } }); // UI event handlers regenerateBtn.addEventListener('click', generateNewDungeon); respawnBtn.addEventListener('click', respawnPlayer); function generateNewDungeon() { seedStr = seedInput.value || "dungeon" + Date.now(); const params = { W: parseInt(widthInput.value), H: parseInt(heightInput.value), targetRooms: parseInt(roomCountInput.value), rmin: parseInt(roomMinInput.value), rmax: parseInt(roomMaxInput.value), seed: seedStr }; dungeon = generateDungeon(params); tileset = buildTileset(seedStr, TILE_SIZE); baseCanvas = renderBaseToCanvas(dungeon, tileset, TILE_SIZE); // Find a safe spawn position for player respawnPlayer(); } function respawnPlayer() { if (!dungeon) return; // Find all passable tiles const passableTiles = []; // Prefer corridor junctions (degree >= 2) for (let y = 0; y < dungeon.H; y++) { for (let x = 0; x < dungeon.W; x++) { if (isPassable(dungeon.grid, x, y, dungeon.doorOpen)) { if (dungeon.grid[y][x] === TILE_CORRIDOR) { // Count neighbors let neighbors = 0; if (isPassable(dungeon.grid, x, y-1, dungeon.doorOpen)) neighbors++; if (isPassable(dungeon.grid, x+1, y, dungeon.doorOpen)) neighbors++; if (isPassable(dungeon.grid, x, y+1, dungeon.doorOpen)) neighbors++; if (isPassable(dungeon.grid, x-1, y, dungeon.doorOpen)) neighbors++; if (neighbors >= 2) { passableTiles.push({ x, y }); } } } } } // If no corridor junctions, use room centers if (passableTiles.length === 0) { for (const room of dungeon.rooms) { if (isPassable(dungeon.grid, room.cx, room.cy, dungeon.doorOpen)) { passableTiles.push({ x: room.cx, y: room.cy }); } } } // If still no position, use first passable tile if (passableTiles.length === 0) { for (let y = 0; y < dungeon.H; y++) { for (let x = 0; x < dungeon.W; x++) { if (isPassable(dungeon.grid, x, y, dungeon.doorOpen)) { passableTiles.push({ x, y }); break; } } if (passableTiles.length > 0) break; } } // Set player position if (passableTiles.length > 0) { const spawn = passableTiles[Math.floor(createRNG(seedStr + "spawn")() * passableTiles.length)]; player.x = spawn.x; player.y = spawn.y; } // Initialize memory memory = new Float32Array(dungeon.W * dungeon.H).fill(0); } // Initialize let baseCanvas = null; let baseLinear = null; // Start the game generateNewDungeon(); requestAnimationFrame(gameLoop); </script> </body> </html> ``` This implementation includes: 1. **Procedural Dungeon Generation**: - Rooms with configurable size and count - Connects rooms with corridors using MST plus extra edges for cycles - Proper wall placement around rooms and corridors - Doors between connected areas that block light and movement when closed 2. **Torch-Based Lighting**: - Physically accurate inverse-square falloff - High-resolution ray marching with configurable quality settings - Prevents light leaks through diagonal gaps - Adjustable exposure and memory intensity 3. **Fog of War**: - Tracks explored areas - Shows dim memory of previously seen tiles that decays over time 4. **Player Movement**: - WASD or arrow key controls - Auto-opening doors when adjacent or attempting to move through them 5. **Configurable UI**: - Adjustable dungeon size, room count, and sizes - Configurable view size, zoom, and exposure - Quality presets for lighting - Controls for memory intensity and half-life 6. **Visual Style**: - Simple pixel-art style - Different tile types with mild noise for variation - Visible player character with torch The implementation is deterministic based on the seed value and avoids diagonal light leaks while providing a smooth exploration experience.
```html <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Procedural Dungeon with Torch Lighting</title> <style> body { margin: 0; padding: 10px; font-family: monospace; background: #111; color: #fff; } .container { display: flex; gap: 10px; } .controls { width: 300px; background: #222; padding: 10px; border-radius: 5px; } .control-group { margin-bottom: 15px; } label { display: block; margin-bottom: 5px; font-size: 12px; } input, select { width: 100%; padding: 5px; margin-bottom: 5px; background: #333; color: #fff; border: 1px solid #555; border-radius: 3px; } button { width: 100%; padding: 8px; background: #555; color: #fff; border: none; border-radius: 3px; cursor: pointer; margin-bottom: 5px; } button:hover { background: #666; } .view-container { flex: 1; } canvas { border: 2px solid #555; background: #000; } .status { margin-top: 10px; font-size: 12px; color: #aaa; } .key-hint { font-size: 11px; color: #888; margin-top: 5px; } </style> </head> <body> <div class="container"> <div class="controls"> <div class="control-group"> <label>Seed:</label> <input type="text" id="seed" value="dungeon123"> <button onclick="randomizeSeed()">Randomize</button> </div> <div class="control-group"> <label>Map Size (W×H):</label> <input type="range" id="mapW" min="40" max="120" value="80"> <span id="mapWValue">80</span> <input type="range" id="mapH" min="40" max="120" value="60"> <span id="mapHValue">60</span> </div> <div class="control-group"> <label>Rooms:</label> <input type="range" id="rooms" min="5" max="25" value="12"> <span id="roomsValue">12</span> </div> <div class="control-group"> <label>Room Size Range:</label> <input type="range" id="rmin" min="3" max="8" value="4"> <span id="rminValue">4</span> <input type="range" id="rmax" min="6" max="15" value="10"> <span id="rmaxValue">10</span> </div> <div class="control-group"> <label>View Size (tiles):</label> <input type="range" id="viewW" min="15" max="40" value="25"> <span id="viewWValue">25</span> <input type="range" id="viewH" min="15" max="30" value="20"> <span id="viewHValue">20</span> </div> <div class="control-group"> <label>Zoom:</label> <input type="range" id="zoom" min="1" max="3" step="0.1" value="1.5"> <span id="zoomValue">1.5</span> </div> <div class="control-group"> <label>Exposure:</label> <input type="range" id="exposure" min="0.5" max="3" step="0.1" value="1.2"> <span id="exposureValue">1.2</span> </div> <div class="control-group"> <label>Light Quality:</label> <select id="quality"> <option value="low">Low (Fast)</option> <option value="medium" selected>Medium</option> <option value="high">High (Slow)</option> </select> </div> <div class="control-group"> <label>Memory Half-Life (s):</label> <input type="range" id="memHalfLife" min="5" max="60" value="20"> <span id="memHalfLifeValue">20</span> </div> <button onclick="generate()">Generate (N)</button> <button onclick="respawn()">Respawn (R)</button> <button onclick="exportView()">Export PNG</button> <div class="key-hint"> Movement: WASD or Arrow Keys<br> Doors auto-open when adjacent </div> <div class="status" id="status">Ready</div> </div> <div class="view-container"> <canvas id="gameCanvas" width="800" height="600"></canvas> </div> </div> <script> // Constants const TILE_SIZE = 32; const VOID = 0, WALL = 1, ROOM = 2, CORRIDOR = 3, DOOR = 4; const HORIZONTAL = 0, VERTICAL = 1; // Global state let dungeon = null; let tiles = null; let baseCanvas = null; let baseLinearRGB = null; let playerX = 0, playerY = 0; let memory = null; let lastFrameTime = 0; let needsLightUpdate = true; let lightSampler = null; let seenMask = null; // Canvas setup const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); // RNG - cyrb128 + mulberry32 function cyrb128(str) { let h1 = 1779033703, h2 = 3144134277, h3 = 1013904242, h4 = 2773480762; for (let i = 0, k; i < str.length; i++) { k = str.charCodeAt(i); h1 = h2 ^ Math.imul(h1 ^ k, 597399067); h2 = h3 ^ Math.imul(h2 ^ k, 2869860233); h3 = h4 ^ Math.imul(h3 ^ k, 951274213); h4 = h1 ^ Math.imul(h4 ^ k, 2716044179); } h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067); h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233); h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213); h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179); h1 ^= (h2 ^ h3 ^ h4); h2 ^= h1; h3 ^= h1; h4 ^= h1; return [h1>>>0, h2>>>0, h3>>>0, h4>>>0]; } function mulberry32(seed) { return function() { seed |= 0; seed = seed + 0x6D2B79F5 | 0; let t = Math.imul(seed ^ seed >>> 15, seed | 1); t ^= t + Math.imul(t ^ t >>> 7, t | 61); return ((t ^ t >>> 14) >>> 0) / 4294967296; } } function createRNG(seedStr) { const [a, b, c, d] = cyrb128(seedStr); return mulberry32(a); } // Color conversion function srgbToLinear(c) { return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); } function linearToSrgb(c) { return c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1/2.4) - 0.055; } function hslToRgb(h, s, l) { const c = (1 - Math.abs(2 * l - 1)) * s; const x = c * (1 - Math.abs((h / 60) % 2 - 1)); const m = l - c / 2; let r, g, b; if (h >= 0 && h < 60) { r = c; g = x; b = 0; } else if (h >= 60 && h < 120) { r = x; g = c; b = 0; } else if (h >= 120 && h < 180) { r = 0; g = c; b = x; } else if (h >= 180 && h < 240) { r = 0; g = x; b = c; } else if (h >= 240 && h < 300) { r = x; g = 0; b = c; } else { r = c; g = 0; b = x; } return [(r + m), (g + m), (b + m)]; } // Dungeon generation function generateDungeon(config) { const { W, H, targetRooms, rmin, rmax, seed } = config; const rng = createRNG(seed); const grid = new Uint8Array(W * H); const rooms = []; const doors = []; const doorOpen = Array(H).fill().map(() => new Array(W).fill(false)); const doorOrient = Array(H).fill().map(() => new Array(W).fill(0)); function getCell(x, y) { if (x < 0 || x >= W || y < 0 || y >= H) return VOID; return grid[y * W + x]; } function setCell(x, y, value) { if (x >= 0 && x < W && y >= 0 && y < H) { grid[y * W + x] = value; } } // Place rooms const padding = 2; let attempts = 0; while (rooms.length < targetRooms && attempts < 1000) { attempts++; const w = Math.floor(rng() * (rmax - rmin + 1)) + rmin; const h = Math.floor(rng() * (rmax - rmin + 1)) + rmin; const x = Math.floor(rng() * (W - w - 2 * padding)) + padding; const y = Math.floor(rng() * (H - h - 2 * padding)) + padding; let overlaps = false; for (const room of rooms) { if (!(x + w + padding <= room.x || x >= room.x + room.w + padding || y + h + padding <= room.y || y >= room.y + room.h + padding)) { overlaps = true; break; } } if (!overlaps) { rooms.push({ x, y, w, h, cx: x + w/2, cy: y + h/2 }); for (let ry = y; ry < y + h; ry++) { for (let rx = x; rx < x + w; rx++) { setCell(rx, ry, ROOM); } } } } if (rooms.length === 0) { return { grid, rooms, doors, doorOpen, doorOrient, W, H, connected: false }; } // Add walls around rooms for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { if (getCell(x, y) === VOID) { if (getCell(x-1, y) === ROOM || getCell(x+1, y) === ROOM || getCell(x, y-1) === ROOM || getCell(x, y+1) === ROOM) { setCell(x, y, WALL); } } } } // Build connectivity graph (MST + extra edges) const edges = []; for (let i = 0; i < rooms.length; i++) { for (let j = i + 1; j < rooms.length; j++) { const dist = Math.hypot(rooms[i].cx - rooms[j].cx, rooms[i].cy - rooms[j].cy); edges.push({ i, j, dist }); } } edges.sort((a, b) => a.dist - b.dist); // MST (Kruskal's) const parent = Array(rooms.length).fill().map((_, i) => i); function find(x) { if (parent[x] !== x) parent[x] = find(parent[x]); return parent[x]; } const mstEdges = []; let edgeCount = 0; for (const edge of edges) { const pi = find(edge.i), pj = find(edge.j); if (pi !== pj) { parent[pi] = pj; mstEdges.push(edge); edgeCount++; if (edgeCount === rooms.length - 1) break; } } // Add extra edges (10-15% of remaining) const remainingEdges = edges.filter(e => !mstEdges.includes(e)); const extraCount = Math.floor(remainingEdges.length * 0.12); for (let k = 0; k < Math.min(extraCount, remainingEdges.length); k++) { mstEdges.push(remainingEdges[k]); } // Place doors and carve corridors const usedSides = rooms.map(() => ({ left: false, right: false, top: false, bottom: false })); for (const edge of mstEdges) { const roomA = rooms[edge.i]; const roomB = rooms[edge.j]; // Determine which sides face each other let sideA, sideB, doorA, doorB; if (roomA.cx < roomB.cx) { // A is left of B sideA = 'right'; sideB = 'left'; } else { // A is right of B sideA = 'left'; sideB = 'right'; } if (Math.abs(roomA.cy - roomB.cy) > Math.abs(roomA.cx - roomB.cx)) { if (roomA.cy < roomB.cy) { // A is above B sideA = 'bottom'; sideB = 'top'; } else { // A is below B sideA = 'top'; sideB = 'bottom'; } } // Place door on side A if not already used if (!usedSides[edge.i][sideA]) { usedSides[edge.i][sideA] = true; if (sideA === 'left' || sideA === 'right') { const dx = sideA === 'left' ? -1 : roomA.w; const dy = Math.floor(rng() * (roomA.h - 2)) + 1; doorA = { x: roomA.x + dx, y: roomA.y + dy, orient: VERTICAL }; } else { const dx = Math.floor(rng() * (roomA.w - 2)) + 1; const dy = sideA === 'top' ? -1 : roomA.h; doorA = { x: roomA.x + dx, y: roomA.y + dy, orient: HORIZONTAL }; } setCell(doorA.x, doorA.y, DOOR); doorOpen[doorA.y][doorA.x] = false; doorOrient[doorA.y][doorA.x] = doorA.orient; doors.push(doorA); } // Place door on side B if not already used if (!usedSides[edge.j][sideB]) { usedSides[edge.j][sideB] = true; if (sideB === 'left' || sideB === 'right') { const dx = sideB === 'left' ? -1 : roomB.w; const dy = Math.floor(rng() * (roomB.h - 2)) + 1; doorB = { x: roomB.x + dx, y: roomB.y + dy, orient: VERTICAL }; } else { const dx = Math.floor(rng() * (roomB.w - 2)) + 1; const dy = sideB === 'top' ? -1 : roomB.h; doorB = { x: roomB.x + dx, y: roomB.y + dy, orient: HORIZONTAL }; } setCell(doorB.x, doorB.y, DOOR); doorOpen[doorB.y][doorB.x] = false; doorOrient[doorB.y][doorB.x] = doorB.orient; doors.push(doorB); } // Carve corridor between doors using simple pathfinding if (doorA && doorB) { carveCorridor(doorA, doorB); } } function carveCorridor(start, end) { // Simple L-shaped corridor const path = []; let x = start.x, y = start.y; // Move horizontally first while (x !== end.x) { x += x < end.x ? 1 : -1; path.push({ x, y }); } // Then vertically while (y !== end.y) { y += y < end.y ? 1 : -1; path.push({ x, y }); } // Carve the path for (const pos of path) { if (getCell(pos.x, pos.y) === VOID || getCell(pos.x, pos.y) === WALL) { // Make sure we're not breaching rooms let canCarve = true; for (const room of rooms) { if (pos.x >= room.x && pos.x < room.x + room.w && pos.y >= room.y && pos.y < room.y + room.h) { canCarve = false; break; } } if (canCarve) { setCell(pos.x, pos.y, CORRIDOR); } } } } // Add walls around corridors for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { if (getCell(x, y) === VOID) { if (getCell(x-1, y) === CORRIDOR || getCell(x+1, y) === CORRIDOR || getCell(x, y-1) === CORRIDOR || getCell(x, y+1) === CORRIDOR) { setCell(x, y, WALL); } } } } return { grid, rooms, doors, doorOpen, doorOrient, W, H, connected: rooms.length > 0 }; } // Tileset generation function buildTileset(seed) { const rng = createRNG(seed + '_tiles'); const tileCanvas = document.createElement('canvas'); tileCanvas.width = tileCanvas.height = TILE_SIZE; const tileCtx = tileCanvas.getContext('2d'); // Generate palette const baseHue = rng() * 360; const roomHue = (baseHue + rng() * 60 - 30) % 360; const corridorHue = (baseHue + 180 + rng() * 60 - 30) % 360; const palette = { wall: hslToRgb(baseHue, 0.2 + rng() * 0.3, 0.3 + rng() * 0.2), room: hslToRgb(roomHue, 0.4 + rng() * 0.3, 0.4 + rng() * 0.2), corridor: hslToRgb(corridorHue, 0.3 + rng() * 0.3, 0.35 + rng() * 0.2), void: [0, 0, 0] }; function drawTile(color, noise = 0.1) { tileCtx.fillStyle = `rgb(${Math.floor(color[0] * 255)}, ${Math.floor(color[1] * 255)}, ${Math.floor(color[2] * 255)})`; tileCtx.fillRect(0, 0, TILE_SIZE, TILE_SIZE); // Add noise const imageData = tileCtx.getImageData(0, 0, TILE_SIZE, TILE_SIZE); for (let i = 0; i < imageData.data.length; i += 4) { const factor = 1 + (rng() - 0.5) * noise; imageData.data[i] *= factor; // R imageData.data[i + 1] *= factor; // G imageData.data[i + 2] *= factor; // B } tileCtx.putImageData(imageData, 0, 0); const img = new Image(); img.src = tileCanvas.toDataURL(); return img; } // Create door sprites function drawDoor(orient, open) { tileCtx.fillStyle = open ? '#8B4513' : '#654321'; tileCtx.fillRect(0, 0, TILE_SIZE, TILE_SIZE); if (orient === HORIZONTAL) { tileCtx.fillStyle = '#2C1810'; tileCtx.fillRect(0, TILE_SIZE * 0.4, TILE_SIZE, TILE_SIZE * 0.2); if (!open) { tileCtx.fillRect(TILE_SIZE * 0.2, TILE_SIZE * 0.3, TILE_SIZE * 0.6, TILE_SIZE * 0.4); } } else { tileCtx.fillStyle = '#2C1810'; tileCtx.fillRect(TILE_SIZE * 0.4, 0, TILE_SIZE * 0.2, TILE_SIZE); if (!open) { tileCtx.fillRect(TILE_SIZE * 0.3, TILE_SIZE * 0.2, TILE_SIZE * 0.4, TILE_SIZE * 0.6); } } const img = new Image(); img.src = tileCanvas.toDataURL(); return img; } // Create player sprite function drawPlayer() { tileCtx.fillStyle = 'transparent'; tileCtx.clearRect(0, 0, TILE_SIZE, TILE_SIZE); // Body tileCtx.fillStyle = '#4A4A4A'; tileCtx.fillRect(TILE_SIZE * 0.3, TILE_SIZE * 0.4, TILE_SIZE * 0.4, TILE_SIZE * 0.5); // Head tileCtx.fillStyle = '#F5DEB3'; tileCtx.beginPath(); tileCtx.arc(TILE_SIZE * 0.5, TILE_SIZE * 0.3, TILE_SIZE * 0.1, 0, Math.PI * 2); tileCtx.fill(); const img = new Image(); img.src = tileCanvas.toDataURL(); return img; } return { wall: drawTile(palette.wall, 0.15), room: drawTile(palette.room, 0.08), corridor: drawTile(palette.corridor, 0.1), void: drawTile(palette.void, 0), doorH: drawDoor(HORIZONTAL, false), doorV: drawDoor(VERTICAL, false), doorOpenH: drawDoor(HORIZONTAL, true), doorOpenV: drawDoor(VERTICAL, true), player: drawPlayer(), palette }; } // Base rendering function renderBaseToCanvas(dungeon, tiles) { const { grid, W, H } = dungeon; const canvas = document.createElement('canvas'); canvas.width = W * TILE_SIZE; canvas.height = H * TILE_SIZE; const ctx = canvas.getContext('2d'); const linearRGB = new Float32Array(W * H * 3); for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { const cell = grid[y * W + x]; let tile; switch (cell) { case WALL: tile = tiles.wall; break; case ROOM: tile = tiles.room; break; case CORRIDOR: tile = tiles.corridor; break; default: tile = tiles.void; break; } ctx.drawImage(tile, x * TILE_SIZE, y * TILE_SIZE); // Store linear RGB values const idx = (y * W + x) * 3; let color; switch (cell) { case WALL: color = tiles.palette.wall; break; case ROOM: color = tiles.palette.room; break; case CORRIDOR: color = tiles.palette.corridor; break; default: color = tiles.palette.void; break; } linearRGB[idx] = srgbToLinear(color[0]); linearRGB[idx + 1] = srgbToLinear(color[1]); linearRGB[idx + 2] = srgbToLinear(color[2]); } } return { canvas, linearRGB }; } // Lighting system function solveTorch(dungeon, playerPos, config) { const { tile, S, rays, step, radiusTiles, p = 1.0, eps = 0.1 } = config; const { grid, W, H, doorOpen } = dungeon; const subW = W * S, subH = H * S; const lightBuffer = new Float32Array(subW * subH); const seenMask = new Uint8Array(W * H); const ox = (playerPos.x + 0.5) * S; const oy = (playerPos.y + 0.5) * S; const maxDist = radiusTiles; function isBlocking(tx, ty) { if (tx < 0 || tx >= W || ty < 0 || ty >= H) return true; const cell = grid[ty * W + tx]; if (cell === WALL || cell === VOID) return true; if (cell === DOOR && !doorOpen[ty][tx]) return true; return false; } for (let r = 0; r < rays; r++) { const angle = (r / rays) * Math.PI * 2; const dx = Math.cos(angle); const dy = Math.sin(angle); let sx = ox, sy = oy; while (true) { const dist = Math.hypot(sx - ox, sy - oy) / S; if (dist > maxDist) break; const tx = Math.floor(sx / S); const ty = Math.floor(sy / S); if (isBlocking(tx, ty)) break; // Check for diagonal transitions (corner occluders) if (r > 0) { const prevSx = sx - dx * step * S; const prevSy = sy - dy * step * S; const prevTx = Math.floor(prevSx / S); const prevTy = Math.floor(prevSy / S); if (tx !== prevTx && ty !== prevTy) { if (isBlocking(tx, prevTy) && isBlocking(prevTx, ty)) { break; } } } const intensity = Math.pow(1 / (dist * dist + eps * eps), p); const bufIdx = Math.floor(sy) * subW + Math.floor(sx); if (bufIdx >= 0 && bufIdx < lightBuffer.length) { lightBuffer[bufIdx] = Math.max(lightBuffer[bufIdx], intensity); } // Mark tile as seen if (tx >= 0 && tx < W && ty >= 0 && ty < H) { seenMask[ty * W + tx] = 1; } sx += dx * step * S; sy += dy * step * S; } } function samplePix(px, py) { const subX = px / tile * S; const subY = py / tile * S; const idx = Math.floor(subY) * subW + Math.floor(subX); if (idx >= 0 && idx < lightBuffer.length) { return lightBuffer[idx]; } return 0; } return { samplePix, seenMask }; } // Composition function compose(viewCanvas, baseCanvas, baseLinear, sampler, exposure, region, memory, dungeon, tile, memIntensity, tiles, zoom) { const ctx = viewCanvas.getContext('2d'); const { x: px, y: py, w, h } = region; const { grid, W, H, doorOpen, doorOrient } = dungeon; // Scale canvas for zoom const scaledW = w * zoom; const scaledH = h * zoom; viewCanvas.width = scaledW; viewCanvas.height = scaledH; const imageData = ctx.createImageData(scaledW, scaledH); const data = imageData.data; for (let y = 0; y < scaledH; y++) { for (let x = 0; x < scaledW; x++) { const worldX = px + (x / zoom); const worldY = py + (y / zoom); const tx = Math.floor(worldX / tile); const ty = Math.floor(worldY / tile); let r = 0, g = 0, b = 0; if (tx >= 0 && tx < W && ty >= 0 && ty < H) { const tileIdx = ty * W + tx; const baseIdx = tileIdx * 3; const baseR = baseLinear[baseIdx]; const baseG = baseLinear[baseIdx + 1]; const baseB = baseLinear[baseIdx + 2]; const torchIntensity = sampler.samplePix(worldX, worldY); const memIntensityValue = memory[tileIdx] * memIntensity; const totalIntensity = exposure * torchIntensity + memIntensityValue; r = baseR * totalIntensity; g = baseG * totalIntensity; b = baseB * totalIntensity; r = Math.max(0, Math.min(1, r)); g = Math.max(0, Math.min(1, g)); b = Math.max(0, Math.min(1, b)); } const pixelIdx = (y * scaledW + x) * 4; data[pixelIdx] = Math.floor(linearToSrgb(r) * 255); data[pixelIdx + 1] = Math.floor(linearToSrgb(g) * 255); data[pixelIdx + 2] = Math.floor(linearToSrgb(b) * 255); data[pixelIdx + 3] = 255; } } ctx.putImageData(imageData, 0, 0); // Draw doors and player for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { const screenX = (x * tile - px) * zoom; const screenY = (y * tile - py) * zoom; if (screenX >= -tile * zoom && screenX < scaledW && screenY >= -tile * zoom && screenY < scaledH) { const tileIdx = y * W + x; const isLit = sampler.samplePix(x * tile + tile/2, y * tile + tile/2) > 0.001; const hasMemory = memory[tileIdx] > 0.01; if ((isLit || hasMemory) && grid[tileIdx] === DOOR) { let doorSprite; if (doorOrient[y][x] === HORIZONTAL) { doorSprite = doorOpen[y][x] ? tiles.doorOpenH : tiles.doorH; } else { doorSprite = doorOpen[y][x] ? tiles.doorOpenV : tiles.doorV; } ctx.save(); if (!isLit) { ctx.globalAlpha = memory[tileIdx] * 0.3; } ctx.drawImage(doorSprite, screenX, screenY, tile * zoom, tile * zoom); ctx.restore(); } } } } // Draw player const playerScreenX = (playerX * tile - px) * zoom; const playerScreenY = (playerY * tile - py) * zoom; if (playerScreenX >= -tile * zoom && playerScreenX < scaledW && playerScreenY >= -tile * zoom && playerScreenY < scaledH) { ctx.drawImage(tiles.player, playerScreenX, playerScreenY, tile * zoom, tile * zoom); } } // Door management function openDoorAt(x, y) { if (x >= 0 && x < dungeon.W && y >= 0 && y < dungeon.H && dungeon.grid[y * dungeon.W + x] === DOOR) { dungeon.doorOpen[y][x] = true; needsLightUpdate = true; } } function openAdjacentDoors(playerPos) { const dirs = [[-1, 0], [1, 0], [0, -1], [0, 1]]; for (const [dx, dy] of dirs) { openDoorAt(playerPos.x + dx, playerPos.y + dy); } } // Player movement function canMoveTo(x, y) { if (x < 0 || x >= dungeon.W || y < 0 || y >= dungeon.H) return false; const cell = dungeon.grid[y * dungeon.W + x]; if (cell === ROOM || cell === CORRIDOR) return true; if (cell === DOOR && dungeon.doorOpen[y][x]) return true; return false; } function movePlayer(dx, dy) { const newX = playerX + dx; const newY = playerY + dy; // Check if trying to move into a closed door if (newX >= 0 && newX < dungeon.W && newY >= 0 && newY < dungeon.H) { const cell = dungeon.grid[newY * dungeon.W + newX]; if (cell === DOOR && !dungeon.doorOpen[newY][newX]) { openDoorAt(newX, newY); // Don't move this frame, let the door open first return; } } if (canMoveTo(newX, newY)) { playerX = newX; playerY = newY; openAdjacentDoors({ x: playerX, y: playerY }); needsLightUpdate = true; } } // Spawning function findSpawnPoint() { const { grid, W, H } = dungeon; // Try corridors first for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { if (grid[y * W + x] === CORRIDOR) { return { x, y }; } } } // Fallback to room centers if (dungeon.rooms.length > 0) { const room = dungeon.rooms[0]; return { x: Math.floor(room.cx), y: Math.floor(room.cy) }; } // Last resort for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { if (grid[y * W + x] === ROOM) { return { x, y }; } } } return { x: 0, y: 0 }; } // Main functions function generate() { const config = { W: parseInt(document.getElementById('mapW').value), H: parseInt(document.getElementById('mapH').value), targetRooms: parseInt(document.getElementById('rooms').value), rmin: parseInt(document.getElementById('rmin').value), rmax: parseInt(document.getElementById('rmax').value), seed: document.getElementById('seed').value }; dungeon = generateDungeon(config); tiles = buildTileset(config.seed); const baseResult = renderBaseToCanvas(dungeon, tiles); baseCanvas = baseResult.canvas; baseLinearRGB = baseResult.linearRGB; memory = new Float32Array(dungeon.W * dungeon.H); const spawn = findSpawnPoint(); playerX = spawn.x; playerY = spawn.y; needsLightUpdate = true; document.getElementById('status').textContent = `Generated ${dungeon.W}×${dungeon.H} dungeon with ${dungeon.rooms.length} rooms`; render(); } function respawn() { if (!dungeon) return; const spawn = findSpawnPoint(); playerX = spawn.x; playerY = spawn.y; needsLightUpdate = true; render(); } function getLightQuality() { const quality = document.getElementById('quality').value; switch (quality) { case 'low': return { S: 3, rays: 900, step: 0.3 }; case 'high': return { S: 5, rays: 1800, step: 0.2 }; default: return { S: 4, rays: 1300, step: 0.25 }; } } function render() { if (!dungeon || !baseCanvas) return; const now = performance.now(); const dt = (now - lastFrameTime) / 1000; lastFrameTime = now; // Update memory decay const halfLife = parseFloat(document.getElementById('memHalfLife').value); const decayFactor = Math.pow(0.5, dt / halfLife); for (let i = 0; i < memory.length; i++) { memory[i] *= decayFactor; } // Update lighting if needed if (needsLightUpdate) { const quality = getLightQuality(); const viewW = parseInt(document.getElementById('viewW').value); const viewH = parseInt(document.getElementById('viewH').value); const radiusTiles = Math.max(viewW, viewH) / 2 + 2; const result = solveTorch(dungeon, { x: playerX, y: playerY }, { tile: TILE_SIZE, S: quality.S, rays: quality.rays, step: quality.step, radiusTiles: radiusTiles }); lightSampler = result.samplePix; seenMask = result.seenMask; // Update memory for seen tiles for (let i = 0; i < seenMask.length; i++) { if (seenMask[i]) { memory[i] = 1.0; } } needsLightUpdate = false; } // Camera and composition if (lightSampler) { const viewW = parseInt(document.getElementById('viewW').value); const viewH = parseInt(document.getElementById('viewH').value); const zoom = parseFloat(document.getElementById('zoom').value); const exposure = parseFloat(document.getElementById('exposure').value); const cameraX = (playerX + 0.5) * TILE_SIZE - (viewW * TILE_SIZE) / 2; const cameraY = (playerY + 0.5) * TILE_SIZE - (viewH * TILE_SIZE) / 2; const clampedX = Math.max(0, Math.min(cameraX, dungeon.W * TILE_SIZE - viewW * TILE_SIZE)); const clampedY = Math.max(0, Math.min(cameraY, dungeon.H * TILE_SIZE - viewH * TILE_SIZE)); const region = { px: clampedX, py: clampedY, w: viewW * TILE_SIZE, h: viewH * TILE_SIZE }; compose(canvas, baseCanvas, baseLinearRGB, { samplePix: lightSampler }, exposure, region, memory, dungeon, TILE_SIZE, 0.08, tiles, zoom); } requestAnimationFrame(render); } // UI helpers function updateSliderValue(sliderId, valueId) { document.getElementById(valueId).textContent = document.getElementById(sliderId).value; } function randomizeSeed() { document.getElementById('seed').value = 'dungeon_' + Math.floor(Math.random() * 1000000); } function exportView() { const link = document.createElement('a'); link.download = 'dungeon_view.png'; link.href = canvas.toDataURL(); link.click(); } // Controls document.addEventListener('keydown', (e) => { if (!dungeon) return; switch (e.key) { case 'w': case 'W': case 'ArrowUp': movePlayer(0, -1); break; case 's': case 'S': case 'ArrowDown': movePlayer(0, 1); break; case 'a': case 'A': case 'ArrowLeft': movePlayer(-1, 0); break; case 'd': case 'D': case 'ArrowRight': movePlayer(1, 0); break; case 'n': case 'N': generate(); break; case 'r': case 'R': respawn(); break; } }); // Slider updates ['mapW', 'mapH', 'rooms', 'rmin', 'rmax', 'viewW', 'viewH', 'zoom', 'exposure', 'memHalfLife'] .forEach(id => { const slider = document.getElementById(id); const valueSpan = document.getElementById(id + 'Value'); slider.addEventListener('input', () => updateSliderValue(id, id + 'Value')); }); // Initialize generate(); </script> </body> </html> ```