(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();
})();