import { SECTION_TYPES, SECTION_WIDTH, SECTION_WIDTH_MIN, SECTION_WIDTH_MAX, SECTION_VSCALE_MIN, SECTION_VSCALE_MAX, } from './sections.js'; import { buildLevelData, splitPlaceables } from './levelBuilder.js'; import { exportLevelToSource } from './exportLevel.js'; import { BUS } from '../config.js'; import { LEVELS } from '../data/levels/index.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, // Loaded from a bundled level or a file? When true the sections palette / track / // size controls are locked (the terrain isn't built from sections, so rebuilding it // from an arbitrary sequence would lose the original shape), and the preview shows // the level's exact terrain instead of one the sequence generates. Details + // items remain editable, and the level can be re-exported. imported: null, // Smashable props + bonus items placed on the course. Each entry has a // `kind`: 'smashable' (TV/chair/cone - can float or fall, smash off on hit) // or 'bonus' (CTC/guitar/cash - always float, pop into fireworks on hit). // x/y are in the SAME scaled world units the preview canvas plots (and that // the exported level uses); refIndex is the track section it was added // under (rebased on reordering). items: [], // Which kind the Placeables section is currently placing (the toggle). kind: 'smashable', // Smashable selection (kind 'smashable'): type + placement mode. itemsType: 'tv', itemsMode: 'float', // Bonus selection (kind 'bonus'): type only - bonus items are float-only. bonusType: 'ctc', }; 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 itemsFormEl = document.getElementById('items-form'); const itemsListEl = document.getElementById('items-list'); // --- Load an existing level -------------------------------------------- const importSelectEl = document.getElementById('import-level-select'); const importFileEl = document.getElementById('import-file'); const clearLevelBtn = document.getElementById('clear-level-btn'); const importStatusEl = document.getElementById('import-status'); const paletteSectionEl = document.getElementById('palette-section'); const sequenceSectionEl = document.getElementById('sequence-section'); const labelFor = (typeId) => SECTION_TYPES.find((t) => t.id === typeId)?.label || typeId; // 'flat' and 'gap' have 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', 'gap']); // --- Load an existing level ------------------------------------------------ // // An imported level is the game's source-of-truth for its terrain (its exact // `terrain` array), so the preview renders that instead of what `state.sequence` // would generate, and section editing is locked. Metadata + items + goal/start // stay editable, and re-export emits the loaded level's fields verbatim. function buildImportSelect() { importSelectEl.innerHTML = ''; const ph = document.createElement('option'); ph.value = ''; ph.textContent = 'Choose a level\u2026'; importSelectEl.appendChild(ph); for (const level of LEVELS) { const opt = document.createElement('option'); opt.value = level.id; opt.textContent = `${level.id} \u2014 ${level.name || ''}`; importSelectEl.appendChild(opt); } } function setImported(level, source) { if (!level || !Array.isArray(level.terrain) || level.terrain.length === 0) { importStatusEl.hidden = false; importStatusEl.style.color = '#c0392b'; importStatusEl.textContent = 'Could not import \u2014 the level has no terrain.'; return; } state.imported = { metadata: { id: level.id || 'levelCustom', name: level.name || '', description: level.description || '', kidsAboard: level.kidsAboard || 1, timeLimit: level.timeLimit, startPosition: level.startPosition, startAngle: level.startAngle, goal: level.goal, cameraBounds: level.cameraBounds, obstacles: level.obstacles, }, terrain: level.terrain, // Smashables and bonus items are two separate level arrays, but the // editor works with ONE list (with a `kind` per entry) so the preview // map / list / click-to-place code is shared - see state.items. items: [ ...(level.items || []).map((it) => ({ ...it, kind: 'smashable' })), ...(level.bonusItems || []).map((it) => ({ ...it, kind: 'bonus' })), ], source, }; // Mirror the imported fields into the shared metadata so edits to name/id/ // kids in the Level Details form are reflected in the re-export. state.metadata = { id: state.imported.metadata.id, name: state.imported.metadata.name, description: state.imported.metadata.description, kidsAboard: state.imported.metadata.kidsAboard, timeLimit: state.imported.metadata.timeLimit, }; state.items = state.imported.items.map((it) => ({ ...it })); buildMetadataForm(); // reflect the imported id/name/description/kids in the form importStatusEl.hidden = false; importStatusEl.style.color = '#2c8f3c'; importStatusEl.textContent = `Loaded ${state.imported.metadata.id} \u2014 \"${state.imported.metadata.name}\" from ${source}.`; render(); } function clearImported() { state.imported = null; // "Start a new level" = full reset to the blank-canvas defaults, so the // imported level's items/metadata don't bleed into the fresh level. state.items = []; state.selectedIndex = null; state.sequence = [{ type: 'flat', width: SECTION_WIDTH, vScale: 1, smooth: false }]; state.metadata = { id: 'levelCustom', name: 'New Level', description: '', kidsAboard: 3 }; importStatusEl.hidden = true; importStatusEl.textContent = ''; importSelectEl.value = ''; if (importFileEl) importFileEl.value = ''; buildMetadataForm(); render(); } function isImported() { return state.imported !== null; } function setSectionsLocked(locked) { for (const el of document.querySelectorAll('[data-section-locked]')) { el.classList.toggle('is-locked', locked); } } 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); } } // --- Smashable items --------------------------------------------------- const ITEM_TYPES = [ { id: 'tv', label: 'TV' }, { id: 'chair', label: 'Chair' }, { id: 'cone', label: 'Cone' }, ]; const ITEM_COLORS = { tv: '#4a90d9', chair: '#d9a04a', cone: '#d96a4a' }; const ITEM_ICONS = { tv: 'TV', chair: 'CH', cone: 'CO' }; // The real spritesheet, loaded directly as an for the preview map // (frames: 0 TV, 1 smashed TV, 2 chair, 3 smashed chair, 4 cone, 5 smashed // cone - 64x64 cells, same order as ITEM_TYPES pairs above). The circle // fallback in drawPreview covers the brief moment before it finishes loading // and any file:// context where the image is blocked. const ITEM_SPRITE = new Image(); ITEM_SPRITE.src = 'assets/sprites/smash-items.png'; const ITEM_SPRITE_FRAMES = { tv: 0, chair: 2, cone: 4 }; const ITEM_SPRITE_CELL = 64; // Bonus items ("Placeables" toggle -> "Bonus items"): float-only collectible // prizes - CTC box / guitar / cash. See BonusItemManager.js in the game for // the type/frame/color table; frames here are the BONUS frame of each pair // (0 CTC, 3 guitar, 6 cash). const BONUS_TYPES = [ { id: 'ctc', label: 'CTC Box' }, { id: 'guitar', label: 'Guitar' }, { id: 'cash', label: 'Cash' }, ]; const BONUS_COLORS = { ctc: '#e0a040', guitar: '#4b9a45', cash: '#b3ad9c' }; const BONUS_ICONS = { ctc: 'CT', guitar: 'GT', cash: '$' }; const BONUS_SPRITE = new Image(); BONUS_SPRITE.src = 'assets/sprites/bonus-items.png'; const BONUS_SPRITE_FRAMES = { ctc: 0, guitar: 3, cash: 6 }; // Samples the terrain surface Y (highest ground) at a given world X - used // to draw the "falls to here" drop line for gravity-mode items in the // preview. Same "first segment whose x-span contains X" logic the physics // engine effectively applies when the item settles. function terrainSurfaceY(levelData, x) { let best = Infinity; for (const segment of levelData.terrain) { const pts = segment.points; for (let i = 0; i < pts.length - 1; i++) { const a = pts[i]; const b = pts[i + 1]; const lo = Math.min(a.x, b.x); const hi = Math.max(a.x, b.x); if (x < lo || x > hi) continue; const t = (b.x === a.x) ? 0 : (x - a.x) / (b.x - a.x); best = Math.min(best, a.y + (b.y - a.y) * t); } } return best; } function buildItemsForm() { itemsFormEl.innerHTML = ''; const fieldset = document.createElement('div'); fieldset.style.display = 'flex'; fieldset.style.flexWrap = 'wrap'; fieldset.style.gap = '16px'; itemsFormEl.appendChild(fieldset); // --- Placeable kind toggle: smashables (TV/chair/cone) vs bonus items // (CTC/guitar/cash). Only the relevant type+mode controls show below, // and clicks on the preview place whichever kind is selected. const kindRadios = []; for (const kind of ['smashable', 'bonus']) { const radio = document.createElement('input'); radio.type = 'radio'; radio.name = 'placeable-kind'; radio.value = kind; radio.id = `placeable-kind-${kind}`; if (kind === state.kind) radio.checked = true; radio.addEventListener('change', () => { state.kind = kind; render(); }); const label = document.createElement('label'); label.className = 'checkbox-row'; label.append(radio, kind === 'smashable' ? 'Smashables (TV / Chair / Cone)' : 'Bonus items (CTC / Guitar / Cash)'); kindRadios.push(label); } const kindWrap = document.createElement('div'); kindWrap.className = 'size-field'; kindWrap.append('Placeable kind', ...kindRadios); fieldset.appendChild(kindWrap); if (state.kind === 'smashable') { const typeSelect = document.createElement('select'); typeSelect.id = 'items-type'; for (const t of ITEM_TYPES) { const opt = document.createElement('option'); opt.value = t.id; opt.textContent = t.label; if (t.id === state.itemsType) opt.selected = true; typeSelect.appendChild(opt); } typeSelect.addEventListener('change', () => { state.itemsType = typeSelect.value; render(); }); const typeWrap = document.createElement('label'); typeWrap.className = 'size-field'; typeWrap.append('Item type', typeSelect); fieldset.appendChild(typeWrap); const modeRadios = []; for (const mode of ['float', 'gravity']) { const radio = document.createElement('input'); radio.type = 'radio'; radio.name = 'items-mode'; radio.value = mode; radio.id = `items-mode-${mode}`; if (mode === state.itemsMode) radio.checked = true; radio.addEventListener('change', () => { state.itemsMode = mode; render(); }); const label = document.createElement('label'); label.className = 'checkbox-row'; label.append(radio, mode === 'float' ? 'Float (stays where placed)' : 'Gravity (falls to the ground)'); modeRadios.push(label); } const modeWrap = document.createElement('div'); modeWrap.className = 'size-field'; modeWrap.append('Placement mode', ...modeRadios); fieldset.appendChild(modeWrap); } else { const typeSelect = document.createElement('select'); typeSelect.id = 'bonus-type'; for (const t of BONUS_TYPES) { const opt = document.createElement('option'); opt.value = t.id; opt.textContent = t.label; if (t.id === state.bonusType) opt.selected = true; typeSelect.appendChild(opt); } typeSelect.addEventListener('change', () => { state.bonusType = typeSelect.value; render(); }); const typeWrap = document.createElement('label'); typeWrap.className = 'size-field'; typeWrap.append('Item type', typeSelect); fieldset.appendChild(typeWrap); const floatNote = document.createElement('div'); floatNote.className = 'size-field'; floatNote.style.color = '#9aa7bb'; floatNote.style.fontSize = '12px'; floatNote.textContent = 'Placement mode: always Float (bonus items can only float)'; fieldset.appendChild(floatNote); } const hint = document.createElement('p'); hint.style.margin = '8px 0 0'; hint.style.color = '#9aa7bb'; hint.style.fontSize = '12px'; hint.textContent = state.kind === 'smashable' ? 'Pick a type + mode, then click anywhere on the Preview map above to place the item there. Click a placed item in the list to delete it.' : 'Pick a type, then click anywhere on the Preview map above to place the bonus item there (it floats exactly where placed). Click a placed item in the list to delete it.'; itemsFormEl.appendChild(hint); } // What a click on the preview map should place right now, based on the // "Placeable kind" toggle (see buildItemsForm). function placementSpec() { if (state.kind === 'bonus') { return { kind: 'bonus', type: state.bonusType }; } return { kind: 'smashable', type: state.itemsType, mode: state.itemsMode }; } function itemLabel(item, index) { const t = (item.kind === 'bonus' ? BONUS_TYPES : ITEM_TYPES).find((x) => x.id === item.type); const modePart = item.kind === 'bonus' ? 'bonus/float' : item.mode; return `${index + 1}. ${t ? t.label : item.type} @ (${Math.round(item.x)}, ${Math.round(item.y)}) [${modePart}]`; } function buildItemsList() { itemsListEl.innerHTML = ''; if (state.items.length === 0) { const empty = document.createElement('div'); empty.style.color = '#9aa7bb'; empty.style.fontSize = '12px'; empty.textContent = 'No items placed yet.'; itemsListEl.appendChild(empty); return; } state.items.forEach((item, index) => { const el = document.createElement('div'); el.className = 'item-row'; el.style.cssText = 'display:flex;align-items:center;gap:8px;cursor:pointer;padding:6px 10px;background:#1a2236;border:1px solid #2c374f;border-radius:4px;'; const dot = document.createElement('span'); const color = item.kind === 'bonus' ? BONUS_COLORS[item.type] : ITEM_COLORS[item.type]; dot.style.cssText = `width:10px;height:10px;border-radius:50%;background:${color || '#888'};flex:none;`; const text = document.createElement('span'); text.textContent = itemLabel(item, index); const del = document.createElement('span'); del.textContent = '\u2715'; del.style.cssText = 'margin-left:auto;cursor:pointer;color:#c0392b;'; el.title = 'Click to delete'; el.addEventListener('click', () => { state.items.splice(index, 1); render(); }); el.append(dot, text, del); itemsListEl.appendChild(el); }); } // Rebase item refIndex references after sequence reordering - track item // indexes shift when sections move, so keep items attached to their section. function rebaseItemRefs(oldToNew) { for (const item of state.items) { if (oldToNew.has(item.refIndex)) item.refIndex = oldToNew.get(item.refIndex); } } 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; const oldToNew = new Map(); oldToNew.set(i, i - 1); oldToNew.set(i - 1, i); [state.sequence[i - 1], state.sequence[i]] = [state.sequence[i], state.sequence[i - 1]]; state.selectedIndex = i - 1; rebaseItemRefs(oldToNew); render(); }); moveRightBtn.addEventListener('click', () => { const i = state.selectedIndex; if (i === null || i === 0 || i === state.sequence.length - 1) return; const oldToNew = new Map(); oldToNew.set(i, i + 1); oldToNew.set(i + 1, i); [state.sequence[i + 1], state.sequence[i]] = [state.sequence[i], state.sequence[i + 1]]; state.selectedIndex = i + 1; rebaseItemRefs(oldToNew); render(); }); removeBtn.addEventListener('click', () => { const i = state.selectedIndex; if (i === null || i === 0) return; state.sequence.splice(i, 1); state.selectedIndex = null; // Items referenced removed/shifted sections keep their position (they were // placed in world space anyway) but their refIndex is now meaningless - // clamp it to a valid index for display purposes only. for (const item of state.items) { if (item.refIndex >= state.sequence.length) item.refIndex = state.sequence.length - 1; else if (item.refIndex > i) item.refIndex -= 1; } 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); // Cache the preview bounds for the click handler below (it inverts the // same scale to map screen px back to world units). previewBounds = { minX, minY }; 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); // Placed placeables (smashables + bonus items): the real sprite frame at // the placement point (same space as terrain/start/goal, so the // click-to-place mapping below is exact). Smashable gravity items also get // a dashed drop line down to the ground under that X - where they'll // actually settle - with a small arrowhead. Bonus items are always float. const itemDrawScale = 26; // px on the preview map, per 64px source cell for (const item of state.items) { const ix = screenX(item.x); const iy = screenY(item.y); const isBonus = item.kind === 'bonus'; const color = isBonus ? (BONUS_COLORS[item.type] || '#888') : (ITEM_COLORS[item.type] || '#888'); // Smashables in 'gravity' mode: the drop line (bonus items never do this - // they always float exactly where placed). if (!isBonus && item.mode === 'gravity') { const groundY = terrainSurfaceY(levelData, item.x); if (groundY !== Infinity) { const gy = screenY(groundY); ctx.save(); ctx.setLineDash([4, 4]); ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(ix, iy + itemDrawScale / 2); ctx.lineTo(ix, gy - 4); ctx.stroke(); ctx.setLineDash([]); // arrowhead ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(ix, gy); ctx.lineTo(ix - 4, gy - 7); ctx.lineTo(ix + 4, gy - 7); ctx.closePath(); ctx.fill(); ctx.restore(); } } const frameTable = isBonus ? BONUS_SPRITE_FRAMES : ITEM_SPRITE_FRAMES; const spriteTable = isBonus ? BONUS_SPRITE : ITEM_SPRITE; const frame = frameTable[item.type]; if (spriteTable.complete && spriteTable.naturalWidth > 0 && frame !== undefined) { const half = itemDrawScale / 2; // float items get a light outline glow so they read as "held in place"; // gravity items rest on (or fall to) the ground, drawn as-is. if (item.mode !== 'gravity') { ctx.save(); ctx.strokeStyle = 'rgba(255,255,255,0.85)'; ctx.lineWidth = 1.5; ctx.strokeRect(ix - half - 1, iy - half - 1, itemDrawScale + 2, itemDrawScale + 2); ctx.restore(); } // Both spritesheets share the same 64px cell size, so ITEM_SPRITE_CELL // works for bonus frames too (no separate cell constant needed). ctx.drawImage( spriteTable, frame * ITEM_SPRITE_CELL, 0, ITEM_SPRITE_CELL, ITEM_SPRITE_CELL, ix - half, iy - half, itemDrawScale, itemDrawScale, ); } else { // Sprite not loaded (yet) - colored circle + letter, as before. ctx.beginPath(); ctx.arc(ix, iy, 8, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); ctx.fillStyle = '#0b0e14'; ctx.font = 'bold 9px monospace'; const letters = isBonus ? BONUS_ICONS : ITEM_ICONS; ctx.fillText(letters[item.type] || '??', ix - 4, iy + 3); } } // If an item was just added (by the click handler that runs right after // this draw), give it a one-frame highlight ring so the placement is // visible even when it overlaps terrain. if (state._lastPlacedItem) { const it = state._lastPlacedItem; const ix = screenX(it.x); const iy = screenY(it.y); ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(ix, iy, 12, 0, Math.PI * 2); ctx.stroke(); state._lastPlacedItem = null; } } // --- Click-to-place items on the preview canvas ------------------------- // The preview canvas is the "map" the user clicks on. A click anywhere // (except on an existing item, which deletes it) adds a new smashable item // at that world position using the currently selected type + mode from the // "Smashables" form above. The screen<->world mapping is the same // H_PX_PER_UNIT scale drawPreview used, so a click at screen (sx, sy) maps // to world ((sx - PADDING)/H_PX_PER_UNIT + minX, (sy - PADDING)/H_PX_PER_UNIT + minY). let previewBounds = null; // set by drawPreview each render previewCanvas.addEventListener('click', (e) => { if (!currentLevelData || !previewBounds) return; const rect = previewCanvas.getBoundingClientRect(); const sx = (e.clientX - rect.left) * (previewCanvas.width / rect.width); const sy = (e.clientY - rect.top) * (previewCanvas.height / rect.height); // Delete? If the click is within ~12px of an existing item, remove it. for (let i = state.items.length - 1; i >= 0; i--) { const it = state.items[i]; const ix = (it.x - previewBounds.minX) * H_PX_PER_UNIT + PADDING; const iy = (it.y - previewBounds.minY) * H_PX_PER_UNIT + PADDING; if (Math.hypot(sx - ix, sy - iy) <= 12) { state.items.splice(i, 1); render(); return; } } // Otherwise: place a new item at this world position, using whatever the // "Placeable kind" toggle is set to (see placementSpec). const worldX = (sx - PADDING) / H_PX_PER_UNIT + previewBounds.minX; const worldY = (sy - PADDING) / H_PX_PER_UNIT + previewBounds.minY; const spec = placementSpec(); const item = { kind: spec.kind, type: spec.type, ...(spec.kind === 'smashable' ? { mode: spec.mode } : {}), x: Math.round(worldX), y: Math.round(worldY), refIndex: state.selectedIndex !== null ? state.selectedIndex : 0, }; state.items.push(item); state._lastPlacedItem = item; render(); }); // --- Import wiring ------------------------------------------------------- importSelectEl.addEventListener('change', () => { const id = importSelectEl.value; if (!id) return; const level = LEVELS.find((l) => l.id === id); if (!level) { importStatusEl.hidden = false; importStatusEl.style.color = '#c0392b'; importStatusEl.textContent = `No level with id "${id}".`; return; } setImported(level, 'bundled levels'); }); importFileEl.addEventListener('change', async () => { const file = importFileEl.files && importFileEl.files[0]; if (!file) return; try { const text = await file.text(); // Level files are ES modules (`export default {...}`). Parse the actual // source as a module (not `new Function('return ...')`, which a leading // `//` comment would swallow into the return) via a blob URL, then read // its default export. const blob = new Blob([text], { type: 'text/javascript' }); const url = URL.createObjectURL(blob); let level; try { const mod = await import(url); level = mod.default; } finally { URL.revokeObjectURL(url); } if (!level || typeof level !== 'object') throw new Error('not a level object'); setImported(level, file.name); } catch (err) { importStatusEl.hidden = false; importStatusEl.style.color = '#c0392b'; importStatusEl.textContent = `Could not read ${file.name}: ${err.message}`; } }); clearLevelBtn.addEventListener('click', () => { clearImported(); }); // --- 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() { setSectionsLocked(isImported()); clearLevelBtn.disabled = !isImported(); buildTrack(); updateTrackControls(); updateSizePanel(); buildItemsList(); // The Placeables form depends on state.kind (which type/mode controls show // for the currently-selected placeable kind), so rebuild it on every // render - the kind toggle's own change handler calls render() too. buildItemsForm(); if (isImported()) { // Imported level: render its own exact terrain (plus the editable goal, // start, and placeables) instead of regenerating from the (locked) // sequence. Placeables get split into the two level arrays the game // expects (items / bonusItems) the same way buildLevelData does. const imp = state.imported; currentLevelData = { id: state.metadata.id, name: state.metadata.name, description: state.metadata.description, kidsAboard: state.metadata.kidsAboard, timeLimit: state.metadata.timeLimit, startPosition: imp.metadata.startPosition, startAngle: imp.metadata.startAngle, terrain: imp.terrain, obstacles: imp.metadata.obstacles || [], ...splitPlaceables(state.items), goal: imp.metadata.goal, cameraBounds: imp.metadata.cameraBounds, }; } else { currentLevelData = buildLevelData(state.sequence, state.metadata, state.items); } 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(); // buildItemsForm() runs on every render() (it depends on state.kind), and // render() is called right below - no need to build it once here. buildImportSelect(); initSizeControls(); render();