Split the editor up
This commit is contained in:
parent
9883dcf4ef
commit
99dec75731
547
js/editor/dialogs.js
Normal file
547
js/editor/dialogs.js
Normal file
@ -0,0 +1,547 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
1229
js/editor/editordefs.js
Normal file
1229
js/editor/editordefs.js
Normal file
File diff suppressed because it is too large
Load Diff
259
js/editor/helpers.js
Normal file
259
js/editor/helpers.js
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
// Small helper classes used by the editor, often with their own UI for the SVG overlay.
|
||||||
|
import { mk, mk_svg } from '../util.js';
|
||||||
|
|
||||||
|
export class SVGConnection {
|
||||||
|
constructor(sx, sy, dx, dy) {
|
||||||
|
this.source = mk_svg('circle.-source', {r: 0.5});
|
||||||
|
this.line = mk_svg('line.-arrow', {});
|
||||||
|
this.dest = mk_svg('rect.-dest', {width: 1, height: 1});
|
||||||
|
this.element = mk_svg('g.overlay-connection', this.source, this.line, this.dest);
|
||||||
|
this.set_source(sx, sy);
|
||||||
|
this.set_dest(dx, dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
set_source(sx, sy) {
|
||||||
|
this.sx = sx;
|
||||||
|
this.sy = sy;
|
||||||
|
this.source.setAttribute('cx', sx + 0.5);
|
||||||
|
this.source.setAttribute('cy', sy + 0.5);
|
||||||
|
this.line.setAttribute('x1', sx + 0.5);
|
||||||
|
this.line.setAttribute('y1', sy + 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
set_dest(dx, dy) {
|
||||||
|
this.dx = dx;
|
||||||
|
this.dy = dy;
|
||||||
|
this.line.setAttribute('x2', dx + 0.5);
|
||||||
|
this.line.setAttribute('y2', dy + 0.5);
|
||||||
|
this.dest.setAttribute('x', dx);
|
||||||
|
this.dest.setAttribute('y', dy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// TODO probably need to combine this with Selection somehow since it IS one, just not committed yet
|
||||||
|
export class PendingSelection {
|
||||||
|
constructor(owner) {
|
||||||
|
this.owner = owner;
|
||||||
|
this.element = mk_svg('rect.overlay-pending-selection');
|
||||||
|
this.owner.svg_group.append(this.element);
|
||||||
|
this.rect = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
set_extrema(x0, y0, x1, y1) {
|
||||||
|
this.rect = new DOMRect(Math.min(x0, x1), Math.min(y0, y1), Math.abs(x0 - x1) + 1, Math.abs(y0 - y1) + 1);
|
||||||
|
this.element.classList.add('--visible');
|
||||||
|
this.element.setAttribute('x', this.rect.x);
|
||||||
|
this.element.setAttribute('y', this.rect.y);
|
||||||
|
this.element.setAttribute('width', this.rect.width);
|
||||||
|
this.element.setAttribute('height', this.rect.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
commit() {
|
||||||
|
this.owner.set_from_rect(this.rect);
|
||||||
|
this.element.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
discard() {
|
||||||
|
this.element.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Selection {
|
||||||
|
constructor(editor) {
|
||||||
|
this.editor = editor;
|
||||||
|
|
||||||
|
this.svg_group = mk_svg('g');
|
||||||
|
this.editor.svg_overlay.append(this.svg_group);
|
||||||
|
|
||||||
|
this.rect = null;
|
||||||
|
this.element = mk_svg('rect.overlay-selection.overlay-transient');
|
||||||
|
this.svg_group.append(this.element);
|
||||||
|
|
||||||
|
this.floated_cells = null;
|
||||||
|
this.floated_element = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get is_empty() {
|
||||||
|
return this.rect === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get is_floating() {
|
||||||
|
return !! this.floated_cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
contains(x, y) {
|
||||||
|
// Empty selection means everything is selected?
|
||||||
|
if (this.rect === null)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return this.rect.left <= x && x < this.rect.right && this.rect.top <= y && y < this.rect.bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
create_pending() {
|
||||||
|
return new PendingSelection(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
set_from_rect(rect) {
|
||||||
|
let old_rect = this.rect;
|
||||||
|
this.editor._do(
|
||||||
|
() => this._set_from_rect(rect),
|
||||||
|
() => {
|
||||||
|
if (old_rect) {
|
||||||
|
this._set_from_rect(old_rect);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this._clear();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_set_from_rect(rect) {
|
||||||
|
this.rect = rect;
|
||||||
|
this.element.classList.add('--visible');
|
||||||
|
this.element.setAttribute('x', this.rect.x);
|
||||||
|
this.element.setAttribute('y', this.rect.y);
|
||||||
|
this.element.setAttribute('width', this.rect.width);
|
||||||
|
this.element.setAttribute('height', this.rect.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
move_by(dx, dy) {
|
||||||
|
if (! this.rect)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.rect.x += dx;
|
||||||
|
this.rect.y += dy;
|
||||||
|
this.element.setAttribute('x', this.rect.x);
|
||||||
|
this.element.setAttribute('y', this.rect.y);
|
||||||
|
|
||||||
|
if (! this.floated_element)
|
||||||
|
return;
|
||||||
|
|
||||||
|
let bbox = this.rect;
|
||||||
|
this.floated_element.setAttribute('transform', `translate(${bbox.x} ${bbox.y})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
let rect = this.rect;
|
||||||
|
if (! rect)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.editor._do(
|
||||||
|
() => this._clear(),
|
||||||
|
() => this._set_from_rect(rect),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_clear() {
|
||||||
|
this.rect = null;
|
||||||
|
this.element.classList.remove('--visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
*iter_coords() {
|
||||||
|
if (! this.rect)
|
||||||
|
return;
|
||||||
|
|
||||||
|
let stored_level = this.editor.stored_level;
|
||||||
|
for (let x = this.rect.left; x < this.rect.right; x++) {
|
||||||
|
for (let y = this.rect.top; y < this.rect.bottom; y++) {
|
||||||
|
let n = stored_level.coords_to_scalar(x, y);
|
||||||
|
yield [x, y, n];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert this selection into a floating selection, plucking all the selected cells from the
|
||||||
|
// level and replacing them with blank cells.
|
||||||
|
enfloat(copy = false) {
|
||||||
|
if (this.floated_cells)
|
||||||
|
console.error("Trying to float a selection that's already floating");
|
||||||
|
|
||||||
|
let floated_cells = [];
|
||||||
|
let tileset = this.editor.renderer.tileset;
|
||||||
|
let stored_level = this.editor.stored_level;
|
||||||
|
let bbox = this.rect;
|
||||||
|
let canvas = mk('canvas', {width: bbox.width * tileset.size_x, height: bbox.height * tileset.size_y});
|
||||||
|
let ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(
|
||||||
|
this.editor.renderer.canvas,
|
||||||
|
bbox.x * tileset.size_x, bbox.y * tileset.size_y, bbox.width * tileset.size_x, bbox.height * tileset.size_y,
|
||||||
|
0, 0, bbox.width * tileset.size_x, bbox.height * tileset.size_y);
|
||||||
|
for (let [x, y, n] of this.iter_coords()) {
|
||||||
|
let cell = stored_level.linear_cells[n];
|
||||||
|
if (copy) {
|
||||||
|
floated_cells.push(cell.map(tile => tile ? {...tile} : null));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
floated_cells.push(cell);
|
||||||
|
this.editor.replace_cell(cell, this.editor.make_blank_cell(x, y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let floated_element = mk_svg('g', mk_svg('foreignObject', {
|
||||||
|
x: 0, y: 0,
|
||||||
|
width: canvas.width, height: canvas.height,
|
||||||
|
transform: `scale(${1/tileset.size_x} ${1/tileset.size_y})`,
|
||||||
|
}, canvas));
|
||||||
|
floated_element.setAttribute('transform', `translate(${bbox.x} ${bbox.y})`);
|
||||||
|
|
||||||
|
// FIXME far more memory efficient to recreate the canvas in the redo, rather than hold onto
|
||||||
|
// it forever
|
||||||
|
this.editor._do(
|
||||||
|
() => {
|
||||||
|
this.floated_element = floated_element;
|
||||||
|
this.floated_cells = floated_cells;
|
||||||
|
this.svg_group.append(floated_element);
|
||||||
|
},
|
||||||
|
() => this._defloat(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
stamp_float(copy = false) {
|
||||||
|
if (! this.floated_element)
|
||||||
|
return;
|
||||||
|
|
||||||
|
let stored_level = this.editor.stored_level;
|
||||||
|
let i = 0;
|
||||||
|
for (let [x, y, n] of this.iter_coords()) {
|
||||||
|
let cell = this.floated_cells[i];
|
||||||
|
if (copy) {
|
||||||
|
cell = cell.map(tile => tile ? {...tile} : null);
|
||||||
|
}
|
||||||
|
cell.x = x;
|
||||||
|
cell.y = y;
|
||||||
|
this.editor.replace_cell(stored_level.linear_cells[n], cell);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defloat() {
|
||||||
|
if (! this.floated_element)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.stamp_float();
|
||||||
|
|
||||||
|
let element = this.floated_element;
|
||||||
|
let cells = this.floated_cells;
|
||||||
|
this.editor._do(
|
||||||
|
() => this._defloat(),
|
||||||
|
() => {
|
||||||
|
this.floated_cells = cells;
|
||||||
|
this.floated_element = element;
|
||||||
|
this.svg_group.append(element);
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_defloat() {
|
||||||
|
this.floated_element.remove();
|
||||||
|
this.floated_element = null;
|
||||||
|
this.floated_cells = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO allow floating/dragging, ctrl-dragging to copy, anchoring...
|
||||||
|
// TODO make more stuff respect this (more things should go through Editor for undo reasons anyway)
|
||||||
|
}
|
||||||
|
|
||||||
1696
js/editor/main.js
Normal file
1696
js/editor/main.js
Normal file
File diff suppressed because it is too large
Load Diff
1276
js/editor/mouseops.js
Normal file
1276
js/editor/mouseops.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,5 @@
|
|||||||
import { TransientOverlay } from './main-base.js';
|
import { TransientOverlay } from '../main-base.js';
|
||||||
import { mk, mk_svg } from './util.js';
|
import { mk, mk_svg } from '../util.js';
|
||||||
|
|
||||||
// FIXME could very much stand to have a little animation when appearing
|
// FIXME could very much stand to have a little animation when appearing
|
||||||
class TileEditorOverlay extends TransientOverlay {
|
class TileEditorOverlay extends TransientOverlay {
|
||||||
4989
js/main-editor.js
4989
js/main-editor.js
File diff suppressed because it is too large
Load Diff
31
js/main.js
31
js/main.js
@ -2,19 +2,19 @@
|
|||||||
// - steam: if a player character starts on a force floor they won't be able to make any voluntary movements until they are no longer on a force floor
|
// - steam: if a player character starts on a force floor they won't be able to make any voluntary movements until they are no longer on a force floor
|
||||||
import * as fflate from './vendor/fflate.js';
|
import * as fflate from './vendor/fflate.js';
|
||||||
|
|
||||||
import { COMPAT_FLAGS, COMPAT_RULESET_LABELS, COMPAT_RULESET_ORDER, DIRECTIONS, INPUT_BITS, TICS_PER_SECOND, compat_flags_for_ruleset } from './defs.js';
|
import { COMPAT_FLAGS, COMPAT_RULESET_LABELS, COMPAT_RULESET_ORDER, INPUT_BITS, TICS_PER_SECOND, compat_flags_for_ruleset } from './defs.js';
|
||||||
import * as c2g from './format-c2g.js';
|
import * as c2g from './format-c2g.js';
|
||||||
import * as dat from './format-dat.js';
|
import * as dat from './format-dat.js';
|
||||||
import * as format_base from './format-base.js';
|
import * as format_base from './format-base.js';
|
||||||
import * as format_tws from './format-tws.js';
|
import * as format_tws from './format-tws.js';
|
||||||
import { Level } from './game.js';
|
import { Level } from './game.js';
|
||||||
import { PrimaryView, Overlay, DialogOverlay, ConfirmOverlay, flash_button, load_json_from_storage, save_json_to_storage } from './main-base.js';
|
import { PrimaryView, DialogOverlay, ConfirmOverlay, flash_button, load_json_from_storage, save_json_to_storage } from './main-base.js';
|
||||||
import { Editor } from './main-editor.js';
|
import { Editor } from './editor/main.js';
|
||||||
import CanvasRenderer from './renderer-canvas.js';
|
import CanvasRenderer from './renderer-canvas.js';
|
||||||
import SOUNDTRACK from './soundtrack.js';
|
import SOUNDTRACK from './soundtrack.js';
|
||||||
import { Tileset, CC2_TILESET_LAYOUT, LL_TILESET_LAYOUT, TILE_WORLD_TILESET_LAYOUT, TILESET_LAYOUTS } from './tileset.js';
|
import { Tileset, TILESET_LAYOUTS } from './tileset.js';
|
||||||
import TILE_TYPES from './tiletypes.js';
|
import TILE_TYPES from './tiletypes.js';
|
||||||
import { random_choice, mk, mk_svg, promise_event } from './util.js';
|
import { random_choice, mk, mk_svg } from './util.js';
|
||||||
import * as util from './util.js';
|
import * as util from './util.js';
|
||||||
|
|
||||||
const PAGE_TITLE = "Lexy's Labyrinth";
|
const PAGE_TITLE = "Lexy's Labyrinth";
|
||||||
@ -55,21 +55,6 @@ function simplify_number(number) {
|
|||||||
|
|
||||||
// TODO:
|
// TODO:
|
||||||
// - level password, if any
|
// - level password, if any
|
||||||
const ACTION_LABELS = {
|
|
||||||
up: '⬆️\ufe0f',
|
|
||||||
down: '⬇️\ufe0f',
|
|
||||||
left: '⬅️\ufe0f',
|
|
||||||
right: '➡️\ufe0f',
|
|
||||||
drop: '🚮',
|
|
||||||
cycle: '🔄',
|
|
||||||
swap: '👫',
|
|
||||||
};
|
|
||||||
const ACTION_DIRECTIONS = {
|
|
||||||
up: 'north',
|
|
||||||
down: 'south',
|
|
||||||
left: 'west',
|
|
||||||
right: 'east',
|
|
||||||
};
|
|
||||||
const OBITUARIES = {
|
const OBITUARIES = {
|
||||||
drowned: [
|
drowned: [
|
||||||
"you tried out water cooling",
|
"you tried out water cooling",
|
||||||
@ -687,7 +672,7 @@ class Player extends PrimaryView {
|
|||||||
// Similarly, grab touch events and translate them to directions
|
// Similarly, grab touch events and translate them to directions
|
||||||
this.current_touches = {}; // ident => action
|
this.current_touches = {}; // ident => action
|
||||||
this.touch_restart_delay = new util.DelayTimer;
|
this.touch_restart_delay = new util.DelayTimer;
|
||||||
let touch_target = this.root.querySelector('#player-game-area');
|
let touch_target = this.root.querySelector('#player-game-area'); // FIXME should be .level but the message overlay blocks touching, whoops!
|
||||||
let collect_touches = ev => {
|
let collect_touches = ev => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
@ -3114,6 +3099,7 @@ class CompatOverlay extends DialogOverlay {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIXME this breaks if you add more levels, since it only reloads the list ui after a pack change
|
||||||
class PackTestDialog extends DialogOverlay {
|
class PackTestDialog extends DialogOverlay {
|
||||||
constructor(conductor) {
|
constructor(conductor) {
|
||||||
super(conductor);
|
super(conductor);
|
||||||
@ -3701,6 +3687,9 @@ class Conductor {
|
|||||||
"Enable debug mode? This will give you lots of toys to play with, " +
|
"Enable debug mode? This will give you lots of toys to play with, " +
|
||||||
"but disable all saving of scores until you reload the page!",
|
"but disable all saving of scores until you reload the page!",
|
||||||
() => {
|
() => {
|
||||||
|
// FIXME this breaks if you do it from the editor bc update_tileset hasn't
|
||||||
|
// been called yet bc that happens in load_level which is deferred... but
|
||||||
|
// then why does it work from splash??
|
||||||
this.player.setup_debug();
|
this.player.setup_debug();
|
||||||
},
|
},
|
||||||
).open();
|
).open();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user