import { SECTION_TYPES } from './sections.js'; import { buildLevelData } from './levelBuilder.js'; import { exportLevelToSource } from './exportLevel.js'; import { BUS } from '../config.js'; // The leading section is always 'flat' and locked at index 0, so a jump // section can never end up as the very first thing the bus drives onto - // see the "Safety default" note in the level editor plan. const state = { metadata: { id: 'levelCustom', name: 'New Level', description: '', kidsAboard: 3 }, sequence: ['flat'], selectedIndex: null, }; const paletteEl = document.getElementById('palette'); const trackEl = document.getElementById('track'); const moveLeftBtn = document.getElementById('move-left-btn'); const moveRightBtn = document.getElementById('move-right-btn'); const removeBtn = document.getElementById('remove-btn'); const metadataFormEl = document.getElementById('metadata-form'); const previewScrollEl = document.getElementById('preview-scroll'); const previewCanvas = document.getElementById('preview-canvas'); const exportTextarea = document.getElementById('export-textarea'); const downloadBtn = document.getElementById('download-btn'); const labelFor = (typeId) => SECTION_TYPES.find((t) => t.id === typeId)?.label || typeId; function buildPalette() { paletteEl.innerHTML = ''; for (const { id, label } of SECTION_TYPES) { const btn = document.createElement('button'); btn.className = 'palette-btn'; btn.textContent = label; btn.addEventListener('click', () => { state.sequence.push(id); render(); }); paletteEl.appendChild(btn); } } function buildMetadataForm() { metadataFormEl.innerHTML = ''; const makeField = (labelText, inputEl) => { const label = document.createElement('label'); label.textContent = labelText; label.appendChild(inputEl); metadataFormEl.appendChild(label); return inputEl; }; const idInput = document.createElement('input'); idInput.type = 'text'; idInput.value = state.metadata.id; idInput.addEventListener('input', () => { state.metadata.id = idInput.value; render(); }); makeField('Level ID', idInput); const nameInput = document.createElement('input'); nameInput.type = 'text'; nameInput.value = state.metadata.name; nameInput.addEventListener('input', () => { state.metadata.name = nameInput.value; render(); }); makeField('Name', nameInput); const descInput = document.createElement('input'); descInput.type = 'text'; descInput.value = state.metadata.description; descInput.addEventListener('input', () => { state.metadata.description = descInput.value; render(); }); makeField('Description', descInput); const kidsInput = document.createElement('input'); kidsInput.type = 'number'; kidsInput.min = '1'; kidsInput.max = String(BUS.maxSeats); kidsInput.value = String(state.metadata.kidsAboard); kidsInput.addEventListener('input', () => { const n = clamp(parseInt(kidsInput.value, 10) || 1, 1, BUS.maxSeats); state.metadata.kidsAboard = n; render(); }); makeField(`Kids Aboard (max ${BUS.maxSeats})`, kidsInput); } function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); } function buildTrack() { trackEl.innerHTML = ''; state.sequence.forEach((typeId, index) => { const item = document.createElement('div'); item.className = 'track-item'; if (index === state.selectedIndex) item.classList.add('selected'); if (index === 0) item.classList.add('locked'); item.textContent = `${index + 1}. ${labelFor(typeId)}${index === 0 ? ' (locked)' : ''}`; item.addEventListener('click', () => { state.selectedIndex = index; render(); }); trackEl.appendChild(item); }); } function updateTrackControls() { const i = state.selectedIndex; const locked = i === 0; moveLeftBtn.disabled = i === null || i <= 1; moveRightBtn.disabled = i === null || locked || i === state.sequence.length - 1; removeBtn.disabled = i === null || locked; } moveLeftBtn.addEventListener('click', () => { const i = state.selectedIndex; if (i === null || i <= 1) return; [state.sequence[i - 1], state.sequence[i]] = [state.sequence[i], state.sequence[i - 1]]; state.selectedIndex = i - 1; render(); }); moveRightBtn.addEventListener('click', () => { const i = state.selectedIndex; if (i === null || i === 0 || i === state.sequence.length - 1) return; [state.sequence[i + 1], state.sequence[i]] = [state.sequence[i], state.sequence[i + 1]]; state.selectedIndex = i + 1; render(); }); removeBtn.addEventListener('click', () => { const i = state.selectedIndex; if (i === null || i === 0) return; state.sequence.splice(i, 1); state.selectedIndex = null; render(); }); // --- Preview ----------------------------------------------------------- const PADDING = 40; const H_PX_PER_UNIT = 0.12; const CANVAS_HEIGHT = 460; function computeBounds(levelData) { let minX = Infinity; let maxX = -Infinity; let minY = Infinity; let maxY = -Infinity; for (const segment of levelData.terrain) { for (const p of segment.points) { minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x); minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y); } } minX = Math.min(minX, levelData.startPosition.x); maxX = Math.max(maxX, levelData.goal.x + levelData.goal.width / 2); minY = Math.min(minY, levelData.startPosition.y - 100, levelData.goal.y - levelData.goal.height / 2); maxY = Math.max(maxY, levelData.goal.y + levelData.goal.height / 2); return { minX, maxX, minY, maxY }; } function drawPreview(levelData) { const { minX, maxX, minY, maxY } = computeBounds(levelData); const contentWidth = Math.max(1, maxX - minX); const contentHeight = Math.max(1, maxY - minY); const width = Math.max(600, Math.round(contentWidth * H_PX_PER_UNIT) + PADDING * 2); const height = CANVAS_HEIGHT; previewCanvas.width = width; previewCanvas.height = height; const vScale = (height - PADDING * 2) / contentHeight; const screenX = (worldX) => (worldX - minX) * H_PX_PER_UNIT + PADDING; const screenY = (worldY) => (worldY - minY) * vScale + PADDING; const ctx = previewCanvas.getContext('2d'); ctx.fillStyle = '#0b0e14'; ctx.fillRect(0, 0, width, height); levelData.terrain.forEach((segment, i) => { const pts = segment.points; ctx.beginPath(); ctx.moveTo(screenX(pts[0].x), height); for (const p of pts) ctx.lineTo(screenX(p.x), screenY(p.y)); ctx.lineTo(screenX(pts[pts.length - 1].x), height); ctx.closePath(); ctx.fillStyle = '#3c8f3c'; ctx.fill(); ctx.beginPath(); ctx.moveTo(screenX(pts[0].x), screenY(pts[0].y)); for (const p of pts) ctx.lineTo(screenX(p.x), screenY(p.y)); ctx.strokeStyle = '#2c6e2c'; ctx.lineWidth = 3; ctx.stroke(); if (i < levelData.terrain.length - 1) { const nextPts = levelData.terrain[i + 1].points; const gx1 = screenX(pts[pts.length - 1].x); const gx2 = screenX(nextPts[0].x); const mid = (gx1 + gx2) / 2; ctx.save(); ctx.setLineDash([6, 6]); ctx.strokeStyle = '#f2c14e'; ctx.beginPath(); ctx.moveTo(mid, PADDING); ctx.lineTo(mid, height - PADDING); ctx.stroke(); ctx.restore(); ctx.fillStyle = '#f2c14e'; ctx.font = '12px monospace'; ctx.fillText('GAP', mid - 14, PADDING - 8); } }); const sx = screenX(levelData.startPosition.x); const sy = screenY(levelData.startPosition.y); ctx.fillStyle = '#ffe066'; ctx.beginPath(); ctx.arc(sx, sy, 8, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#1a1f29'; ctx.font = 'bold 12px monospace'; ctx.fillText('S', sx - 4, sy + 4); const g = levelData.goal; const gx = screenX(g.x); const gyTop = screenY(g.y - g.height / 2); const gyBottom = screenY(g.y + g.height / 2); const gWidthPx = g.width * H_PX_PER_UNIT; ctx.fillStyle = 'rgba(242, 193, 78, 0.25)'; ctx.fillRect(gx - gWidthPx / 2, gyTop, gWidthPx, gyBottom - gyTop); ctx.strokeStyle = '#f2c14e'; ctx.lineWidth = 2; ctx.strokeRect(gx - gWidthPx / 2, gyTop, gWidthPx, gyBottom - gyTop); ctx.fillStyle = '#f2c14e'; ctx.font = '12px monospace'; ctx.fillText('GOAL', gx - 16, gyTop - 6); } // --- Export -------------------------------------------------------------- let currentLevelData = null; downloadBtn.addEventListener('click', () => { if (!currentLevelData) return; const source = exportLevelToSource(currentLevelData); const blob = new Blob([source], { type: 'text/javascript' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${currentLevelData.id || 'levelCustom'}.js`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); }); // --- Render loop ----------------------------------------------------------- function render() { buildTrack(); updateTrackControls(); currentLevelData = buildLevelData(state.sequence, state.metadata); if (!currentLevelData) { previewScrollEl.innerHTML = '
Add a section to see the preview.
'; exportTextarea.value = ''; downloadBtn.disabled = true; return; } if (!previewScrollEl.contains(previewCanvas)) { previewScrollEl.innerHTML = ''; previewScrollEl.appendChild(previewCanvas); } drawPreview(currentLevelData); exportTextarea.value = exportLevelToSource(currentLevelData); downloadBtn.disabled = false; } buildPalette(); buildMetadataForm(); render();