(function () { 'use strict'; const LEVEL_FORMAT_VERSION = 1; const TILE_NAMES = [ 'Empty', 'STARTAN', 'STONE', 'TECH', 'DOOR', 'SWITCH', 'Secret(Brick)', 'Secret(Stone)', 'Secret(Tech)', 'Key Red', 'Key Yellow', 'Key Blue', ]; const DOOR_TILES = [4, 6, 7, 8, 9, 10, 11]; /** In raycaster preview, player can walk through doors and switches for easier level design. */ const PREVIEW_PASSTHROUGH_TILES = [4, 5, 6, 7, 8, 9, 10, 11]; // DOOR, SWITCH, secret doors, key doors const ENEMY_NAMES = ['Zombie', 'Sergeant', 'Imp', 'Demon', 'Commando']; const PICKUP_NAMES = [ 'Ammo clip', 'Health small', 'Health large', 'Shotgun', 'Helmet', 'Armor', 'Shells', 'Rocket launcher', 'Key Red', 'Key Yellow', 'Key Blue', 'Chaingun', ]; /** Game limits (must match enemy.h, pickup.h, door.h). Editor enforces these and shows caps in UI. */ const GAME_LIMITS = { MAX_ENEMIES: 21, MAX_PICKUPS: 16, MAX_DOORS: 24, MAX_SWITCHES: 4, }; let state = { mapW: 32, mapH: 32, map: null, spawn1: null, spawn2: null, enemies: [], pickups: [], switchLinks: [], brush: 1, placing: null, enemyType: 0, pickupType: 0, hoverCell: null, // { x, y, type, index? } or null hoverEntity: null, // { type: 'spawn1'|'spawn2'|'enemy'|'pickup', index } or null linkMode: false, pendingSwitchIndex: null, previewPos: null, // { x, y } in tile space (float) or null to use spawn1 previewAngle: null, // 0-1023 or null to use spawn1.angle }; function cellIndex(x, y) { return y * state.mapW + x; } function getCell(x, y) { return state.map[cellIndex(x, y)]; } function setCell(x, y, v) { const isBorder = x === 0 || x === state.mapW - 1 || y === 0 || y === state.mapH - 1; if (isBorder && v === 0) v = 1; state.map[cellIndex(x, y)] = v; } function fillBorderWalls() { const w = state.mapW, h = state.mapH; for (let x = 0; x < w; x++) { state.map[cellIndex(x, 0)] = 1; state.map[cellIndex(x, h - 1)] = 1; } for (let y = 0; y < h; y++) { state.map[cellIndex(0, y)] = 1; state.map[cellIndex(w - 1, y)] = 1; } } function resizeMap(w, h) { const newMap = new Uint8Array(w * h); const copyW = Math.min(w, state.mapW); const copyH = Math.min(h, state.mapH); for (let y = 0; y < copyH; y++) for (let x = 0; x < copyW; x++) newMap[y * w + x] = getCell(x, y); state.mapW = w; state.mapH = h; state.map = newMap; state.spawn1 = state.spawn1 && state.spawn1.x < w && state.spawn1.y < h ? state.spawn1 : null; state.spawn2 = state.spawn2 && state.spawn2.x < w && state.spawn2.y < h ? state.spawn2 : null; state.enemies = state.enemies.filter((e) => e.tileX < w && e.tileY < h); state.pickups = state.pickups.filter((p) => p.tileX < w && p.tileY < h); state.switchLinks = []; fillBorderWalls(); refreshDoorSwitchLists(); } function getDoors() { const list = []; for (let y = 0; y < state.mapH; y++) for (let x = 0; x < state.mapW; x++) if (DOOR_TILES.includes(getCell(x, y))) list.push({ tileX: x, tileY: y, tile: getCell(x, y) }); return list; } function getSwitches() { const list = []; for (let y = 0; y < state.mapH; y++) for (let x = 0; x < state.mapW; x++) if (getCell(x, y) === 5) list.push({ tileX: x, tileY: y }); return list; } function getCellInfo(x, y) { if (x < 0 || x >= state.mapW || y < 0 || y >= state.mapH) return null; if (state.spawn1 && state.spawn1.x === x && state.spawn1.y === y) return { type: 'spawn1', index: 0, label: 'Spawn 1' }; if (state.spawn2 && state.spawn2.x === x && state.spawn2.y === y) return { type: 'spawn2', index: 0, label: 'Spawn 2' }; const ei = state.enemies.findIndex((e) => e.tileX === x && e.tileY === y); if (ei >= 0) return { type: 'enemy', index: ei, label: ENEMY_NAMES[state.enemies[ei].type] + ' (' + x + ',' + y + ')', }; const pi = state.pickups.findIndex((p) => p.tileX === x && p.tileY === y); if (pi >= 0) return { type: 'pickup', index: pi, label: PICKUP_NAMES[state.pickups[pi].type] + ' (' + x + ',' + y + ')', }; const doors = getDoors(); const di = doors.findIndex((d) => d.tileX === x && d.tileY === y); if (di >= 0) return { type: 'door', index: di, label: 'Door ' + di }; const switches = getSwitches(); const si = switches.findIndex((s) => s.tileX === x && s.tileY === y); if (si >= 0) { const link = state.switchLinks[si]; const linkStr = link === -1 ? 'EXIT' : 'Door ' + link; return { type: 'switch', index: si, label: 'Switch ' + si + ' → ' + linkStr }; } const t = getCell(x, y); return { type: 'empty', index: 0, label: t === 0 ? 'Empty' : TILE_NAMES[t] }; } function refreshDoorSwitchLists() { const doors = getDoors(); const switches = getSwitches(); while (state.switchLinks.length < switches.length) state.switchLinks.push(0); state.switchLinks.length = switches.length; const doorList = document.getElementById('doorList'); const doorCap = GAME_LIMITS.MAX_DOORS; const switchCap = GAME_LIMITS.MAX_SWITCHES; const doorOver = doors.length > doorCap; const switchOver = switches.length > switchCap; const doorHeader = document.getElementById('doorListCap'); if (doorHeader) { doorHeader.textContent = 'Doors: ' + doors.length + ' / ' + doorCap; doorHeader.classList.toggle('over-limit', doorOver); } const switchHeader = document.getElementById('switchListCap'); if (switchHeader) { switchHeader.textContent = 'Switches: ' + switches.length + ' / ' + switchCap; switchHeader.classList.toggle('over-limit', switchOver); } doorList.innerHTML = ''; if (doors.length) { doors.forEach((d, i) => { const li = document.createElement('li'); li.textContent = 'Door ' + i + ' (' + d.tileX + ',' + d.tileY + ')'; doorList.appendChild(li); }); } else { doorList.appendChild(document.createElement('li')).textContent = 'None'; } const switchList = document.getElementById('switchList'); switchList.innerHTML = ''; if (switches.length) { switches.forEach((s, i) => { const li = document.createElement('li'); const sel = document.createElement('select'); sel.className = 'switch-link'; sel.innerHTML = '' + doors.map((_, d) => ``).join(''); sel.value = String(state.switchLinks[i] === -1 ? -1 : state.switchLinks[i]); sel.addEventListener('change', () => { state.switchLinks[i] = parseInt(sel.value, 10); }); li.appendChild(document.createTextNode(`Switch ${i} (${s.tileX},${s.tileY}) → `)); li.appendChild(sel); switchList.appendChild(li); }); } else { switchList.appendChild(document.createElement('li')).textContent = 'None'; } } function initMap() { const size = document.getElementById('mapSize').value.split(',').map(Number); const w = size[0], h = size[1]; if (state.map === null) { state.map = new Uint8Array(w * h); state.mapW = w; state.mapH = h; } else resizeMap(w, h); fillBorderWalls(); document.getElementById('status').textContent = `Map ${state.mapW}×${state.mapH}. Click or drag to paint.`; refreshDoorSwitchLists(); refreshEntityList(); state.previewPos = state.spawn1 ? { x: state.spawn1.x + 0.5, y: state.spawn1.y + 0.5 } : { x: state.mapW / 2, y: state.mapH / 2 }; state.previewAngle = state.spawn1 ? (state.spawn1.angle != null ? state.spawn1.angle : 512) : 0; drawGrid(); if (typeof resizePreview === 'function') resizePreview(); } const canvas = document.getElementById('grid'); const ctx = canvas.getContext('2d'); const CELL_PX = 12; const MAX_CANVAS = 600; function drawGrid() { const w = state.mapW; const h = state.mapH; let cellPx = CELL_PX; let cw = w * cellPx, ch = h * cellPx; if (cw > MAX_CANVAS || ch > MAX_CANVAS) { cellPx = Math.floor(MAX_CANVAS / Math.max(w, h)); cw = w * cellPx; ch = h * cellPx; } canvas.width = cw; canvas.height = ch; canvas.dataset.cellPx = cellPx; for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) { let tile = getCell(x, y); ctx.fillStyle = tile <= 11 ? [ '#1a0a0a', '#8b4513', '#555', '#4a4a6a', '#6a3a2a', '#5a4a3a', '#7a3525', '#454545', '#3a3a5a', '#8b2a2a', '#8b7a2a', '#2a4a8b', ][tile] : '#333'; ctx.fillRect(x * cellPx, y * cellPx, cellPx, cellPx); if (DOOR_TILES.includes(tile)) { ctx.strokeStyle = '#aaa'; ctx.lineWidth = 1; ctx.strokeRect(x * cellPx, y * cellPx, cellPx, cellPx); } } ctx.strokeStyle = '#333'; ctx.lineWidth = 1; for (let x = 0; x <= w; x++) ctx.strokeRect(x * cellPx, 0, 0, ch); for (let y = 0; y <= h; y++) ctx.strokeRect(0, y * cellPx, cw, 0); const cx = (x) => (x + 0.5) * cellPx; const cy = (y) => (y + 0.5) * cellPx; const r = cellPx * 0.4; const facingLen = cellPx * 0.48; const drawFacing = (centerX, centerY, angle, strokeColor) => { const angleRad = (angle / 1024) * 2 * Math.PI - Math.PI / 2; ctx.strokeStyle = strokeColor || '#fff'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(centerX, centerY); ctx.lineTo( centerX + Math.cos(angleRad) * facingLen, centerY + Math.sin(angleRad) * facingLen, ); ctx.stroke(); }; if (state.spawn1) { const x = state.spawn1.x, y = state.spawn1.y; const angle = state.spawn1.angle != null ? state.spawn1.angle : 512; ctx.fillStyle = '#0a3'; ctx.fillRect(x * cellPx, y * cellPx, cellPx, cellPx); ctx.strokeStyle = '#0a5'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(cx(x) - r, cy(y)); ctx.lineTo(cx(x) + r, cy(y)); ctx.moveTo(cx(x), cy(y) - r); ctx.lineTo(cx(x), cy(y) + r); ctx.stroke(); drawFacing(cx(x), cy(y), angle, '#fff'); } if (state.spawn2) { const x = state.spawn2.x, y = state.spawn2.y; const angle = state.spawn2.angle != null ? state.spawn2.angle : 512; ctx.fillStyle = '#03a'; ctx.fillRect(x * cellPx, y * cellPx, cellPx, cellPx); ctx.strokeStyle = '#05a'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(cx(x) - r, cy(y)); ctx.lineTo(cx(x) + r, cy(y)); ctx.moveTo(cx(x), cy(y) - r); ctx.lineTo(cx(x), cy(y) + r); ctx.stroke(); drawFacing(cx(x), cy(y), angle, '#fff'); } const ENEMY_GRID_COLORS = ['#e05050', '#8b8b00', '#e08020', '#a020a0', '#00cc80']; const PICKUP_GRID_COLORS = [ '#ffcc00', '#50ff50', '#50ffff', '#ff9050', '#c0c0c0', '#8080ff', '#ffa060', '#ff4040', '#ff2020', /* Key Red */ '#ffff20', /* Key Yellow */ '#2020ff', /* Key Blue */ '#ff8020', /* Chaingun */ ]; state.enemies.forEach((e) => { ctx.fillStyle = ENEMY_GRID_COLORS[e.type] || '#e00'; ctx.beginPath(); ctx.arc(cx(e.tileX), cy(e.tileY), r, 0, 2 * Math.PI); ctx.fill(); ctx.strokeStyle = '#c00'; ctx.lineWidth = 1.5; ctx.stroke(); drawFacing(cx(e.tileX), cy(e.tileY), e.angle != null ? e.angle : 0, '#fff'); }); state.pickups.forEach((p) => { const x = p.tileX, y = p.tileY; const left = x * cellPx + cellPx * 0.2, right = (x + 1) * cellPx - cellPx * 0.2; const top = y * cellPx + cellPx * 0.15, bottom = (y + 1) * cellPx - cellPx * 0.15; ctx.fillStyle = PICKUP_GRID_COLORS[p.type] || '#5a5'; ctx.beginPath(); ctx.moveTo(cx(x), top); ctx.lineTo(right, bottom); ctx.lineTo(left, bottom); ctx.closePath(); ctx.fill(); }); if (state.hoverEntity) { let hx, hy; if (state.hoverEntity.type === 'spawn1' && state.spawn1) { hx = state.spawn1.x; hy = state.spawn1.y; } else if (state.hoverEntity.type === 'spawn2' && state.spawn2) { hx = state.spawn2.x; hy = state.spawn2.y; } else if (state.hoverEntity.type === 'enemy' && state.enemies[state.hoverEntity.index]) { const e = state.enemies[state.hoverEntity.index]; hx = e.tileX; hy = e.tileY; } else if (state.hoverEntity.type === 'pickup' && state.pickups[state.hoverEntity.index]) { const p = state.pickups[state.hoverEntity.index]; hx = p.tileX; hy = p.tileY; } else { hx = null; hy = null; } if (hx != null && hy != null) { ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.strokeRect(hx * cellPx, hy * cellPx, cellPx, cellPx); } } if (state.linkMode && state.pendingSwitchIndex !== null) { const switches = getSwitches(); const sw = switches[state.pendingSwitchIndex]; if (sw) { ctx.strokeStyle = '#ff0'; ctx.lineWidth = 2; ctx.strokeRect(sw.tileX * cellPx, sw.tileY * cellPx, cellPx, cellPx); } } const overlay = document.getElementById('gridOverlay'); if (overlay) { overlay.width = cw; overlay.height = ch; } } function drawPreviewOnMap() { const overlay = document.getElementById('gridOverlay'); if (!overlay || overlay.width === 0 || overlay.height === 0 || !state.map) return; const cellPx = Number(canvas.dataset.cellPx) || CELL_PX; const preview = getPreviewPlayer(); const ox = overlay.getContext('2d'); ox.clearRect(0, 0, overlay.width, overlay.height); const px = preview.x * cellPx, py = preview.y * cellPx; const previewR = Math.max(3, cellPx * 0.2); const len = cellPx * 0.5; ox.fillStyle = '#ff0'; ox.beginPath(); ox.arc(px, py, previewR, 0, 2 * Math.PI); ox.fill(); ox.strokeStyle = '#aa0'; ox.lineWidth = 1; ox.stroke(); ox.strokeStyle = '#ff0'; ox.lineWidth = 2; const angleRad = (preview.angle / 1024) * 2 * Math.PI - Math.PI / 2; ox.beginPath(); ox.moveTo(px, py); ox.lineTo(px + Math.cos(angleRad) * len, py + Math.sin(angleRad) * len); ox.stroke(); } function getPreviewPlayer() { const x = state.previewPos != null ? state.previewPos.x : state.mapW / 2; const y = state.previewPos != null ? state.previewPos.y : state.mapH / 2; const angle = state.previewAngle != null ? state.previewAngle : 0; return { x, y, angle }; } const WALL_COLORS = [ '#1a0a0a', '#8b4513', '#555', '#4a4a6a', '#6a3a2a', '#5a4a3a', '#7a3525', '#454545', '#3a3a5a', '#8b2a2a', '#8b7a2a', '#2a4a8b', ]; const WALL_TEXTURE_URLS = [ null, '../doom_graphics_unvirtualboyed/wall_textures/WALL01_1.png', '../doom_graphics_unvirtualboyed/wall_textures/WALL02_1.png', '../doom_graphics_unvirtualboyed/wall_textures/WALL03_1.png', '../door_texture.png', '../gaint-switch1-doom-palette.png', '../doom_graphics_unvirtualboyed/wall_textures/WALL01_1.png', '../doom_graphics_unvirtualboyed/wall_textures/WALL02_1.png', '../doom_graphics_unvirtualboyed/wall_textures/WALL03_1.png', '../door_texture.png', '../door_texture.png', '../door_texture.png', ]; const wallTextures = []; (function loadWallTextures() { for (let t = 1; t <= 11; t++) { const img = new Image(); img.src = WALL_TEXTURE_URLS[t] || ''; wallTextures[t] = img; } })(); const ENEMY_SPRITE_URLS = [ '../doom_graphics_unvirtualboyed/monster_imp/TROOA1.png', '../doom_graphics_unvirtualboyed/monster_imp/TROOA2A8.png', '../doom_graphics_unvirtualboyed/monster_imp/TROOA3A7.png', '../doom_graphics_unvirtualboyed/monster_imp/TROOA4A6.png', '../doom_graphics_unvirtualboyed/monster_imp/TROOA5.png', ]; const enemySprites = []; (function loadEnemySprites() { ENEMY_SPRITE_URLS.forEach((url, i) => { const img = new Image(); img.src = url; enemySprites[i] = img; }); })(); const spritePlayer = new Image(); spritePlayer.src = '../doom_graphics_unvirtualboyed/player/PLAYA1.png'; const spritePickup = new Image(); spritePickup.src = '../doom_graphics_unvirtualboyed/stage_pickups_n_shit/AMMOA0.png'; const spritePistol = new Image(); spritePistol.src = '../doom_graphics_unvirtualboyed/pistol/PISGA0.png'; function getEnemySpriteIndex(ent, px, py) { // Sprite sheet uses angle from ENEMY TO PLAYER; atan2 gives player→enemy, so add π const dx = ent.x - px, dy = ent.y - py; let angle = Math.atan2(dx, -dy) + Math.PI; if (angle < 0) angle += 2 * Math.PI; let segment = Math.floor((angle / (2 * Math.PI)) * 8) % 8; // Offset by enemy's facing so the dropdown direction updates the raycaster const enemy = state.enemies[ent.i]; const faceAngle = enemy.angle != null ? enemy.angle : 0; segment = (segment + Math.floor(faceAngle / 128)) % 8; return segment; } function drawRaycastPreview(ctx, width, height) { const player = getPreviewPlayer(); const px = player.x, py = player.y; const angleRad = (player.angle / 1024) * 2 * Math.PI - Math.PI / 2; const fov = Math.PI / 3; const halfFov = fov / 2; const numRays = width; const W = state.mapW, H = state.mapH; ctx.fillStyle = '#1a1a2a'; ctx.fillRect(0, 0, width, height / 2); ctx.fillStyle = '#2a1a1a'; ctx.fillRect(0, height / 2, width, height / 2); const step = 0.02; for (let col = 0; col < numRays; col++) { const t = col / numRays - 0.5; const rayAngle = angleRad + t * fov; const dx = Math.cos(rayAngle), dy = Math.sin(rayAngle); let x = px, y = py; let dist = 0; let hit = 0; let hitX = 0, hitY = 0; for (let i = 0; i < 400; i++) { const gx = Math.floor(x), gy = Math.floor(y); if (gx < 0 || gx >= W || gy < 0 || gy >= H) break; const cell = getCell(gx, gy); if (cell > 0 && !PREVIEW_PASSTHROUGH_TILES.includes(cell)) { hit = cell; hitX = x; hitY = y; break; } x += dx * step; y += dy * step; dist += step; } if (hit > 0 && hit <= 11) { const gx = Math.floor(hitX), gy = Math.floor(hitY); const fracX = hitX - gx, fracY = hitY - gy; const minDist = Math.min(fracX, 1 - fracX, fracY, 1 - fracY); const texU = minDist === fracX || minDist === 1 - fracX ? fracY : fracX; const wallHeight = Math.min(height * 1.2, height / (dist + 0.01)); const top = (height - wallHeight) / 2; const shade = Math.min(1, 1 / (dist * 0.3)); const img = wallTextures[hit]; if (img && img.complete && img.naturalWidth) { const tw = img.naturalWidth, th = img.naturalHeight; const srcX = Math.floor(texU * tw) % tw; ctx.globalAlpha = shade; ctx.drawImage(img, srcX, 0, 1, th, col, top, 1, wallHeight); ctx.globalAlpha = 1; } else { ctx.fillStyle = WALL_COLORS[hit]; ctx.globalAlpha = shade; ctx.fillRect(col, top, 1, wallHeight); ctx.globalAlpha = 1; } } } const viewAngle = (player.angle / 1024) * 2 * Math.PI; const halfFovRad = fov / 2; const proj = width / 2 / Math.tan(halfFovRad); const SPRITE_WORLD_SIZE = 0.8; const ENEMY_COLORS = ['#e05050', '#8b8b00', '#e08020', '#a020a0', '#00cc80']; const PICKUP_COLORS = [ '#ffcc00', '#50ff50', '#50ffff', '#ff9050', '#c0c0c0', '#8080ff', '#ffa060', '#ff4040', '#ff2020', /* Key Red */ '#ffff20', /* Key Yellow */ '#2020ff', /* Key Blue */ '#ff8020', /* Chaingun */ ]; const entities = []; state.enemies.forEach((e, i) => entities.push({ x: e.tileX + 0.5, y: e.tileY + 0.5, type: 'enemy', i, enemyType: e.type }), ); state.pickups.forEach((p, i) => entities.push({ x: p.tileX + 0.5, y: p.tileY + 0.5, type: 'pickup', i, pickupType: p.type }), ); if (state.spawn1 && (Math.floor(px) !== state.spawn1.x || Math.floor(py) !== state.spawn1.y)) entities.push({ x: state.spawn1.x + 0.5, y: state.spawn1.y + 0.5, type: 'spawn', id: 1 }); if (state.spawn2) entities.push({ x: state.spawn2.x + 0.5, y: state.spawn2.y + 0.5, type: 'spawn', id: 2 }); entities.forEach((ent) => { const dx = ent.x - px, dy = ent.y - py; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < 0.05) return; const angleTo = Math.atan2(dx, -dy); let angleDiff = angleTo - viewAngle; while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI; while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI; if (angleDiff < -halfFovRad || angleDiff > halfFovRad) return; ent.depth = dist * Math.cos(angleDiff); ent.angleDiff = angleDiff; ent.dist = dist; }); const visible = entities.filter((e) => e.depth != null); // Raycast from player to each entity; skip if blocked by wall (reuses step from above) const raycastTo = (ex, ey) => { const rdx = ex - px, rdy = ey - py; const rdist = Math.sqrt(rdx * rdx + rdy * rdy); if (rdist < 0.05) return true; const rnx = (rdx / rdist) * step, rny = (rdy / rdist) * step; let rx = px, ry = py; const steps = Math.ceil(rdist / step); for (let i = 0; i < steps; i++) { rx += rnx; ry += rny; const gx = Math.floor(rx), gy = Math.floor(ry); if (gx < 0 || gx >= W || gy < 0 || gy >= H) return false; const c = getCell(gx, gy); if (c > 0 && !PREVIEW_PASSTHROUGH_TILES.includes(c)) return false; // hit solid wall before entity } return true; }; const reallyVisible = visible.filter((ent) => raycastTo(ent.x, ent.y)); reallyVisible.sort((a, b) => b.depth - a.depth); const ENEMY_SPRITE_MAP = [0, 1, 2, 3, 4, 3, 2, 1]; const ENEMY_SPRITE_FLIP = [false, false, false, false, false, true, true, true]; reallyVisible.forEach((ent) => { const dist = ent.dist; const angleDiff = ent.angleDiff; const depth = ent.depth; const screenX = (width / 2) * (1 + Math.tan(angleDiff) / Math.tan(halfFovRad)); const h = Math.max(8, Math.min(height * 0.9, (SPRITE_WORLD_SIZE * proj) / depth)); const w = h; const left = screenX - w / 2; const top = (height - h) / 2; if (left + w < 0 || left > width) return; ctx.globalAlpha = 1; // full opacity for sprites (was distance shading) if (ent.type === 'enemy') { const segment = getEnemySpriteIndex(ent, px, py); const imgIdx = ENEMY_SPRITE_MAP[segment]; const img = enemySprites[imgIdx]; if (img && img.complete && img.naturalWidth) { const w = (img.naturalWidth / img.naturalHeight) * h; const x = screenX - w / 2; if (ENEMY_SPRITE_FLIP[segment]) { ctx.save(); ctx.translate(screenX, 0); ctx.scale(-1, 1); ctx.translate(-screenX, 0); ctx.drawImage(img, x, top, w, h); ctx.restore(); } else { ctx.drawImage(img, x, top, w, h); } } else { const c = ENEMY_COLORS[ent.enemyType] || '#e00'; ctx.fillStyle = c; ctx.fillRect(screenX - 4, top, 8, h); } } else if (ent.type === 'pickup') { const img = spritePickup; if (img && img.complete && img.naturalWidth) { const w = (img.naturalWidth / img.naturalHeight) * h; ctx.drawImage(img, screenX - w / 2, top, w, h); } else { const c = PICKUP_COLORS[ent.pickupType] || '#5a5'; ctx.fillStyle = c; ctx.fillRect(screenX - 4, top, 8, h); } } else { const img = spritePlayer; if (img && img.complete && img.naturalWidth) { const w = (img.naturalWidth / img.naturalHeight) * h; ctx.drawImage(img, screenX - w / 2, top, w, h); } else { ctx.fillStyle = '#0a5'; ctx.fillRect(screenX - 4, top, 8, h); ctx.fillStyle = '#fff'; ctx.font = '10px sans-serif'; ctx.fillText(String(ent.id || ''), screenX - 3, top + 10); } } ctx.globalAlpha = 1; }); ctx.globalAlpha = 1; if (spritePistol.complete && spritePistol.naturalWidth) { const pw = spritePistol.naturalWidth, ph = spritePistol.naturalHeight; const scale = Math.min(width / pw, (height * 0.4) / ph); const w = pw * scale, h = ph * scale; ctx.drawImage(spritePistol, (width - w) / 2, height - h, w, h); } } function updateTooltip(clientX, clientY, info) { const el = document.getElementById('gridTooltip'); if (!info) { el.classList.add('hidden'); el.textContent = ''; return; } el.textContent = info.label; el.classList.remove('hidden'); el.style.left = clientX + 10 + 'px'; el.style.top = clientY + 10 + 'px'; } const ANGLE_OPTIONS = [ [0, 'N'], [128, 'NE'], [256, 'E'], [384, 'SE'], [512, 'S'], [640, 'SW'], [768, 'W'], [896, 'NW'], ]; function refreshEntityList() { const ul = document.getElementById('entityList'); ul.innerHTML = ''; const add = (entityType, index, label, getAngle, setAngle) => { const li = document.createElement('li'); li.setAttribute('data-entity', entityType); li.setAttribute('data-index', String(index)); li.appendChild(document.createTextNode(label)); if (getAngle != null && setAngle != null) { const sel = document.createElement('select'); sel.className = 'entity-angle'; sel.title = 'Direction'; ANGLE_OPTIONS.forEach(([val, dir]) => { const opt = document.createElement('option'); opt.value = String(val); opt.textContent = dir; sel.appendChild(opt); }); const current = getAngle(); const closest = ANGLE_OPTIONS.reduce((a, b) => Math.abs(a[0] - current) <= Math.abs(b[0] - current) ? a : b, ); sel.value = String(closest[0]); sel.addEventListener('change', () => { setAngle(parseInt(sel.value, 10)); drawGrid(); }); li.appendChild(document.createTextNode(' ')); li.appendChild(sel); } li.addEventListener('mouseenter', () => { state.hoverEntity = { type: entityType, index }; drawGrid(); }); li.addEventListener('mouseleave', () => { state.hoverEntity = null; drawGrid(); }); ul.appendChild(li); }; if (state.spawn1) add( 'spawn1', 0, 'Spawn 1 (' + state.spawn1.x + ',' + state.spawn1.y + ')', () => (state.spawn1.angle != null ? state.spawn1.angle : 512), (v) => { state.spawn1.angle = v; }, ); const enemyCap = GAME_LIMITS.MAX_ENEMIES; const pickupCap = GAME_LIMITS.MAX_PICKUPS; const entityCapEl = document.getElementById('entityListCap'); if (entityCapEl) { entityCapEl.textContent = 'Enemies: ' + state.enemies.length + ' / ' + enemyCap + ' · Pickups: ' + state.pickups.length + ' / ' + pickupCap; entityCapEl.classList.toggle( 'over-limit', state.enemies.length > enemyCap || state.pickups.length > pickupCap, ); } if (state.spawn2) add( 'spawn2', 0, 'Spawn 2 (' + state.spawn2.x + ',' + state.spawn2.y + ')', () => (state.spawn2.angle != null ? state.spawn2.angle : 512), (v) => { state.spawn2.angle = v; }, ); state.enemies.forEach((e, i) => add( 'enemy', i, ENEMY_NAMES[e.type] + ' (' + e.tileX + ',' + e.tileY + ')', () => (state.enemies[i].angle != null ? state.enemies[i].angle : 0), (v) => { state.enemies[i].angle = v; }, ), ); state.pickups.forEach((p, i) => add('pickup', i, PICKUP_NAMES[p.type] + ' (' + p.tileX + ',' + p.tileY + ')', null, null), ); if (!ul.children.length) { const li = document.createElement('li'); li.textContent = 'None'; ul.appendChild(li); } updateEntityListHighlight(); } /** Show a short-lived message (toast) so user sees limits / errors without blocking. */ let toastTimeout = null; function showToast(message, durationMs) { if (durationMs == null) durationMs = 2800; const el = document.getElementById('toast'); if (!el) return; el.textContent = message; el.classList.remove('hidden'); if (toastTimeout) clearTimeout(toastTimeout); toastTimeout = setTimeout(() => { el.classList.add('hidden'); toastTimeout = null; }, durationMs); } function updateEntityListHighlight() { document.querySelectorAll('#entityList [data-entity]').forEach((li) => { const type = li.getAttribute('data-entity'); const index = parseInt(li.getAttribute('data-index'), 10); const match = state.hoverCell && state.hoverCell.type === type && state.hoverCell.index === index; li.classList.toggle('highlight', !!match); }); } function xyFromEvent(e) { const rect = canvas.getBoundingClientRect(); const cellPx = Number(canvas.dataset.cellPx) || CELL_PX; const x = Math.floor((e.clientX - rect.left) / cellPx); const y = Math.floor((e.clientY - rect.top) / cellPx); return { x, y }; } function onGridClick(e) { const { x, y } = xyFromEvent(e); if (x < 0 || x >= state.mapW || y < 0 || y >= state.mapH) return; if (state.linkMode) { const switches = getSwitches(); const doors = getDoors(); const switchIdx = switches.findIndex((s) => s.tileX === x && s.tileY === y); const doorIdx = doors.findIndex((d) => d.tileX === x && d.tileY === y); if (state.pendingSwitchIndex === null) { if (switchIdx >= 0) { state.pendingSwitchIndex = switchIdx; document.getElementById('pendingSwitchNum').textContent = String(switchIdx); document.getElementById('linkModeExit').classList.remove('hidden'); document.getElementById('status').textContent = 'Switch ' + switchIdx + ' selected. Click a door to link, or set EXIT below.'; drawGrid(); } return; } if (doorIdx >= 0) { state.switchLinks[state.pendingSwitchIndex] = doorIdx; state.pendingSwitchIndex = null; document.getElementById('linkModeExit').classList.add('hidden'); refreshDoorSwitchLists(); document.getElementById('status').textContent = 'Map ' + state.mapW + '×' + state.mapH + '. Click or drag to paint.'; drawGrid(); return; } if (switchIdx >= 0) { state.pendingSwitchIndex = switchIdx; document.getElementById('pendingSwitchNum').textContent = String(switchIdx); document.getElementById('linkModeExit').classList.remove('hidden'); document.getElementById('status').textContent = 'Switch ' + switchIdx + ' selected. Click a door to link, or set EXIT below.'; drawGrid(); return; } state.pendingSwitchIndex = null; document.getElementById('linkModeExit').classList.add('hidden'); document.getElementById('status').textContent = 'Map ' + state.mapW + '×' + state.mapH + '. Click or drag to paint.'; drawGrid(); return; } if (state.placing === 'spawn1') { state.spawn1 = { x, y, angle: 512 }; state.placing = null; document.getElementById('brushSpawn1').classList.remove('selected'); } else if (state.placing === 'spawn2') { state.spawn2 = { x, y, angle: 512 }; state.placing = null; document.getElementById('brushSpawn2').classList.remove('selected'); } else if (state.placing === 'enemy') { if (state.enemies.length >= GAME_LIMITS.MAX_ENEMIES) { showToast( 'Max enemies reached (' + GAME_LIMITS.MAX_ENEMIES + '). Remove one or increase MAX_ENEMIES in enemy.h.', ); return; } state.enemies.push({ type: state.enemyType, tileX: x, tileY: y, angle: 0 }); state.placing = null; document.getElementById('brushEnemy').value = ''; } else if (state.placing === 'pickup') { if (state.pickups.length >= GAME_LIMITS.MAX_PICKUPS) { showToast( 'Max pickups reached (' + GAME_LIMITS.MAX_PICKUPS + '). Remove one or increase MAX_PICKUPS in pickup.h.', ); return; } state.pickups.push({ type: state.pickupType, tileX: x, tileY: y }); state.placing = null; document.getElementById('brushPickup').value = ''; } else { setCell(x, y, state.brush); refreshDoorSwitchLists(); const doors = getDoors(); const switches = getSwitches(); if (doors.length > GAME_LIMITS.MAX_DOORS) showToast( 'Too many door tiles (' + doors.length + '). Game max is ' + GAME_LIMITS.MAX_DOORS + ' (MAX_DOORS in door.h).', ); if (switches.length > GAME_LIMITS.MAX_SWITCHES) showToast( 'Too many switch tiles (' + switches.length + '). Game max is ' + GAME_LIMITS.MAX_SWITCHES + ' (MAX_SWITCHES in door.h).', ); } refreshEntityList(); drawGrid(); } function buildJson() { return { version: LEVEL_FORMAT_VERSION, mapW: state.mapW, mapH: state.mapH, map: Array.from(state.map), spawn1: state.spawn1, spawn2: state.spawn2, enemies: state.enemies.map((e) => ({ type: e.type, tileX: e.tileX, tileY: e.tileY, angle: e.angle != null ? e.angle : 0, })), pickups: state.pickups.map((p) => ({ type: p.type, tileX: p.tileX, tileY: p.tileY })), switchLinks: state.switchLinks.slice(), }; } function loadFromJson(obj) { const v = obj.version; if (v != null && v > LEVEL_FORMAT_VERSION) { alert('Level was saved with a newer editor. Please update the editor.'); return false; } const mapW = obj.mapW, mapH = obj.mapH, mapArr = obj.map; if ( typeof mapW !== 'number' || typeof mapH !== 'number' || !Array.isArray(mapArr) || mapArr.length !== mapW * mapH ) { alert('Invalid level JSON: bad map dimensions or map array.'); return false; } state.mapW = mapW; state.mapH = mapH; state.map = new Uint8Array(mapArr); state.spawn1 = obj.spawn1 && typeof obj.spawn1.x === 'number' && typeof obj.spawn1.y === 'number' ? { x: obj.spawn1.x, y: obj.spawn1.y, angle: obj.spawn1.angle != null ? obj.spawn1.angle : 512, } : null; state.spawn2 = obj.spawn2 && typeof obj.spawn2.x === 'number' && typeof obj.spawn2.y === 'number' ? { x: obj.spawn2.x, y: obj.spawn2.y, angle: obj.spawn2.angle != null ? obj.spawn2.angle : 512, } : null; const rawEnemies = (obj.enemies || []).map((e) => ({ type: Number(e.type) || 0, tileX: Number(e.tileX) || 0, tileY: Number(e.tileY) || 0, angle: e.angle != null ? Number(e.angle) : 0, })); const rawPickups = (obj.pickups || []).map((p) => ({ type: Number(p.type) || 0, tileX: Number(p.tileX) || 0, tileY: Number(p.tileY) || 0, })); state.enemies = rawEnemies.slice(0, GAME_LIMITS.MAX_ENEMIES); state.pickups = rawPickups.slice(0, GAME_LIMITS.MAX_PICKUPS); if (rawEnemies.length > GAME_LIMITS.MAX_ENEMIES) showToast( 'Loaded level had ' + rawEnemies.length + ' enemies; trimmed to ' + GAME_LIMITS.MAX_ENEMIES + ' (game limit).', ); if (rawPickups.length > GAME_LIMITS.MAX_PICKUPS) showToast( 'Loaded level had ' + rawPickups.length + ' pickups; trimmed to ' + GAME_LIMITS.MAX_PICKUPS + ' (game limit).', ); state.switchLinks = Array.isArray(obj.switchLinks) ? obj.switchLinks.slice() : []; state.previewPos = state.spawn1 ? { x: state.spawn1.x + 0.5, y: state.spawn1.y + 0.5 } : { x: state.mapW / 2, y: state.mapH / 2 }; state.previewAngle = state.spawn1 ? (state.spawn1.angle != null ? state.spawn1.angle : 512) : 0; refreshDoorSwitchLists(); refreshEntityList(); drawGrid(); document.getElementById('status').textContent = 'Map ' + state.mapW + '×' + state.mapH + '. Click or drag to paint.'; return true; } /** Content for the .h file only (map + spawn defines). Do NOT put init functions here - they go in enemy.c/pickup.c. */ function buildExportHeaderFileContent() { const id = (document.getElementById('levelId').value || 'e1m4') .toLowerCase() .replace(/\s/g, ''); const prefix = id.toUpperCase(); const W = state.mapW, H = state.mapH; const cells = W * H; let out = ''; out += `/* VBDOOM_LEVEL_FORMAT ${LEVEL_FORMAT_VERSION} */\n`; out += `/* ${id} map: ${W}x${H} */\n`; out += `const u8 ${id}_map[${cells}] = {\n`; for (let y = 0; y < H; y++) { out += '/* Row ' + y + ' */ '; for (let x = 0; x < W; x++) out += getCell(x, y) + (x < W - 1 ? ',' : ''); out += y < H - 1 ? ',\n' : '\n'; } out += '};\n\n'; const s1 = state.spawn1 || { x: 0, y: 0, angle: 512 }; const s2 = state.spawn2 || { x: 0, y: 0, angle: 512 }; out += `/* ${id} spawn and level data */\n`; out += `#define ${prefix}_SPAWN_X (${s1.x} * 256 + 128)\n`; out += `#define ${prefix}_SPAWN_Y (${s1.y} * 256 + 128)\n`; out += `#define ${prefix}_SPAWN2_X (${s2.x} * 256 + 128)\n`; out += `#define ${prefix}_SPAWN2_Y (${s2.y} * 256 + 128)\n`; return out; } /** * Build export sections. Returns an array of {title, code} objects. * Each section represents a distinct file or function that the user must * copy into the right place. */ function buildExportSections(headerOnly) { const id = (document.getElementById('levelId').value || 'e1m4') .toLowerCase() .replace(/\s/g, ''); const prefix = id.toUpperCase(); const W = state.mapW, H = state.mapH; const cells = W * H; const doors = getDoors(); const switches = getSwitches(); const s1 = state.spawn1 || { x: 0, y: 0, angle: 512 }; const s2 = state.spawn2 || { x: 0, y: 0, angle: 512 }; const sections = []; /* ---- Section: header file (map array + defines) ---- */ { let code = `/* VBDOOM_LEVEL_FORMAT ${LEVEL_FORMAT_VERSION} */\n`; code += `/* ${id} map: ${W}x${H} */\n`; code += `const u8 ${id}_map[${cells}] = {\n`; for (let y = 0; y < H; y++) { code += '/* Row ' + y + ' */ '; for (let x = 0; x < W; x++) code += getCell(x, y) + (x < W - 1 ? ',' : ''); code += y < H - 1 ? ',\n' : '\n'; } code += '};\n\n'; code += `#define ${prefix}_SPAWN_X (${s1.x} * 256 + 128)\n`; code += `#define ${prefix}_SPAWN_Y (${s1.y} * 256 + 128)\n`; code += `#define ${prefix}_SPAWN2_X (${s2.x} * 256 + 128)\n`; code += `#define ${prefix}_SPAWN2_Y (${s2.y} * 256 + 128)\n`; sections.push({ title: `src/vbdoom/assets/doom/${id}.h (save as file)`, code, }); } if (headerOnly) return sections; /* ---- Section: RayCasterData.h include ---- */ sections.push({ title: 'RayCasterData.h (add this include)', code: `#include "../assets/doom/${id}.h"`, }); /* ---- Section: enemy.c function ---- */ { let code = `void initEnemies${prefix}(void) {\n`; code += '\tint i; for (i = 0; i < MAX_ENEMIES; i++) { g_enemies[i].active = false; g_enemies[i].enemyType = ETYPE_ZOMBIEMAN; }\n'; state.enemies.forEach((e, i) => { const health = e.type === 0 ? 'ZOMBIE_HEALTH' : e.type === 1 ? 'SGT_HEALTH' : e.type === 2 ? 'IMP_HEALTH' : e.type === 3 ? 'DEMON_HEALTH' : 'COMMANDO_HEALTH'; const angle = e.angle != null ? e.angle : 0; code += `\tg_enemies[${i}].x = ${e.tileX} * 256 + 128;\n`; code += `\tg_enemies[${i}].y = ${e.tileY} * 256 + 128;\n`; code += `\tg_enemies[${i}].angle = ${angle};\n`; code += `\tg_enemies[${i}].active = true;\n`; code += `\tg_enemies[${i}].enemyType = ETYPE_${['ZOMBIEMAN', 'SERGEANT', 'IMP', 'DEMON', 'COMMANDO'][e.type]};\n`; code += `\tg_enemies[${i}].health = ${health};\n`; }); code += '}'; sections.push({ title: `enemy.c (paste this function)`, code, }); } /* ---- Section: enemy.h declaration ---- */ sections.push({ title: 'enemy.h (add this declaration)', code: `void initEnemies${prefix}(void);`, }); /* ---- Section: pickup.c function ---- */ { let code = `void initPickups${prefix}(void) {\n`; code += '\tint i; for (i = 0; i < MAX_PICKUPS; i++) g_pickups[i].active = false;\n'; state.pickups.forEach((p, i) => { code += `\tg_pickups[${i}].x = ${p.tileX} * 256 + 128;\n`; code += `\tg_pickups[${i}].y = ${p.tileY} * 256 + 128;\n`; code += `\tg_pickups[${i}].type = PICKUP_${['AMMO_CLIP', 'HEALTH_SMALL', 'HEALTH_LARGE', 'WEAPON_SHOTGUN', 'HELMET', 'ARMOR', 'SHELLS', 'WEAPON_ROCKET', 'KEY_RED', 'KEY_YELLOW', 'KEY_BLUE', 'WEAPON_CHAINGUN'][p.type]};\n`; code += `\tg_pickups[${i}].active = true;\n`; }); code += '}'; sections.push({ title: `pickup.c (paste this function)`, code, }); } /* ---- Section: pickup.h declaration ---- */ sections.push({ title: 'pickup.h (add this declaration)', code: `void initPickups${prefix}(void);`, }); /* ---- Section: gameLoop.c loadLevel() block ---- */ { let code = ''; code += `{ u16 row; for (row = 0; row < ${H}; row++) copymem((u8*)g_map + row * MAP_X, (const u8*)${id}_map + row * ${W}, ${W}); }\n`; code += `{ u16 i; for (i = ${H} * MAP_X; i < MAP_CELLS; i++) ((u8*)g_map)[i] = 0; }\n`; code += `{ u16 row; for (row = 0; row < ${H}; row++) { u16 c; for (c = ${W}; c < MAP_X; c++) ((u8*)g_map)[row * MAP_X + c] = 0; } }\n`; code += `fPlayerX = ${prefix}_SPAWN_X;\n`; code += `fPlayerY = ${prefix}_SPAWN_Y;\n`; code += `fPlayerAng = ${s1.angle};\n`; code += `initEnemies${prefix}();\n`; code += `initPickups${prefix}();\n`; code += `initDoors();\n`; doors.forEach((d, i) => { code += `registerDoor(${d.tileX}, ${d.tileY}); /* door ${i} */\n`; }); switches.forEach((s, i) => { const link = state.switchLinks[i]; const type = link === -1 ? 'SW_EXIT' : 'SW_DOOR'; const linkArg = link === -1 ? '0' : String(link); code += `registerSwitch(${s.tileX}, ${s.tileY}, ${type}, ${linkArg});\n`; }); sections.push({ title: `gameLoop.c → loadLevel() → else if (levelNum == N) { ... }`, code, }); } return sections; } /** Render an array of {title, code} sections into the export modal container. */ function renderExportSections(sections) { const container = document.getElementById('exportSections'); container.innerHTML = ''; sections.forEach((sec, idx) => { const wrapper = document.createElement('div'); wrapper.className = 'export-section'; const header = document.createElement('div'); header.className = 'export-section-header'; const label = document.createElement('span'); label.className = 'export-section-title'; label.textContent = sec.title; const copyBtn = document.createElement('button'); copyBtn.type = 'button'; copyBtn.className = 'export-copy-btn'; copyBtn.textContent = 'COPY'; copyBtn.addEventListener('click', () => { navigator.clipboard.writeText(sec.code).then(() => { copyBtn.textContent = 'Copied!'; copyBtn.classList.add('copied'); setTimeout(() => { copyBtn.textContent = 'COPY'; copyBtn.classList.remove('copied'); }, 1500); }); }); header.appendChild(label); header.appendChild(copyBtn); wrapper.appendChild(header); const ta = document.createElement('textarea'); ta.className = 'export-section-textarea'; ta.readOnly = true; ta.value = sec.code; /* Auto-size: clamp between 2 and 20 visible lines */ const lineCount = sec.code.split('\n').length; ta.rows = Math.max(2, Math.min(20, lineCount + 1)); wrapper.appendChild(ta); container.appendChild(wrapper); }); } function renderBrushGrid() { const grid = document.getElementById('brushGrid'); grid.innerHTML = ''; for (let t = 0; t <= 11; t++) { const cell = document.createElement('div'); cell.className = 'brush-cell' + (state.brush === t ? ' selected' : ''); cell.textContent = t; cell.title = TILE_NAMES[t]; cell.style.background = [ '#1a0a0a', '#8b4513', '#555', '#4a4a6a', '#6a3a2a', '#5a4a3a', '#7a3525', '#454545', '#3a3a5a', '#8b2a2a', '#8b7a2a', '#2a4a8b', ][t]; cell.addEventListener('click', () => { state.brush = t; state.placing = null; document.querySelectorAll('.brush-cell').forEach((c) => c.classList.remove('selected')); document.getElementById('brushSpawn1').classList.remove('selected'); document.getElementById('brushSpawn2').classList.remove('selected'); document.getElementById('brushEnemy').value = ''; document.getElementById('brushPickup').value = ''; cell.classList.add('selected'); }); grid.appendChild(cell); } } document.getElementById('mapSize').addEventListener('change', initMap); document.getElementById('levelId').addEventListener('change', () => refreshDoorSwitchLists()); document.getElementById('brushSpawn1').addEventListener('click', () => { state.placing = 'spawn1'; document.getElementById('brushSpawn2').classList.remove('selected'); document.getElementById('brushEnemy').value = ''; document.getElementById('brushPickup').value = ''; document.getElementById('brushSpawn1').classList.add('selected'); }); document.getElementById('brushSpawn2').addEventListener('click', () => { state.placing = 'spawn2'; document.getElementById('brushSpawn1').classList.remove('selected'); document.getElementById('brushEnemy').value = ''; document.getElementById('brushPickup').value = ''; document.getElementById('brushSpawn2').classList.add('selected'); }); document.getElementById('brushEnemy').addEventListener('change', function () { const v = this.value; if (v === '') { state.placing = null; return; } if (state.enemies.length >= GAME_LIMITS.MAX_ENEMIES) { showToast( 'Max enemies (' + GAME_LIMITS.MAX_ENEMIES + ') reached. Remove one before adding more.', ); this.value = ''; return; } state.placing = 'enemy'; state.enemyType = parseInt(v, 10); document.getElementById('brushSpawn1').classList.remove('selected'); document.getElementById('brushSpawn2').classList.remove('selected'); document.getElementById('brushPickup').value = ''; }); document.getElementById('brushPickup').addEventListener('change', function () { const v = this.value; if (v === '') { state.placing = null; return; } if (state.pickups.length >= GAME_LIMITS.MAX_PICKUPS) { showToast( 'Max pickups (' + GAME_LIMITS.MAX_PICKUPS + ') reached. Remove one before adding more.', ); this.value = ''; return; } state.placing = 'pickup'; state.pickupType = parseInt(v, 10); document.getElementById('brushSpawn1').classList.remove('selected'); document.getElementById('brushSpawn2').classList.remove('selected'); document.getElementById('brushEnemy').value = ''; }); canvas.addEventListener('click', onGridClick); canvas.addEventListener('mousemove', function (e) { const { x, y } = xyFromEvent(e); const info = x >= 0 && x < state.mapW && y >= 0 && y < state.mapH ? getCellInfo(x, y) : null; state.hoverCell = info ? { x, y, type: info.type, index: info.index } : null; updateTooltip(e.clientX, e.clientY, info); updateEntityListHighlight(); drawGrid(); }); canvas.addEventListener('mouseleave', function () { state.hoverCell = null; updateTooltip(null, null, null); updateEntityListHighlight(); drawGrid(); }); canvas.addEventListener('contextmenu', (e) => e.preventDefault()); canvas.addEventListener('mousedown', function (e) { if (e.button === 2) { const { x, y } = xyFromEvent(e); if (x < 0 || x >= state.mapW || y < 0 || y >= state.mapH) return; if (state.spawn1 && state.spawn1.x === x && state.spawn1.y === y) { state.spawn1 = null; refreshEntityList(); drawGrid(); return; } if (state.spawn2 && state.spawn2.x === x && state.spawn2.y === y) { state.spawn2 = null; refreshEntityList(); drawGrid(); return; } const ei = state.enemies.findIndex((en) => en.tileX === x && en.tileY === y); if (ei >= 0) { state.enemies.splice(ei, 1); refreshEntityList(); drawGrid(); return; } const pi = state.pickups.findIndex((p) => p.tileX === x && p.tileY === y); if (pi >= 0) { state.pickups.splice(pi, 1); refreshEntityList(); drawGrid(); return; } return; } if (e.button !== 0) return; let last = xyFromEvent(e); const paint = function (moveE) { const { x, y } = xyFromEvent(moveE); if ( x >= 0 && x < state.mapW && y >= 0 && y < state.mapH && (x !== last.x || y !== last.y) && !state.placing ) { setCell(x, y, state.brush); refreshDoorSwitchLists(); drawGrid(); last = { x, y }; } }; const up = () => { canvas.removeEventListener('mousemove', paint); document.removeEventListener('mouseup', up); }; canvas.addEventListener('mousemove', paint); document.addEventListener('mouseup', up); }); document.getElementById('btnSaveJson').addEventListener('click', () => { const json = JSON.stringify(buildJson(), null, 2); const blob = new Blob([json], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'level.json'; a.click(); URL.revokeObjectURL(a.href); }); document.getElementById('btnLoadJson').addEventListener('click', () => { document.getElementById('loadJsonText').value = ''; document.getElementById('loadJsonModal').classList.remove('hidden'); }); document.getElementById('btnLoadJsonChooseFile').addEventListener('click', () => { document.getElementById('loadJsonInput').click(); }); document.getElementById('loadJsonInput').addEventListener('change', function () { const f = this.files && this.files[0]; if (!f) return; const r = new FileReader(); r.onload = () => { document.getElementById('loadJsonText').value = r.result; }; r.readAsText(f); this.value = ''; }); document.getElementById('btnLoadJsonApply').addEventListener('click', () => { try { const text = document.getElementById('loadJsonText').value.trim(); if (!text) { alert('Paste JSON or choose a file.'); return; } const obj = JSON.parse(text); if (loadFromJson(obj)) document.getElementById('loadJsonModal').classList.add('hidden'); } catch (err) { alert('Invalid JSON: ' + err.message); } }); document.getElementById('btnCloseLoadJson').addEventListener('click', () => { document.getElementById('loadJsonModal').classList.add('hidden'); }); /* ---- Load .h (C header) map import ---- */ /** * Parse a C header file containing a VB Doom map array and spawn defines. * Returns { levelId, mapW, mapH, map: number[], spawn1, spawn2 } or null on failure. */ function parseHFile(text) { /* 1. Find the map array: const u8 _map[] = { ... }; */ const arrMatch = text.match(/const\s+u8\s+(\w+)_map\s*\[\s*(\d+)\s*\]\s*=\s*\{/); if (!arrMatch) return null; const levelId = arrMatch[1]; /* e.g. "e1m4" */ const declaredCells = parseInt(arrMatch[2], 10); /* Extract body between the opening { and closing }; */ const openIdx = text.indexOf('{', arrMatch.index); const closeIdx = text.indexOf('};', openIdx); if (openIdx < 0 || closeIdx < 0) return null; const body = text.substring(openIdx + 1, closeIdx); /* Parse all integers from the body (skip comments and whitespace) */ const values = []; const numRe = /\b(\d+)\b/g; let m; /* Strip C comments first so " Row 12 , row numbers aren't counted as tile values */ const stripped = body.replace(/\/\*[\s\S]*?\*\//g, ''); while ((m = numRe.exec(stripped)) !== null) { values.push(parseInt(m[1], 10)); } if (values.length === 0) return null; if (values.length !== declaredCells) { /* Accept anyway if close, but warn */ console.warn( 'Header declared ' + declaredCells + ' cells but found ' + values.length + ' values.', ); } /* 2. Determine dimensions */ let mapW = 0, mapH = 0; /* Try the comment: "e1m4 map: 32x32" */ const dimComment = text.match(/map:\s*(\d+)\s*x\s*(\d+)/i); if (dimComment) { mapW = parseInt(dimComment[1], 10); mapH = parseInt(dimComment[2], 10); } /* Fall back: count "Row N" comments to get height, derive width */ if (!mapW || !mapH) { const rowComments = body.match(/\/\*\s*Row\s+\d+/g); if (rowComments && rowComments.length > 0) { mapH = rowComments.length; mapW = Math.round(values.length / mapH); } } /* Last resort: assume square */ if (!mapW || !mapH || mapW * mapH !== values.length) { const sq = Math.sqrt(values.length); if (sq === Math.floor(sq)) { mapW = sq; mapH = sq; } else { /* Try common non-square sizes */ const tryWidths = [64, 48, 32]; for (let i = 0; i < tryWidths.length; i++) { if (values.length % tryWidths[i] === 0) { mapW = tryWidths[i]; mapH = values.length / tryWidths[i]; break; } } } } if (!mapW || !mapH || mapW * mapH !== values.length) return null; /* 3. Parse spawn defines: #define _SPAWN_X (tile * 256 + 128) */ const prefix = levelId.toUpperCase(); function parseSpawnCoord(suffix) { const re = new RegExp( '#define\\s+' + prefix + '_' + suffix + '\\s+\\(\\s*(\\d+)\\s*\\*\\s*256\\s*\\+\\s*128\\s*\\)', ); const sm = text.match(re); return sm ? parseInt(sm[1], 10) : null; } const s1x = parseSpawnCoord('SPAWN_X'); const s1y = parseSpawnCoord('SPAWN_Y'); const s2x = parseSpawnCoord('SPAWN2_X'); const s2y = parseSpawnCoord('SPAWN2_Y'); const spawn1 = s1x !== null && s1y !== null ? { x: s1x, y: s1y, angle: 512 } : null; const spawn2 = s2x !== null && s2y !== null ? { x: s2x, y: s2y, angle: 512 } : null; return { levelId: levelId, mapW: mapW, mapH: mapH, map: values, spawn1: spawn1, spawn2: spawn2, }; } document.getElementById('btnLoadH').addEventListener('click', () => { document.getElementById('loadHInput').click(); }); document.getElementById('loadHInput').addEventListener('change', function () { const f = this.files && this.files[0]; if (!f) return; const r = new FileReader(); r.onload = function () { const parsed = parseHFile(r.result); if (!parsed) { alert( 'Could not find a valid map array in the .h file.\nExpected: const u8 _map[N] = { ... };', ); return; } /* Build a JSON-like object and load via loadFromJson */ const obj = { version: LEVEL_FORMAT_VERSION, mapW: parsed.mapW, mapH: parsed.mapH, map: parsed.map, spawn1: parsed.spawn1, spawn2: parsed.spawn2, enemies: [], pickups: [], switchLinks: [], }; if (loadFromJson(obj)) { /* Set the level ID field to match the loaded map */ document.getElementById('levelId').value = parsed.levelId; /* Update map size dropdown if it matches a known size */ const sizeKey = parsed.mapW + ',' + parsed.mapH; const sizeSelect = document.getElementById('mapSize'); for (let i = 0; i < sizeSelect.options.length; i++) { if (sizeSelect.options[i].value === sizeKey) { sizeSelect.value = sizeKey; break; } } showToast( 'Loaded ' + parsed.levelId + ' (' + parsed.mapW + 'x' + parsed.mapH + ') from header file. Enemies/pickups not included in .h files — add them manually or load a JSON.', ); } }; r.readAsText(f); this.value = ''; }); document.getElementById('btnHelp').addEventListener('click', () => { document.getElementById('helpModal').classList.remove('hidden'); }); document.getElementById('btnCloseHelp').addEventListener('click', () => { document.getElementById('helpModal').classList.add('hidden'); }); function getExportFileName() { const raw = (document.getElementById('levelId').value || 'e1m4').trim(); const safe = raw .toLowerCase() .replace(/\s+/g, '') .replace(/[^a-z0-9_]/g, '') || 'map'; return safe + '.h'; } function openExportModal() { const fn = getExportFileName(); document.getElementById('exportFileName').textContent = fn; const checklistEl = document.getElementById('exportFileNameChecklist'); if (checklistEl) checklistEl.textContent = fn; } document.getElementById('btnExport').addEventListener('click', () => { renderExportSections(buildExportSections(false)); openExportModal(); document.getElementById('exportModal').classList.remove('hidden'); }); document.getElementById('btnExportHeader').addEventListener('click', () => { renderExportSections(buildExportSections(true)); openExportModal(); document.getElementById('exportModal').classList.remove('hidden'); }); document.getElementById('btnCloseExport').addEventListener('click', () => { document.getElementById('exportModal').classList.add('hidden'); }); document.getElementById('btnSaveAsHeader').addEventListener('click', () => { const fileName = getExportFileName(); /* Save only map + spawn defines so the .h can be #included from RayCasterData.h without pulling in enemy/pickup types */ const text = buildExportHeaderFileContent(); const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = fileName; a.click(); URL.revokeObjectURL(url); }); document.getElementById('btnLinkMode').addEventListener('click', function () { state.linkMode = !state.linkMode; this.classList.toggle('selected', state.linkMode); if (!state.linkMode) { state.pendingSwitchIndex = null; document.getElementById('linkModeExit').classList.add('hidden'); document.getElementById('status').textContent = 'Map ' + state.mapW + '×' + state.mapH + '. Click or drag to paint.'; } drawGrid(); }); document.getElementById('btnSetExit').addEventListener('click', () => { if (state.pendingSwitchIndex === null) return; state.switchLinks[state.pendingSwitchIndex] = -1; state.pendingSwitchIndex = null; refreshDoorSwitchLists(); document.getElementById('linkModeExit').classList.add('hidden'); document.getElementById('status').textContent = 'Map ' + state.mapW + '×' + state.mapH + '. Click or drag to paint.'; drawGrid(); }); const previewCanvas = document.getElementById('previewCanvas'); const previewCtx = previewCanvas.getContext('2d'); const PREVIEW_HEIGHT = 200; function resizePreview() { const cw = canvas.width || state.mapW * CELL_PX; const w = Math.min(cw, MAX_CANVAS); previewCanvas.width = w; previewCanvas.height = PREVIEW_HEIGHT; if (state.map) drawRaycastPreview(previewCtx, previewCanvas.width, previewCanvas.height); } const keysPressed = {}; const PREVIEW_MOVE_SPEED = 0.08; const PREVIEW_TURN_SPEED = 4; function isPreviewKey(key) { return ( key === 'ArrowUp' || key === 'ArrowDown' || key === 'ArrowLeft' || key === 'ArrowRight' || key === 'w' || key === 'W' || key === 's' || key === 'S' || key === 'a' || key === 'A' || key === 'd' || key === 'D' ); } function previewCellBlocked(x, y) { const gx = Math.floor(x), gy = Math.floor(y); if (gx < 0 || gx >= state.mapW || gy < 0 || gy >= state.mapH) return true; const tile = getCell(gx, gy); if (tile === 0) return false; if (PREVIEW_PASSTHROUGH_TILES.includes(tile)) return false; // walk through doors/switches in preview return true; } function applyPreviewInput() { if (document.activeElement !== previewCanvas) return; const fwd = keysPressed['ArrowUp'] || keysPressed['w'] || keysPressed['W']; const back = keysPressed['ArrowDown'] || keysPressed['s'] || keysPressed['S']; const left = keysPressed['ArrowLeft'] || keysPressed['a'] || keysPressed['A']; const right = keysPressed['ArrowRight'] || keysPressed['d'] || keysPressed['D']; if (!fwd && !back && !left && !right) return; const player = getPreviewPlayer(); let nx = player.x, ny = player.y; let nangle = player.angle; const angleRad = (nangle / 1024) * 2 * Math.PI - Math.PI / 2; if (fwd) { nx += Math.cos(angleRad) * PREVIEW_MOVE_SPEED; ny += Math.sin(angleRad) * PREVIEW_MOVE_SPEED; } if (back) { nx -= Math.cos(angleRad) * PREVIEW_MOVE_SPEED; ny -= Math.sin(angleRad) * PREVIEW_MOVE_SPEED; } if (left) nangle = (nangle - PREVIEW_TURN_SPEED + 1024) % 1024; if (right) nangle = (nangle + PREVIEW_TURN_SPEED) % 1024; let finalX = nx, finalY = ny; if (previewCellBlocked(nx, ny)) { const slideX = !previewCellBlocked(nx, player.y); const slideY = !previewCellBlocked(player.x, ny); if (slideX && slideY) { finalX = nx; finalY = player.y; } else if (slideX) { finalX = nx; finalY = player.y; } else if (slideY) { finalX = player.x; finalY = ny; } else { finalX = player.x; finalY = player.y; } } state.previewPos = { x: finalX, y: finalY }; state.previewAngle = nangle; } function tickPreview() { applyPreviewInput(); if (previewCanvas.width && previewCanvas.height && state.map) drawRaycastPreview(previewCtx, previewCanvas.width, previewCanvas.height); drawPreviewOnMap(); requestAnimationFrame(tickPreview); } tickPreview(); window.addEventListener('keydown', function (e) { if (!isPreviewKey(e.key)) return; if (document.activeElement === previewCanvas) e.preventDefault(); keysPressed[e.key] = true; }); window.addEventListener('keyup', function (e) { if (isPreviewKey(e.key)) keysPressed[e.key] = false; }); previewCanvas.addEventListener('blur', function () { Object.keys(keysPressed).forEach((k) => { keysPressed[k] = false; }); }); document.getElementById('btnResetPreview').addEventListener('click', () => { state.previewPos = state.spawn1 ? { x: state.spawn1.x + 0.5, y: state.spawn1.y + 0.5 } : { x: state.mapW / 2, y: state.mapH / 2 }; state.previewAngle = state.spawn1 ? (state.spawn1.angle != null ? state.spawn1.angle : 512) : 0; }); window.addEventListener('resize', resizePreview); initMap(); renderBrushGrid(); resizePreview(); })();