1878 lines
64 KiB
JavaScript
1878 lines
64 KiB
JavaScript
(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 =
|
||
'<option value="-1">EXIT</option>' +
|
||
doors.map((_, d) => `<option value="${d}">Door ${d}</option>`).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 <name>_map[<cells>] = { ... }; */
|
||
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 <PREFIX>_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 <name>_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();
|
||
})();
|