import * as c2g from '../format-c2g.js'; import { DialogOverlay, AlertOverlay, flash_button } from '../main-base.js'; import CanvasRenderer from '../renderer-canvas.js'; import { mk, mk_button } from '../util.js'; import * as util from '../util.js'; export class EditorPackMetaOverlay extends DialogOverlay { constructor(conductor, stored_pack) { super(conductor); this.set_title("pack properties"); let dl = mk('dl.formgrid'); this.main.append(dl); dl.append( mk('dt', "Title"), mk('dd', mk('input', {name: 'title', type: 'text', value: stored_pack.title})), ); // TODO...? what else is a property of the pack itself this.add_button("save", () => { let els = this.root.elements; let title = els.title.value; if (title !== stored_pack.title) { stored_pack.title = title; this.conductor.update_level_title(); } this.close(); }); this.add_button("nevermind", () => { this.close(); }); } } export class EditorLevelMetaOverlay extends DialogOverlay { constructor(conductor, stored_level) { super(conductor); this.set_title("level properties"); let dl = mk('dl.formgrid'); this.main.append(dl); let time_limit_input = mk('input', {name: 'time_limit', type: 'number', min: 0, max: 65535, value: stored_level.time_limit}); let time_limit_output = mk('output'); let update_time_limit = () => { let time_limit = parseInt(time_limit_input.value, 10); // FIXME need a change event for this tbh? // FIXME handle NaN; maybe block keydown of not-numbers time_limit = Math.max(0, Math.min(65535, time_limit)); time_limit_input.value = time_limit; let text; if (time_limit === 0) { text = "No time limit"; } else { text = util.format_duration(time_limit); } time_limit_output.textContent = text; }; update_time_limit(); time_limit_input.addEventListener('input', update_time_limit); let make_size_input = (name) => { let input = mk('input', {name: name, type: 'number', min: 10, max: 100, value: stored_level[name]}); // TODO maybe block keydown of non-numbers too? // Note that this is a change event, not an input event, so we don't prevent them from // erasing the whole value to type a new one input.addEventListener('change', ev => { let value = parseInt(ev.target.value, 10); if (isNaN(value)) { ev.target.value = stored_level[name]; } else if (value < 1) { // Smaller than 10×10 isn't supported by CC2, but LL doesn't mind, so let it // through if they try it manually ev.target.value = 1; } else if (value > 100) { ev.target.value = 100; } }); return input; }; dl.append( mk('dt', "Title"), mk('dd.-one-field', mk('input', {name: 'title', type: 'text', value: stored_level.title})), mk('dt', "Author"), mk('dd.-one-field', mk('input', {name: 'author', type: 'text', value: stored_level.author})), mk('dt', "Comment"), mk('dd.-textarea', mk('textarea', {name: 'comment', rows: 4, cols: 20}, stored_level.comment)), mk('dt', "Time limit"), mk('dd.-with-buttons', mk('div.-left', time_limit_input, " ", time_limit_output, ), mk('div.-right', mk_button("None", () => { this.root.elements['time_limit'].value = 0; update_time_limit(); }), mk_button("−30s", () => { this.root.elements['time_limit'].value = Math.max(0, parseInt(this.root.elements['time_limit'].value, 10) - 30); update_time_limit(); }), mk_button("+30s", () => { this.root.elements['time_limit'].value = Math.min(999, parseInt(this.root.elements['time_limit'].value, 10) + 30); update_time_limit(); }), mk_button("Max", () => { this.root.elements['time_limit'].value = 999; update_time_limit(); }), ), ), mk('dt', "Size"), mk('dd.-with-buttons', mk('div.-left', make_size_input('size_x'), " × ", make_size_input('size_y')), mk('div.-right', ...[10, 32, 50, 100].map(size => mk_button(`${size}²`, () => { this.root.elements['size_x'].value = size; this.root.elements['size_y'].value = size; }), )), ), mk('dt', "Viewport"), mk('dd', mk('label', mk('input', {name: 'viewport', type: 'radio', value: '10'}), " 10×10 (Chip's Challenge 2 size)"), mk('br'), mk('label', mk('input', {name: 'viewport', type: 'radio', value: '9'}), " 9×9 (Chip's Challenge 1 size)"), mk('br'), mk('label', mk('input', {name: 'viewport', type: 'radio', value: '', disabled: 'disabled'}), " Split 10×10 (not yet supported)"), ), mk('dt', "Blob behavior"), mk('dd', mk('label', mk('input', {name: 'blob_behavior', type: 'radio', value: '0'}), " Deterministic (PRNG + simple convolution)"), mk('br'), mk('label', mk('input', {name: 'blob_behavior', type: 'radio', value: '1'}), " 4 patterns (CC2 default; PRNG + rotating offset)"), mk('br'), mk('label', mk('input', {name: 'blob_behavior', type: 'radio', value: '2'}), " Extra random (LL default; initial seed is truly random)"), ), mk('dt', "Options"), mk('dd', mk('label', mk('input', {name: 'hide_logic', type: 'checkbox'}), " Hide wires and logic gates (warning: CC2 also hides pink/black buttons!)")), mk('dd', mk('label', mk('input', {name: 'use_cc1_boots', type: 'checkbox'}), " Use CC1-style inventory (can only pick up the four classic boots; can't drop or cycle)")), ); this.root.elements['viewport'].value = stored_level.viewport_size; this.root.elements['blob_behavior'].value = stored_level.blob_behavior; this.root.elements['hide_logic'].checked = stored_level.hide_logic; this.root.elements['use_cc1_boots'].checked = stored_level.use_cc1_boots; // TODO: // - chips? // - password??? // - comment // - use CC1 tools // - hide logic // - "unviewable", "read only" this.add_button("save", () => { let els = this.root.elements; let title = els.title.value; if (title !== stored_level.title) { stored_level.title = title; this.conductor.stored_game.level_metadata[this.conductor.level_index].title = title; this.conductor.update_level_title(); } let author = els.author.value; if (author !== stored_level.author) { stored_level.author = author; } // FIXME gotta deal with NaNs here too, sigh, might just need a teeny tiny form library stored_level.time_limit = Math.max(0, Math.min(65535, parseInt(els.time_limit.value, 10))); let size_x = Math.max(1, Math.min(100, parseInt(els.size_x.value, 10))); let size_y = Math.max(1, Math.min(100, parseInt(els.size_y.value, 10))); if (size_x !== stored_level.size_x || size_y !== stored_level.size_y) { this.conductor.editor.resize_level(size_x, size_y); } stored_level.blob_behavior = parseInt(els.blob_behavior.value, 10); stored_level.hide_logic = els.hide_logic.checked; stored_level.use_cc1_boots = els.use_cc1_boots.checked; let viewport_size = parseInt(els.viewport.value, 10); if (viewport_size !== 9 && viewport_size !== 10) { viewport_size = 10; } stored_level.viewport_size = viewport_size; this.conductor.player.update_viewport_size(); this.close(); }); this.add_button("nevermind", () => { this.close(); }); } } // List of levels, used in the player export class EditorLevelBrowserOverlay extends DialogOverlay { constructor(conductor) { super(conductor); this.set_title("choose a level"); // Set up some infrastructure to lazily display level renders // FIXME should this use the tileset appropriate for the particular level? this.renderer = new CanvasRenderer(this.conductor.tilesets['ll'], 32); this.awaiting_renders = []; this.observer = new IntersectionObserver((entries, _observer) => { let any_new = false; let to_remove = new Set; for (let entry of entries) { if (entry.target.classList.contains('--rendered')) continue; let index = this._get_index(entry.target); if (entry.isIntersecting) { this.awaiting_renders.push(index); any_new = true; } else { to_remove.add(index); } } this.awaiting_renders = this.awaiting_renders.filter(index => ! to_remove.has(index)); if (any_new) { this.schedule_level_render(); } }, { root: this.main }, ); this.list = mk('ol.editor-level-browser'); this.selection = this.conductor.level_index; for (let [i, meta] of conductor.stored_game.level_metadata.entries()) { this.list.append(this._make_list_item(i, meta)); } this.list.childNodes[this.selection].classList.add('--selected'); this.main.append( mk('p', "Drag to rearrange. Changes are immediate!"), this.list, ); this.list.addEventListener('click', ev => { let index = this._get_index(ev.target); if (index === null) return; this._select(index); }); this.list.addEventListener('dblclick', ev => { let index = this._get_index(ev.target); if (index !== null && this.conductor.change_level(index)) { this.close(); } }); this.sortable = new Sortable(this.list, { group: 'editor-levels', onEnd: ev => { if (ev.oldIndex === ev.newIndex) return; this._move_level(ev.oldIndex, ev.newIndex); this.undo_stack.push(() => { this.list.insertBefore( this.list.childNodes[ev.newIndex], this.list.childNodes[ev.oldIndex + (ev.oldIndex < ev.newIndex ? 0 : 1)]); this._move_level(ev.newIndex, ev.oldIndex); }); this.undo_button.disabled = false; }, }); // FIXME ring buffer? this.undo_stack = []; // Left buttons this.undo_button = this.add_button("undo", () => { if (! this.undo_stack.length) return; let undo = this.undo_stack.pop(); undo(); this.undo_button.disabled = ! this.undo_stack.length; }); this.undo_button.disabled = true; this.add_button("create", () => { let index = this.selection + 1; let stored_level = this.conductor.editor._make_empty_level(index + 1, 32, 32); this.conductor.editor.move_level(stored_level, index); this._after_insert_level(stored_level, index); this.undo_stack.push(() => { this._delete_level(index); }); this.undo_button.disabled = false; }); this.add_button("duplicate", () => { let index = this.selection + 1; let stored_level = this.conductor.editor.duplicate_level(this.selection); this._after_insert_level(stored_level, index); this.undo_stack.push(() => { this._delete_level(index); }); this.undo_button.disabled = false; }); this.delete_button = this.add_button("delete", () => { let index = this.selection; if (index === this.conductor.level_index) { new AlertOverlay(this.conductor, "You can't delete the level you have open.").open(); return; } // Snag a copy of the serialized level for undo purposes // FIXME can't undo deleting a corrupt level let meta = this.conductor.stored_game.level_metadata[index]; let serialized_level = window.localStorage.getItem(meta.key); this._delete_level(index); this.undo_stack.push(() => { let stored_level = meta.stored_level ?? c2g.parse_level( util.bytestring_to_buffer(serialized_level), index + 1); this.conductor.editor.move_level(stored_level, index); if (this.selection >= index) { this.selection += 1; } this._after_insert_level(stored_level, index); }); this.undo_button.disabled = false; }); this._update_delete_button(); // Right buttons this.add_button_gap(); this.add_button("open", () => { if (this.selection === this.conductor.level_index || this.conductor.change_level(this.selection)) { this.close(); } }); this.add_button("nevermind", () => { this.close(); }); } _make_list_item(index, meta) { let li = mk('li', {'data-index': index}, mk('div.-preview'), mk('div.-number', {}, meta.number), mk('div.-title', {}, meta.error ? "(error!)" : meta.title), ); if (meta.error) { li.classList.add('--error'); } else { this.observer.observe(li); } return li; } renumber_levels(start_index, end_index = null) { end_index = end_index ?? this.conductor.stored_game.level_metadata.length - 1; for (let i = start_index; i <= end_index; i++) { let li = this.list.childNodes[i]; let meta = this.conductor.stored_game.level_metadata[i]; li.setAttribute('data-index', i); li.querySelector('.-number').textContent = meta.number; } } _get_index(element) { let li = element.closest('li'); if (! li) return null; return parseInt(li.getAttribute('data-index'), 10); } _select(index) { this.list.childNodes[this.selection].classList.remove('--selected'); this.selection = index; this.list.childNodes[this.selection].classList.add('--selected'); this._update_delete_button(); } _update_delete_button() { this.delete_button.disabled = !! (this.selection === this.conductor.level_index); } schedule_level_render() { if (this._handle) return; this._handle = setTimeout(() => { this.render_level() }, 50); } render_level() { this._handle = null; let t0 = performance.now(); while (true) { if (this.awaiting_renders.length === 0) return; let index = this.awaiting_renders.shift(); let element = this.list.childNodes[index]; // FIXME levels may have been renumbered since this was queued, whoops let stored_level = this.conductor.stored_game.load_level(index); this.renderer.set_level(stored_level); this.renderer.set_viewport_size(stored_level.size_x, stored_level.size_y); this.renderer.draw_static_region(0, 0, stored_level.size_x, stored_level.size_y); let canvas = mk('canvas', { width: stored_level.size_x * this.renderer.tileset.size_x / 4, height: stored_level.size_y * this.renderer.tileset.size_y / 4, }); canvas.getContext('2d').drawImage(this.renderer.canvas, 0, 0, canvas.width, canvas.height); element.querySelector('.-preview').append(canvas); element.classList.add('--rendered'); if (performance.now() - t0 > 10) break; } this.schedule_level_render(); } expire(index) { let li = this.list.childNodes[index]; li.classList.remove('--rendered'); li.querySelector('.-preview').textContent = ''; } _after_insert_level(stored_level, index) { this.list.insertBefore( this._make_list_item(index, this.conductor.stored_game.level_metadata[index]), this.list.childNodes[index]); this._select(index); this.renumber_levels(index + 1); } _delete_level(index) { let num_levels = this.conductor.stored_game.level_metadata.length; this.conductor.editor.move_level(index, null); this.list.childNodes[this.selection].classList.remove('--selected'); this.list.childNodes[index].remove(); if (index === num_levels - 1) { this.selection -= 1; } else { this.renumber_levels(index); } this.list.childNodes[this.selection].classList.add('--selected'); } _move_level(from_index, to_index) { this.conductor.editor.move_level(from_index, to_index); let selection = this.selection; if (from_index < to_index) { this.renumber_levels(from_index, to_index); if (from_index < selection && selection <= to_index) { selection -= 1; } } else { this.renumber_levels(to_index, from_index); if (to_index <= selection && selection < from_index) { selection += 1; } } if (this.selection === from_index) { this.selection = to_index; } else { this.selection = selection; } this._update_delete_button(); } } export class EditorShareOverlay extends DialogOverlay { constructor(conductor, url) { super(conductor); this.set_title("give this to friends"); this.main.append(mk('p', "Give this URL out to let others try your level:")); this.main.append(mk('p.editor-share-url', {}, url)); let copy_button = mk('button', {type: 'button'}, "Copy to clipboard"); copy_button.addEventListener('click', ev => { flash_button(ev.target); navigator.clipboard.writeText(url); }); this.main.append(copy_button); let ok = mk('button', {type: 'button'}, "neato"); ok.addEventListener('click', () => { this.close(); }); this.footer.append(ok); } } export class EditorExportFailedOverlay extends DialogOverlay { constructor(conductor, errors, _warnings) { // TODO support warnings i guess super(conductor); this.set_title("export didn't go so well"); this.main.append(mk('p', "Whoops! I tried very hard to export your level, but it didn't work out. Sorry.")); let ul = mk('ul.editor-export-errors'); // TODO structure the errors better and give them names out here, also reduce duplication, // also be clear about which are recoverable or not for (let error of errors) { ul.append(mk('li', error)); } this.main.append(ul); this.add_button("oh well", () => { this.close(); }); } }