import { SECTION_TYPES, SECTION_WIDTH, SECTION_WIDTH_MIN, SECTION_WIDTH_MAX, SECTION_VSCALE_MIN, SECTION_VSCALE_MAX, } 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. Each sequence // entry carries its own width/vScale so sections can be individually // stretched/contracted horizontally (width) or vertically (vScale, a // multiplier on that section's rise/amplitude/depth - see sections.js). const state = { metadata: { id: 'levelCustom', name: 'New Level', description: '', kidsAboard: 3 }, sequence: [{ type: 'flat', width: SECTION_WIDTH, vScale: 1, smooth: false }], 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 sizePanelEl = document.getElementById('size-panel'); const widthRangeEl = document.getElementById('width-range'); const widthNumberEl = document.getElementById('width-number'); const vscaleRangeEl = document.getElementById('vscale-range'); const vscaleNumberEl = document.getElementById('vscale-number'); const smoothCheckboxEl = document.getElementById('smooth-checkbox'); 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; // 'flat' has no rise/amplitude/depth for vScale to multiply - disable that // control rather than leave it silently do nothing. const FLAT_LIKE_TYPES = new Set(['flat']); 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({ type: id, width: SECTION_WIDTH, vScale: 1, smooth: false }); 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 sizeSuffix(section) { const parts = []; if (section.width !== SECTION_WIDTH) parts.push(`${Math.round(section.width)}w`); if (!FLAT_LIKE_TYPES.has(section.type) && section.vScale !== 1) parts.push(`${section.vScale.toFixed(2)}x`); if (section.smooth) parts.push('smooth'); return parts.length ? ` (${parts.join(', ')})` : ''; } function buildTrack() { trackEl.innerHTML = ''; state.sequence.forEach((section, 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(section.type)}${sizeSuffix(section)}${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; } function updateSizePanel() { const i = state.selectedIndex; if (i === null) { sizePanelEl.hidden = true; return; } sizePanelEl.hidden = false; const section = state.sequence[i]; widthRangeEl.value = String(section.width); widthNumberEl.value = String(Math.round(section.width)); const vScaleDisabled = FLAT_LIKE_TYPES.has(section.type); vscaleRangeEl.disabled = vScaleDisabled; vscaleNumberEl.disabled = vScaleDisabled; vscaleRangeEl.value = String(section.vScale); vscaleNumberEl.value = section.vScale.toFixed(2); // Smoothing blends into the seam with the *previous* section - the // leading section has none, so there's nothing for it to do there. smoothCheckboxEl.disabled = i === 0; smoothCheckboxEl.checked = section.smooth; } function setSelectedWidth(value) { const i = state.selectedIndex; if (i === null) return; state.sequence[i].width = clamp(value, SECTION_WIDTH_MIN, SECTION_WIDTH_MAX); render(); } function setSelectedVScale(value) { const i = state.selectedIndex; if (i === null) return; state.sequence[i].vScale = clamp(value, SECTION_VSCALE_MIN, SECTION_VSCALE_MAX); render(); } widthRangeEl.addEventListener('input', () => setSelectedWidth(parseFloat(widthRangeEl.value))); widthNumberEl.addEventListener('change', () => setSelectedWidth(parseFloat(widthNumberEl.value) || SECTION_WIDTH)); vscaleRangeEl.addEventListener('input', () => setSelectedVScale(parseFloat(vscaleRangeEl.value))); vscaleNumberEl.addEventListener('change', () => setSelectedVScale(parseFloat(vscaleNumberEl.value) || 1)); smoothCheckboxEl.addEventListener('change', () => { const i = state.selectedIndex; if (i === null) return; state.sequence[i].smooth = smoothCheckboxEl.checked; render(); }); 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 MIN_CANVAS_HEIGHT = 260; 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); // One scale factor for both axes - the actual game (Terrain.js) always // scales X and Y identically (everything just multiplies by WORLD_SCALE), // so fitting height to a fixed canvas box with its own independently // derived vertical scale (the old behavior) stretched or squashed every // incline/hill relative to how it actually plays. Height now just follows // from content height at the same scale, with a floor so a nearly-flat // level doesn't render as a sliver. const width = Math.max(600, Math.round(contentWidth * H_PX_PER_UNIT) + PADDING * 2); const height = Math.max(MIN_CANVAS_HEIGHT, Math.round(contentHeight * H_PX_PER_UNIT) + PADDING * 2); previewCanvas.width = width; previewCanvas.height = height; const screenX = (worldX) => (worldX - minX) * H_PX_PER_UNIT + PADDING; const screenY = (worldY) => (worldY - minY) * H_PX_PER_UNIT + 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(); updateSizePanel(); 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; } function initSizeControls() { for (const el of [widthRangeEl, widthNumberEl]) { el.min = String(SECTION_WIDTH_MIN); el.max = String(SECTION_WIDTH_MAX); } for (const el of [vscaleRangeEl, vscaleNumberEl]) { el.min = String(SECTION_VSCALE_MIN); el.max = String(SECTION_VSCALE_MAX); } } buildPalette(); buildMetadataForm(); initSizeControls(); render();