Split the editor up

This commit is contained in:
Eevee (Evelyn Woods) committed 2021-05-07 17:57:25 -06:00
1 parent 9883dcf4ef
commit 99dec75731
8 files changed
+5019 -5012

No files matched your search

+547
View 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();
});
}
}
File diff suppressed because it is too large. Load diff
+259
View 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
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+290
View File
@@ -0,0 +1,290 @@
import { TransientOverlay } from '../main-base.js';
import { mk, mk_svg } from '../util.js';
// FIXME could very much stand to have a little animation when appearing
class TileEditorOverlay extends TransientOverlay {
constructor(conductor) {
let root = mk('form.editor-popup-tile-editor');
super(conductor, root);
this.editor = conductor.editor;
this.tile = null;
}
edit_tile(tile, cell) {
this.tile = tile;
this.cell = cell;
this.needs_undo_entry = false;
}
// Please call this BEFORE actually modifying the tile; it's important for undo!
mark_dirty() {
if (this.cell) {
if (! this.needs_undo_entry) {
// We are ABOUT to mutate this tile for the first time; swap it out with a clone in
// preparation for making an undo entry when this overlay closes
this.pristine_tile = this.tile;
this.tile = {...this.tile};
this.cell[this.tile.type.layer] = this.tile;
this.needs_undo_entry = true;
}
this.editor.mark_cell_dirty(this.cell);
}
else if (this.tile === this.editor.fg_tile) {
// The change hasn't happened yet! Don't redraw until we return to the event loop
setTimeout(() => this.editor.redraw_foreground_tile(), 0);
}
else if (this.tile === this.editor.bg_tile) {
setTimeout(() => this.editor.redraw_background_tile(), 0);
}
}
close() {
if (this.needs_undo_entry) {
// This will be a no-op the first time since the tile was already swapped, but it's
// important for redo
this.editor._assign_tile(this.cell, this.tile.type.layer, this.tile, this.pristine_tile);
this.editor.commit_undo();
}
super.close();
}
static configure_tile_defaults(tile) {
// FIXME maybe this should be on the tile type, so it functions as documentation there?
}
}
class LetterTileEditor extends TileEditorOverlay {
constructor(conductor) {
super(conductor);
this.root.append(mk('h3', "Letter tile"));
let list = mk('ol.editor-letter-tile-picker');
this.root.append(list);
this.glyph_elements = {};
let add = glyph => {
let input = mk('input', {type: 'radio', name: 'glyph', value: glyph});
this.glyph_elements[glyph] = input;
let item = mk('li', mk('label', input, mk('span.-glyph', glyph)));
list.append(item);
};
let arrows = ["⬆", "➡", "⬇", "⬅"];
for (let c = 32; c < 96; c++) {
let glyph = String.fromCharCode(c);
add(glyph);
// Add the arrows to the ends of the rows
if (c % 16 === 15) {
add(arrows[(c - 47) / 16]);
}
}
list.addEventListener('change', ev => {
if (this.tile) {
this.mark_dirty();
this.tile.overlaid_glyph = this.root.elements['glyph'].value;
}
});
}
edit_tile(tile, cell) {
super.edit_tile(tile, cell);
this.root.elements['glyph'].value = tile.overlaid_glyph;
}
static configure_tile_defaults(tile) {
tile.type.populate_defaults(tile);
}
}
class HintTileEditor extends TileEditorOverlay {
constructor(conductor) {
super(conductor);
this.root.append(mk('h3', "Hint text"));
this.text = mk('textarea.editor-hint-tile-text');
this.root.append(this.text);
this.text.addEventListener('change', ev => {
if (this.tile && this.text.value !== this.tile.hint_text) {
this.mark_dirty();
this.tile.hint_text = this.text.value;
}
});
}
edit_tile(tile, cell) {
super.edit_tile(tile, cell);
this.text.value = tile.hint_text ?? "";
}
static configure_tile_defaults(tile) {
tile.hint_text = "";
}
}
class FrameBlockTileEditor extends TileEditorOverlay {
constructor(conductor) {
super(conductor);
let svg_icons = [];
for (let center of [[16, 0], [16, 16], [0, 16], [0, 0]]) {
let symbol = mk_svg('svg', {viewBox: '0 0 16 16'},
mk_svg('circle', {cx: center[0], cy: center[1], r: 3}),
mk_svg('circle', {cx: center[0], cy: center[1], r: 13}),
);
svg_icons.push(symbol);
}
svg_icons.push(mk_svg('svg', {viewBox: '0 0 16 16'},
mk_svg('rect', {x: -2, y: 3, width: 20, height: 10}),
));
svg_icons.push(mk_svg('svg', {viewBox: '0 0 16 16'},
mk_svg('rect', {x: 3, y: -2, width: 10, height: 20}),
));
this.root.append(mk('h3', "Arrows"));
let arrow_list = mk('ol.editor-directional-block-tile-arrows.editor-tile-editor-svg-parts');
// Arrange the arrows in a grid
for (let [direction, icon] of [
[null, mk_svg('path', {d: 'M 8,16 v -8 h 8'})],
['north', mk_svg('path', {d: 'M 0,12 h 16 l -8,-8 z'})],
[null, mk_svg('path', {d: 'M 0,8 h 8 v 8'})],
['west', mk_svg('path', {d: 'M 12,16 v -16 l -8,8 z'})],
[null, null],
['east', mk_svg('path', {d: 'M 4,0 v 16 l 8,-8 z'})],
[null, mk_svg('path', {d: 'M 16,8 h -8 v -8'})],
['south', mk_svg('path', {d: 'M 16,4 h -16 l 8,8 z'})],
[null, mk_svg('path', {d: 'M 8,0 v 8 h -8'})],
]) {
let li = mk('li');
let svg;
if (icon) {
svg = mk_svg('svg', {viewBox: '0 0 16 16'}, icon);
}
if (direction === null) {
if (svg) {
li.append(svg);
}
}
else {
let input = mk('input', {type: 'checkbox', name: 'direction', value: direction});
li.append(mk('label', input, svg));
}
arrow_list.append(li);
}
arrow_list.addEventListener('change', ev => {
if (! this.tile)
return;
this.mark_dirty();
if (ev.target.checked) {
this.tile.arrows.add(ev.target.value);
}
else {
this.tile.arrows.delete(ev.target.value);
}
});
this.root.append(arrow_list);
}
edit_tile(tile, cell) {
super.edit_tile(tile, cell);
for (let input of this.root.elements['direction']) {
input.checked = tile.arrows.has(input.value);
}
}
static configure_tile_defaults(tile) {
}
}
class RailroadTileEditor extends TileEditorOverlay {
constructor(conductor) {
super(conductor);
let svg_icons = [];
for (let center of [[16, 0], [16, 16], [0, 16], [0, 0]]) {
let symbol = mk_svg('svg', {viewBox: '0 0 16 16'},
mk_svg('circle', {cx: center[0], cy: center[1], r: 3}),
mk_svg('circle', {cx: center[0], cy: center[1], r: 13}),
);
svg_icons.push(symbol);
}
svg_icons.push(mk_svg('svg', {viewBox: '0 0 16 16'},
mk_svg('rect', {x: -2, y: 3, width: 20, height: 10}),
));
svg_icons.push(mk_svg('svg', {viewBox: '0 0 16 16'},
mk_svg('rect', {x: 3, y: -2, width: 10, height: 20}),
));
this.root.append(mk('h3', "Tracks"));
let track_list = mk('ul.editor-railroad-tile-tracks.editor-tile-editor-svg-parts');
// Shown as two rows, this puts the straight parts first and the rest in a circle
let track_order = [4, 1, 2, 5, 0, 3];
for (let i of track_order) {
let input = mk('input', {type: 'checkbox', name: 'track', value: i});
track_list.append(mk('li', mk('label', input, svg_icons[i])));
}
track_list.addEventListener('change', ev => {
if (! this.tile)
return;
this.mark_dirty();
let bit = 1 << ev.target.value;
if (ev.target.checked) {
this.tile.tracks |= bit;
}
else {
this.tile.tracks &= ~bit;
}
});
this.root.append(track_list);
this.root.append(mk('h3', "Switch"));
let switch_list = mk('ul.editor-railroad-tile-tracks.--switch.editor-tile-editor-svg-parts');
for (let i of track_order) {
let input = mk('input', {type: 'radio', name: 'switch', value: i});
switch_list.append(mk('li', mk('label', input, svg_icons[i].cloneNode(true))));
}
// TODO if they remove a track it should change the switch
// TODO if they pick a track that's missing it should add it
switch_list.addEventListener('change', ev => {
if (this.tile) {
this.mark_dirty();
this.tile.track_switch = parseInt(ev.target.value, 10);
}
});
this.root.append(switch_list);
// TODO need a way to set no actor at all
// TODO initial actor facing (maybe only if there's an actor in the cell)
}
edit_tile(tile, cell) {
super.edit_tile(tile, cell);
for (let input of this.root.elements['track']) {
input.checked = !! (tile.tracks & (1 << input.value));
}
if (tile.track_switch === null) {
this.root.elements['switch'].value = '';
}
else {
this.root.elements['switch'].value = tile.track_switch;
}
}
static configure_tile_defaults(tile) {
}
}
export const TILES_WITH_PROPS = {
floor_letter: LetterTileEditor,
hint: HintTileEditor,
frame_block: FrameBlockTileEditor,
railroad: RailroadTileEditor,
// TODO various wireable tiles (hmm not sure how that ui works)
// TODO initial value of counter
// TODO cloner arrows (should this be automatic unless you set them explicitly?)
// TODO later, custom floor/wall selection
};