973 lines
36 KiB
JavaScript
973 lines
36 KiB
JavaScript
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/flour - 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 <img> 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 / flour. 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, 9 flour).
|
|
const BONUS_TYPES = [
|
|
{ id: 'ctc', label: 'CTC Box' },
|
|
{ id: 'guitar', label: 'Guitar' },
|
|
{ id: 'cash', label: 'Cash' },
|
|
{ id: 'flour', label: 'Flour' },
|
|
];
|
|
const BONUS_COLORS = { ctc: '#e0a040', guitar: '#4b9a45', cash: '#b3ad9c', flour: '#cbb58e' };
|
|
const BONUS_ICONS = { ctc: 'CT', guitar: 'GT', cash: '$', flour: 'FL' };
|
|
const BONUS_SPRITE = new Image();
|
|
BONUS_SPRITE.src = 'assets/sprites/bonus-items.png';
|
|
const BONUS_SPRITE_FRAMES = { ctc: 0, guitar: 3, cash: 6, flour: 9 };
|
|
|
|
// 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/flour). 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 / Flour)');
|
|
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 = '<div id="empty-state">Add a section to see the preview.</div>';
|
|
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();
|