lexys-labyrinth/js/main-editor.js

2094 lines
74 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { DIRECTIONS, TICS_PER_SECOND } from './defs.js';
import { TILES_WITH_PROPS } from './editor-tile-overlays.js';
import * as format_base from './format-base.js';
import * as c2g from './format-c2g.js';
import { PrimaryView, TransientOverlay, DialogOverlay, load_json_from_storage, save_json_to_storage } from './main-base.js';
import CanvasRenderer from './renderer-canvas.js';
import TILE_TYPES from './tiletypes.js';
import { SVG_NS, mk, mk_svg, string_from_buffer_ascii, bytestring_to_buffer, walk_grid } from './util.js';
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();
});
}
}
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: 'range', min: 0, max: 999, value: stored_level.time_limit});
let time_limit_output = mk('output');
let update_time_limit = () => {
let time_limit = parseInt(time_limit_input.value, 10);
let text;
if (time_limit === 0) {
text = "No time limit";
}
else {
text = `${time_limit} (${Math.floor(time_limit / 60)}:${('0' + String(time_limit % 60)).slice(-2)})`;
}
time_limit_output.textContent = text;
};
update_time_limit();
time_limit_input.addEventListener('input', update_time_limit);
let make_radio_set = (name, options) => {
let elements = [];
for (let [label, value] of options) {
elements.push();
}
};
dl.append(
mk('dt', "Title"),
mk('dd', mk('input', {name: 'title', type: 'text', value: stored_level.title})),
mk('dt', "Author"),
mk('dd', mk('input', {name: 'author', type: 'text', value: stored_level.author})),
mk('dt', "Time limit"),
mk('dd', time_limit_input, " ", time_limit_output),
mk('dt', "Size"),
mk('dd',
"Width: ",
mk('input', {name: 'size_x', type: 'number', min: 10, max: 100, value: stored_level.size_x}),
mk('br'),
"Height: ",
mk('input', {name: 'size_y', type: 'number', min: 10, max: 100, value: stored_level.size_y}),
),
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 (default; PRNG + rotating offset)"),
mk('br'),
mk('label',
mk('input', {name: 'blob_behavior', type: 'radio', value: '2'}),
" Extra random (initial seed is truly random)"),
),
);
this.root.elements['viewport'].value = stored_level.viewport_size;
this.root.elements['blob_behavior'].value = stored_level.blob_behavior;
// 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.update_level_title();
}
let author = els.author.value;
if (author !== stored_level.author) {
stored_level.author = author;
}
stored_level.time_limit = parseInt(els.time_limit.value, 10);
let size_x = parseInt(els.size_x.value, 10);
let size_y = 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.viewport_size = parseInt(els.viewport.value, 10);
this.conductor.player.update_viewport_size();
this.close();
});
this.add_button("nevermind", () => {
this.close();
});
}
}
// List of levels, used in the player
class EditorLevelBrowserOverlay extends DialogOverlay {
constructor(conductor) {
super(conductor);
this.set_title("choose a level");
// Set up some infrastructure to lazily display level renders
this.renderer = new CanvasRenderer(this.conductor.tileset, 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 = parseInt(entry.target.getAttribute('data-index'), 10);
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');
for (let [i, meta] of conductor.stored_game.level_metadata.entries()) {
let title = meta.title;
let li = mk('li',
{'data-index': i},
mk('div.-preview'),
mk('div.-number', {}, meta.number),
mk('div.-title', {}, meta.error ? "(error!)" : meta.title),
);
this.list.append(li);
if (meta.error) {
li.classList.add('--error');
}
else {
this.observer.observe(li);
}
}
this.main.append(this.list);
this.list.addEventListener('click', ev => {
let li = ev.target.closest('li');
if (! li)
return;
let index = parseInt(li.getAttribute('data-index'), 10);
if (this.conductor.change_level(index)) {
this.close();
}
});
this.add_button("new level", ev => {
this.conductor.editor.append_new_level();
this.close();
});
this.add_button("nevermind", ev => {
this.close();
});
}
schedule_level_render() {
if (this._handle)
return;
this._handle = setTimeout(() => { this.render_level() }, 100);
}
render_level() {
this._handle = null;
if (this.awaiting_renders.length === 0)
return;
let index = this.awaiting_renders.shift();
let element = this.list.childNodes[index];
let stored_level = this.conductor.stored_game.load_level(index);
this.conductor.editor._xxx_update_stored_level_cells(stored_level);
this.renderer.set_level(stored_level);
this.renderer.set_viewport_size(stored_level.size_x, stored_level.size_y);
this.renderer.draw();
let canvas = mk('canvas', {
width: stored_level.size_x * this.conductor.tileset.size_x / 4,
height: stored_level.size_y * this.conductor.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');
this.schedule_level_render();
}
}
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 => {
navigator.clipboard.writeText(url);
// TODO feedback?
});
this.main.append(copy_button);
let ok = mk('button', {type: 'button'}, "neato");
ok.addEventListener('click', ev => {
this.close();
});
this.footer.append(ok);
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////
// Mouse handling
// Stores and controls what the mouse is doing during a movement, mostly by dispatching to functions
// defined for the individual tools
const MOUSE_BUTTON_MASKS = [1, 4, 2]; // MouseEvent.button/buttons are ordered differently
class MouseOperation {
constructor(editor, ev, target = null) {
this.editor = editor;
this.target = target;
this.button_mask = MOUSE_BUTTON_MASKS[ev.button];
this.alt_mode = ev.button > 0;
this.modifier = null; // or 'shift' or 'ctrl' (ctrl takes precedent)
this._update_modifier(ev);
// Client coordinates of the initial click
this.mx0 = ev.clientX;
this.my0 = ev.clientY;
// Real cell coordinates (i.e. including fractional position within a cell) of the click
[this.gx0f, this.gy0f] = this.editor.renderer.real_cell_coords_from_event(ev);
// Cell coordinates
this.gx0 = Math.floor(this.gx0f);
this.gy0 = Math.floor(this.gy0f);
// Same as above but for the previous mouse position
this.mx1 = this.mx0;
this.my1 = this.my0;
this.gx1f = this.gx0f;
this.gy1f = this.gy0f;
this.gx1 = this.gx0;
this.gy1 = this.gy0;
this.start(ev);
}
cell(gx, gy) {
return this.editor.cell(Math.floor(gx), Math.floor(gy));
}
do_mousemove(ev) {
let [gxf, gyf] = this.editor.renderer.real_cell_coords_from_event(ev);
let gx = Math.floor(gxf);
let gy = Math.floor(gyf);
this._update_modifier(ev);
this.step(ev.clientX, ev.clientY, gxf, gyf, gx, gy);
this.mx1 = ev.clientX;
this.my1 = ev.clientY;
this.gx1f = gxf;
this.gy1f = gyf;
this.gx1 = gx;
this.gy1 = gy;
}
_update_modifier(ev) {
if (ev.ctrlKey) {
this.modifier = 'ctrl';
}
else if (ev.shiftKey) {
this.modifier = 'shift';
}
else {
this.modifier = null;
}
}
do_commit() {
this.commit();
this.cleanup();
}
do_abort() {
this.abort();
this.cleanup();
}
*iter_touched_cells(gxf, gyf) {
for (let pt of walk_grid(
this.gx1f, this.gy1f, gxf, gyf,
// Bound the grid walk to one cell beyond the edges of the level, so that dragging the
// mouse in from outside the actual edges still works reliably
-1, -1, this.editor.stored_level.size_x, this.editor.stored_level.size_y))
{
if (this.editor.is_in_bounds(...pt)) {
yield pt;
}
}
}
// Implement these
start() {}
step(x, y) {}
commit() {}
abort() {}
cleanup() {}
}
class PanOperation extends MouseOperation {
step(mx, my) {
let target = this.editor.viewport_el.parentNode;
target.scrollLeft -= mx - this.mx1;
target.scrollTop -= my - this.my1;
}
}
class DrawOperation extends MouseOperation {
}
class PencilOperation extends DrawOperation {
start() {
this.handle_cell(this.gx0, this.gy0);
}
step(mx, my, gxf, gyf, gx, gy) {
if (this.modifier === 'ctrl') {
this.handle_cell(gx, gy);
}
else {
for (let [x, y] of this.iter_touched_cells(gxf, gyf)) {
this.handle_cell(x, y);
}
}
}
handle_cell(x, y) {
if (this.modifier === 'ctrl') {
let cell = this.cell(x, y);
if (cell) {
this.editor.select_palette(cell[cell.length - 1]);
}
return;
}
let template = this.editor.palette_selection;
if (this.alt_mode) {
// Erase
let cell = this.cell(x, y);
if (this.modifier === 'shift') {
// Aggressive mode: erase the entire cell
cell.length = 0;
cell.push({type: TILE_TYPES.floor});
}
else if (template) {
// Erase whatever's on the same layer
// TODO this seems like the wrong place for this
let layer = template.type.draw_layer;
for (let i = cell.length - 1; i >= 0; i--) {
if (cell[i].type.draw_layer === layer) {
cell.splice(i, 1);
}
}
// Don't allow erasing the floor entirely
if (layer === 0) {
cell.unshift({type: TILE_TYPES.floor});
}
this.editor.mark_cell_dirty(cell);
}
}
else {
// Draw
if (! template)
return;
if (this.modifier === 'shift') {
// Aggressive mode: erase whatever's already in the cell
let cell = this.cell(x, y);
cell.length = 0;
let type = this.editor.palette_selection.type;
if (type.draw_layer !== 0) {
cell.push({type: TILE_TYPES.floor});
}
this.editor.place_in_cell(x, y, template);
}
else {
// Default operation: only erase whatever's on the same layer
this.editor.place_in_cell(x, y, template);
}
}
}
}
class ForceFloorOperation extends DrawOperation {
start() {
// Begin by placing an all-way force floor under the mouse
this.editor.place_in_cell(x, y, 'force_floor_all');
}
step(mx, my, gxf, gyf) {
// Walk the mouse movement and change each we touch to match the direction we
// crossed the border
// FIXME occasionally i draw a tetris S kinda shape and both middle parts point
// the same direction, but shouldn't
let i = 0;
let prevx, prevy;
for (let [x, y] of this.iter_touched_cells(gxf, gyf)) {
i++;
// The very first cell is the one the mouse was already in, and we don't
// have a movement direction yet, so leave that alone
if (i === 1) {
prevx = x;
prevy = y;
continue;
}
let name;
if (x === prevx) {
if (y > prevy) {
name = 'force_floor_s';
}
else {
name = 'force_floor_n';
}
}
else {
if (x > prevx) {
name = 'force_floor_e';
}
else {
name = 'force_floor_w';
}
}
// The second cell tells us the direction to use for the first, assuming it
// had some kind of force floor
if (i === 2) {
let prevcell = this.editor.cell(prevx, prevy);
if (prevcell[0].type.name.startsWith('force_floor_')) {
prevcell[0].type = TILE_TYPES[name];
}
}
// Drawing a loop with force floors creates ice (but not in the previous
// cell, obviously)
let cell = this.editor.cell(x, y);
if (cell[0].type.name.startsWith('force_floor_') &&
cell[0].type.name !== name)
{
name = 'ice';
}
this.editor.place_in_cell(x, y, name);
prevx = x;
prevy = y;
}
}
}
// TODO entered cell should get blank railroad?
// TODO maybe place a straight track in the new cell so it looks like we're doing something, then
// fix it if it wasn't there?
// TODO gonna need an ice tool too, so maybe i can merge all three with some base thing that tracks
// the directions the mouse is moving? or is FF tool too different?
class TrackOperation extends DrawOperation {
start() {
// Do nothing to start; we only lay track when the mouse leaves a cell
this.entry_direction = null;
}
step(mx, my, gxf, gyf) {
// Walk the mouse movement and, for every tile we LEAVE, add a railroad track matching the
// two edges of it that we crossed.
let prevx = null, prevy = null;
for (let [x, y] of this.iter_touched_cells(gxf, gyf)) {
if (prevx === null || prevy === null) {
prevx = x;
prevy = y;
continue;
}
// Figure out which way we're leaving the tile
let exit_direction;
if (x === prevx) {
if (y > prevy) {
exit_direction = 'south';
}
else {
exit_direction = 'north';
}
}
else {
if (x > prevx) {
exit_direction = 'east';
}
else {
exit_direction = 'west';
}
}
// If the entry direction is missing or bogus, lay straight track
if (this.entry_direction === null || this.entry_direction === exit_direction) {
this.entry_direction = DIRECTIONS[exit_direction].opposite;
}
// Get the corresponding bit
let bit = null;
for (let [i, track] of TILE_TYPES['railroad'].track_order.entries()) {
if ((track[0] === this.entry_direction && track[1] === exit_direction) ||
(track[1] === this.entry_direction && track[0] === exit_direction))
{
bit = 1 << i;
break;
}
}
if (bit === null)
continue;
// Update the cell we just left
let cell = this.cell(prevx, prevy);
let terrain = cell[0];
if (terrain.type.name === 'railroad') {
if (this.alt_mode) {
// Erase
// TODO fix track switch?
// TODO if this leaves tracks === 0, replace with floor?
terrain.tracks &= ~bit;
}
else {
// Draw
terrain.tracks |= bit;
}
}
else if (! this.alt_mode) {
terrain = { type: TILE_TYPES['railroad'] };
terrain.type.populate_defaults(terrain);
terrain.tracks |= bit;
this.editor.place_in_cell(prevx, prevy, terrain);
}
prevx = x;
prevy = y;
this.entry_direction = DIRECTIONS[exit_direction].opposite;
}
}
}
class WireOperation extends DrawOperation {
start() {
if (this.modifier === 'ctrl') {
// Place or remove wire tunnels
let cell = this.cell(this.gx0f, this.gy0f);
if (! cell)
return;
let direction;
// Use the offset from the center to figure out which edge of the tile to affect
let xoff = this.gx0f % 1 - 0.5;
let yoff = this.gy0f % 1 - 0.5;
if (Math.abs(xoff) > Math.abs(yoff)) {
if (xoff > 0) {
direction = 'east';
}
else {
direction = 'west';
}
}
else {
if (yoff > 0) {
direction = 'south';
}
else {
direction = 'north';
}
}
let bit = DIRECTIONS[direction].bit;
for (let tile of cell) {
if (tile.type.name !== 'floor')
continue;
if (this.alt_mode) {
tile.wire_tunnel_directions &= ~bit;
}
else {
tile.wire_tunnel_directions |= bit;
}
}
return;
}
}
step(mx, my, gxf, gyf) {
if (this.modifier === 'ctrl') {
// Wire tunnels don't support dragging
// TODO but maybe they should?? makes erasing a lot of them easier at least
return;
}
// Wire is interesting. Consider this diagram.
// +-------+
// | . A . |
// |...A...|
// | . A . |
// |BBB+CCC|
// | . D . |
// |...D...|
// | . D . |
// +-------+
// In order to know which of the four wire pieces in a cell (A, B, C, D) someone is trying
// to draw over, we use a quarter-size grid, indicated by the dots. Then any mouse movement
// that crosses the first horizontal grid line means we should draw wire A.
// (Note that crossing either a tile boundary or the middle of a cell doesn't mean anything;
// for example, dragging the mouse horizontally across the A wire is meaningless.)
// TODO maybe i should just have a walk_grid variant that yields line crossings, christ
let prevqx = null, prevqy = null;
for (let [qx, qy] of walk_grid(
this.gx1f * 4, this.gy1f * 4, gxf * 4, gyf * 4,
// See comment in iter_touched_cells
-1, -1, this.editor.stored_level.size_x * 2, this.editor.stored_level.size_y * 2))
{
if (prevqx === null || prevqy === null) {
prevqx = qx;
prevqy = qy;
continue;
}
// Figure out which grid line we've crossed; direction doesn't matter, so we just get
// the index of the line, which matches the coordinate of the cell to the right/bottom
// FIXME 'continue' means we skip the update of prevs, solution is really annoying
// FIXME if you trace around just the outside of a tile, you'll get absolute nonsense:
// +---+---+
// | | |
// | |.+ |
// | |.| |
// +---+.--+
// | .... |
// | +-| |
// | | |
// +---+---+
let wire_direction;
let x, y;
if (qx === prevqx) {
// Vertical
let line = Math.max(qy, prevqy);
// Even crossings don't correspond to a wire
if (line % 2 === 0) {
prevqx = qx;
prevqy = qy;
continue;
}
// Convert to real coordinates
x = Math.floor(qx / 4);
y = Math.floor(line / 4);
if (line % 4 === 1) {
// Consult the diagram!
wire_direction = 'north';
}
else {
wire_direction = 'south';
}
}
else {
// Horizontal; same as above
let line = Math.max(qx, prevqx);
if (line % 2 === 0) {
prevqx = qx;
prevqy = qy;
continue;
}
x = Math.floor(line / 4);
y = Math.floor(qy / 4);
if (line % 4 === 1) {
wire_direction = 'west';
}
else {
wire_direction = 'east';
}
}
if (! this.editor.is_in_bounds(x, y)) {
prevqx = qx;
prevqy = qy;
continue;
}
let cell = this.cell(x, y);
for (let tile of Array.from(cell).reverse()) {
// TODO probably a better way to do this
if (['floor', 'steel', 'button_pink', 'button_black', 'teleport_blue', 'teleport_red', 'light_switch_on', 'light_switch_off', 'circuit_block'].indexOf(tile.type.name) < 0)
continue;
tile.wire_directions = tile.wire_directions ?? 0;
if (this.alt_mode) {
// Erase
tile.wire_directions &= ~DIRECTIONS[wire_direction].bit;
}
else {
// Draw
tile.wire_directions |= DIRECTIONS[wire_direction].bit;
}
// TODO this.editor.mark_tile_dirty(tile);
break;
}
prevqx = qx;
prevqy = qy;
}
}
}
// Tiles the "adjust" tool will turn into each other
const ADJUST_TOGGLES_CW = {};
const ADJUST_TOGGLES_CCW = {};
{
for (let cycle of [
['chip', 'chip_extra'],
// TODO shouldn't this convert regular walls into regular floors then?
['floor_custom_green', 'wall_custom_green'],
['floor_custom_pink', 'wall_custom_pink'],
['floor_custom_yellow', 'wall_custom_yellow'],
['floor_custom_blue', 'wall_custom_blue'],
['fake_floor', 'fake_wall'],
['popdown_floor', 'popdown_wall'],
['wall_invisible', 'wall_appearing'],
['green_floor', 'green_wall'],
['green_bomb', 'green_chip'],
['purple_floor', 'purple_wall'],
['thief_keys', 'thief_tools'],
['swivel_nw', 'swivel_ne', 'swivel_se', 'swivel_sw'],
['ice_nw', 'ice_ne', 'ice_se', 'ice_sw'],
['force_floor_n', 'force_floor_e', 'force_floor_s', 'force_floor_w'],
['ice', 'force_floor_all'],
['water', 'turtle'],
['no_player1_sign', 'no_player2_sign'],
['flame_jet_off', 'flame_jet_on'],
['light_switch_off', 'light_switch_on'],
])
{
for (let [i, tile] of cycle.entries()) {
let other = cycle[(i + 1) % cycle.length];
ADJUST_TOGGLES_CW[tile] = other;
ADJUST_TOGGLES_CCW[other] = tile;
}
}
}
class AdjustOperation extends MouseOperation {
start() {
let cell = this.cell(this.gx1, this.gy1);
if (this.modifier === 'ctrl') {
for (let tile of cell) {
if (TILES_WITH_PROPS[tile.type.name] !== undefined) {
// TODO use the tile's bbox, not the mouse position
this.editor.open_tile_prop_overlay(tile, this.mx0, this.my0);
break;
}
}
return;
}
// FIXME implement shift to always target floor, or maybe start from bottom
for (let i = cell.length - 1; i >= 0; i--) {
let tile = cell[i];
let rotated;
if (this.alt_mode) {
// Reverse, go counterclockwise
rotated = this.editor.rotate_tile_left(tile);
}
else {
rotated = this.editor.rotate_tile_right(tile);
}
if (rotated) {
this.editor.mark_tile_dirty(tile);
break;
}
// Toggle tiles that go in obvious pairs
let other = (this.alt_mode ? ADJUST_TOGGLES_CCW : ADJUST_TOGGLES_CW)[tile.type.name];
if (other) {
tile.type = TILE_TYPES[other];
this.editor.mark_tile_dirty(tile);
break;
}
}
}
// Adjust tool doesn't support dragging
// TODO should it?
// TODO if it does then it should end as soon as you spawn a popup
}
// FIXME currently allows creating outside the map bounds and moving beyond the right/bottom, sigh
class CameraOperation extends MouseOperation {
start(ev) {
this.offset_x = 0;
this.offset_y = 0;
this.resize_x = 0;
this.resize_y = 0;
let cursor;
this.target = ev.target.closest('.overlay-camera');
if (! this.target) {
// Clicking in empty space creates a new camera region
this.mode = 'create';
cursor = 'move';
this.region = new DOMRect(this.gx0, this.gy0, 1, 1);
this.target = mk_svg('rect.overlay-camera', {
x: this.gx0, y: this.gy1, width: 1, height: 1,
'data-region-index': this.editor.stored_level.camera_regions.length,
});
this.editor.connections_g.append(this.target);
}
else {
this.region = this.editor.stored_level.camera_regions[parseInt(this.target.getAttribute('data-region-index'), 10)];
// If we're grabbing an edge, resize it
let rect = this.target.getBoundingClientRect();
let grab_left = (this.mx0 < rect.left + 16);
let grab_right = (this.mx0 > rect.right - 16);
let grab_top = (this.my0 < rect.top + 16);
let grab_bottom = (this.my0 > rect.bottom - 16);
if (grab_left || grab_right || grab_top || grab_bottom) {
this.mode = 'resize';
if (grab_left) {
this.resize_edge_x = -1;
}
else if (grab_right) {
this.resize_edge_x = 1;
}
else {
this.resize_edge_x = 0;
}
if (grab_top) {
this.resize_edge_y = -1;
}
else if (grab_bottom) {
this.resize_edge_y = 1;
}
else {
this.resize_edge_y = 0;
}
if ((grab_top && grab_left) || (grab_bottom && grab_right)) {
cursor = 'nwse-resize';
}
else if ((grab_top && grab_right) || (grab_bottom && grab_left)) {
cursor = 'nesw-resize';
}
else if (grab_top || grab_bottom) {
cursor = 'ns-resize';
}
else {
cursor = 'ew-resize';
}
}
else {
this.mode = 'move';
cursor = 'move';
}
}
this.editor.viewport_el.style.cursor = cursor;
// Create a text element to show the size while editing
this.size_text = mk_svg('text.overlay-edit-tip', {
// Center it within the rectangle probably (x and y are set in _update_size_text)
'text-anchor': 'middle', 'dominant-baseline': 'middle',
});
this._update_size_text();
this.editor.svg_overlay.append(this.size_text);
}
_update_size_text() {
this.size_text.setAttribute('x', this.region.x + this.offset_x + (this.region.width + this.resize_x) / 2);
this.size_text.setAttribute('y', this.region.y + this.offset_y + (this.region.height + this.resize_y) / 2);
this.size_text.textContent = `${this.region.width + this.resize_x} × ${this.region.height + this.resize_y}`;
}
step(mx, my, gxf, gyf, gx, gy) {
// FIXME not right if we zoom, should use gxf
let dx = Math.floor((mx - this.mx0) / this.editor.conductor.tileset.size_x + 0.5);
let dy = Math.floor((my - this.my0) / this.editor.conductor.tileset.size_y + 0.5);
let stored_level = this.editor.stored_level;
if (this.mode === 'create') {
// Just make the new region span between the original click and the new position
this.region.x = Math.min(gx, this.gx0);
this.region.y = Math.min(gy, this.gy0);
this.region.width = Math.max(gx, this.gx0) + 1 - this.region.x;
this.region.height = Math.max(gy, this.gy0) + 1 - this.region.y;
}
else if (this.mode === 'move') {
// Keep it within the map!
this.offset_x = Math.max(- this.region.x, Math.min(stored_level.size_x - this.region.width, dx));
this.offset_y = Math.max(- this.region.y, Math.min(stored_level.size_y - this.region.height, dy));
}
else {
// Resize, based on the edge we originally grabbed
if (this.resize_edge_x < 0) {
// Left
dx = Math.max(-this.region.x, Math.min(this.region.width - 1, dx));
this.resize_x = -dx;
this.offset_x = dx;
}
else if (this.resize_edge_x > 0) {
// Right
dx = Math.max(-(this.region.width - 1), Math.min(stored_level.size_x - this.region.right, dx));
this.resize_x = dx;
this.offset_x = 0;
}
if (this.resize_edge_y < 0) {
// Top
dy = Math.max(-this.region.y, Math.min(this.region.height - 1, dy));
this.resize_y = -dy;
this.offset_y = dy;
}
else if (this.resize_edge_y > 0) {
// Bottom
dy = Math.max(-(this.region.height - 1), Math.min(stored_level.size_y - this.region.bottom, dy));
this.resize_y = dy;
this.offset_y = 0;
}
}
this.target.setAttribute('x', this.region.x + this.offset_x);
this.target.setAttribute('y', this.region.y + this.offset_y);
this.target.setAttribute('width', this.region.width + this.resize_x);
this.target.setAttribute('height', this.region.height + this.resize_y);
this._update_size_text();
}
commit() {
if (this.mode === 'create') {
// Region is already updated, just add it to the level
this.editor.stored_level.camera_regions.push(this.region);
}
else {
// Actually edit the underlying region
this.region.x += this.offset_x;
this.region.y += this.offset_y;
this.region.width += this.resize_x;
this.region.height += this.resize_y;
}
}
abort() {
if (this.mode === 'create') {
// The element was fake, so delete it
this.target.remove();
}
else {
// Move the element back to its original location
this.target.setAttribute('x', this.region.x);
this.target.setAttribute('y', this.region.y);
this.target.setAttribute('width', this.region.width);
this.target.setAttribute('height', this.region.height);
}
}
cleanup() {
this.editor.viewport_el.style.cursor = '';
this.size_text.remove();
}
}
class CameraEraseOperation extends MouseOperation {
start(ev) {
let target = ev.target.closest('.overlay-camera');
if (target) {
let index = parseInt(target.getAttribute('data-region-index'), 10);
target.remove();
this.editor.stored_level.camera_regions.splice(index, 1);
}
}
}
const EDITOR_TOOLS = {
pencil: {
icon: 'icons/tool-pencil.png',
name: "Pencil",
desc: "Place, erase, and select tiles.\nLeft click: draw\nRight click: erase\nShift: Replace all layers\nCtrl-click: eyedrop",
uses_palette: true,
op1: PencilOperation,
op2: PencilOperation,
//hover: show current selection under cursor
},
line: {
// TODO not implemented
icon: 'icons/tool-line.png',
name: "Line",
desc: "Draw straight lines",
uses_palette: true,
},
box: {
// TODO not implemented
icon: 'icons/tool-box.png',
name: "Box",
desc: "Fill a rectangular area with tiles",
uses_palette: true,
},
fill: {
// TODO not implemented
icon: 'icons/tool-fill.png',
name: "Fill",
desc: "Flood-fill an area with tiles",
uses_palette: true,
},
'force-floors': {
icon: 'icons/tool-force-floors.png',
name: "Force floors",
desc: "Draw force floors following the cursor.",
op1: ForceFloorOperation,
},
tracks: {
icon: 'icons/tool-tracks.png',
name: "Tracks",
desc: "Draw tracks following the cursor.\nLeft click: Lay tracks\nRight click: Erase tracks\nCtrl-click: Toggle track switch",
op1: TrackOperation,
op2: TrackOperation,
},
adjust: {
icon: 'icons/tool-adjust.png',
name: "Adjust",
desc: "Edit existing tiles.\nLeft click: rotate actor or toggle terrain\nRight click: rotate or toggle in reverse\nShift: always target terrain\nCtrl-click: edit properties of complex tiles\n(wires, railroads, hints, etc.)",
op1: AdjustOperation,
op2: AdjustOperation,
},
connect: {
// TODO not implemented
icon: 'icons/tool-connect.png',
name: "Connect",
desc: "Set up CC1 clone and trap connections",
},
wire: {
// TODO not implemented
icon: 'icons/tool-wire.png',
name: "Wire",
desc: "Edit CC2 wiring.\nLeft click: draw wires\nRight click: erase wires\nCtrl-click: toggle tunnels (floor only)",
op1: WireOperation,
op2: WireOperation,
},
camera: {
icon: 'icons/tool-camera.png',
name: "Camera",
desc: "Draw and edit custom camera regions",
help: "Draw and edit camera regions.\n(LL only. When the player is within a camera region,\nthe camera stays locked inside it.)\nLeft click: create or edit a region\nRight click: erase a region",
op1: CameraOperation,
op2: CameraEraseOperation,
},
// TODO text tool; thin walls tool; ice tool; map generator?; subtools for select tool (copy, paste, crop)
// TODO interesting option: rotate an actor as you draw it by dragging? or hold a key like in
// slade when you have some selected?
// TODO ah, railroads...
};
const EDITOR_TOOL_ORDER = ['pencil', 'adjust', 'force-floors', 'tracks', 'wire', 'camera'];
// TODO this MUST use a LL tileset!
const EDITOR_PALETTE = [{
title: "Basics",
tiles: [
'player', 'player2',
'chip', 'chip_extra',
'floor', 'wall', 'hint', 'socket', 'exit',
],
}, {
title: "Terrain",
tiles: [
'popwall',
'steel',
'wall_invisible',
'wall_appearing',
'fake_floor',
'fake_wall',
'popdown_floor',
'popdown_wall',
'floor_letter',
'gravel',
'dirt',
'slime',
'thief_keys',
'thief_tools',
'no_player1_sign',
'no_player2_sign',
'floor_custom_green', 'floor_custom_pink', 'floor_custom_yellow', 'floor_custom_blue',
'wall_custom_green', 'wall_custom_pink', 'wall_custom_yellow', 'wall_custom_blue',
'door_blue', 'door_red', 'door_yellow', 'door_green',
'swivel_nw',
'railroad/straight',
'railroad/curve',
'railroad/switch',
'water', 'turtle', 'fire',
'ice', 'ice_nw',
'force_floor_n', 'force_floor_all',
],
}, {
title: "Items",
tiles: [
'key_blue', 'key_red', 'key_yellow', 'key_green',
'flippers', 'fire_boots', 'cleats', 'suction_boots',
'bribe', 'railroad_sign', 'hiking_boots', 'speed_boots',
'xray_eye', 'helmet', 'foil', 'lightning_bolt',
'bowling_ball', 'dynamite', 'no_sign', 'bestowal_bow',
'score_10', 'score_100', 'score_1000', 'score_2x',
],
}, {
title: "Creatures",
tiles: [
'tank_blue',
'tank_yellow',
'ball',
'walker',
'fireball',
'glider',
'bug',
'paramecium',
'doppelganger1',
'doppelganger2',
'teeth',
'teeth_timid',
'floor_mimic',
'ghost',
'rover',
'blob',
],
}, {
title: "Mechanisms",
tiles: [
'dirt_block',
'ice_block',
'directional_block/0',
'directional_block/1',
'directional_block/2a',
'directional_block/2o',
'directional_block/3',
'directional_block/4',
'green_floor',
'green_wall',
'green_chip',
'green_bomb',
'button_green',
'button_blue',
'button_yellow',
'bomb',
'button_red', 'cloner',
'button_brown', 'trap',
'button_orange', 'flame_jet_off', 'flame_jet_on',
'transmogrifier',
'teleport_blue',
'teleport_red',
'teleport_green',
'teleport_yellow',
'stopwatch_bonus',
'stopwatch_penalty',
'stopwatch_toggle',
],
// TODO missing:
// - wires, wire tunnels probably a dedicated tool, placing tunnels like a tile makes no sense
// - canopy normal tile; layering problem
// - thin walls special rotate logic, like force floors; layering problem
// - light switches
// TODO should tiles that respond to wiring and/or gray buttons be highlighted, highlightable?
}, {
title: "Logic",
tiles: [
'logic_gate/not',
'logic_gate/and',
'logic_gate/or',
'logic_gate/xor',
'logic_gate/nand',
'logic_gate/latch-cw',
'logic_gate/latch-ccw',
'logic_gate/counter',
'button_pink',
'button_black',
'light_switch_off',
'light_switch_on',
'purple_floor',
'purple_wall',
'button_gray',
'circuit_block/xxx',
],
}];
const SPECIAL_PALETTE_ENTRIES = {
'directional_block/0': { name: 'directional_block', arrows: new Set },
'directional_block/1': { name: 'directional_block', arrows: new Set(['north']) },
'directional_block/2a': { name: 'directional_block', arrows: new Set(['north', 'east']) },
'directional_block/2o': { name: 'directional_block', arrows: new Set(['north', 'south']) },
'directional_block/3': { name: 'directional_block', arrows: new Set(['north', 'east', 'south']) },
'directional_block/4': { name: 'directional_block', arrows: new Set(['north', 'east', 'south', 'west']) },
// FIXME these should be additive/subtractive, but a track picked up from the level should replace
'railroad/straight': { name: 'railroad', tracks: 1 << 5, track_switch: null, entered_direction: 'north' },
'railroad/curve': { name: 'railroad', tracks: 1 << 0, track_switch: null, entered_direction: 'north' },
'railroad/switch': { name: 'railroad', tracks: 0, track_switch: 0, entered_direction: 'north' },
'logic_gate/not': { name: 'logic_gate', direction: 'north', gate_type: 'not' },
'logic_gate/and': { name: 'logic_gate', direction: 'north', gate_type: 'and' },
'logic_gate/or': { name: 'logic_gate', direction: 'north', gate_type: 'or' },
'logic_gate/xor': { name: 'logic_gate', direction: 'north', gate_type: 'xor' },
'logic_gate/nand': { name: 'logic_gate', direction: 'north', gate_type: 'nand' },
'logic_gate/latch-cw': { name: 'logic_gate', direction: 'north', gate_type: 'latch-cw' },
'logic_gate/latch-ccw': { name: 'logic_gate', direction: 'north', gate_type: 'latch-ccw' },
'logic_gate/counter': { name: 'logic_gate', direction: 'north', gate_type: 'counter', memory: 0 },
'circuit_block/xxx': { name: 'circuit_block', direction: 'south', wire_directions: 0xf },
};
const _RAILROAD_ROTATED_LEFT = [3, 0, 1, 2, 5, 4];
const _RAILROAD_ROTATED_RIGHT = [1, 2, 3, 0, 5, 4];
const SPECIAL_PALETTE_BEHAVIOR = {
directional_block: {
pick_palette_entry(tile) {
if (tile.arrows.size === 2) {
let [a, b] = tile.arrows.keys();
if (a === DIRECTIONS[b].opposite) {
return 'directional_block/2o';
}
else {
return 'directional_block/2a';
}
}
else {
return `directional_block/${tile.arrows.size}`;
}
},
rotate_left(tile) {
tile.arrows = new Set(Array.from(tile.arrows, arrow => DIRECTIONS[arrow].left));
},
rotate_right(tile) {
tile.arrows = new Set(Array.from(tile.arrows, arrow => DIRECTIONS[arrow].right));
},
},
logic_gate: {
pick_palette_entry(tile) {
return `logic_gate/${tile.gate_type}`;
},
rotate_left(tile) {
if (tile.gate_type === 'counter') {
tile.memory = (tile.memory + 9) % 10;
}
else {
tile.direction = DIRECTIONS[tile.direction].left;
}
},
rotate_right(tile) {
if (tile.gate_type === 'counter') {
tile.memory = (tile.memory + 1) % 10;
}
else {
tile.direction = DIRECTIONS[tile.direction].right;
}
},
},
railroad: {
pick_palette_entry(tile) {
// This is a little fuzzy, since railroads are compound, but we just go with the first
// one that matches and fall back to the switch if it's empty
if (tile.tracks & 0x30) {
return 'railroad/straight';
}
if (tile.tracks) {
return 'railroad/curve';
}
return 'railroad/switch';
},
rotate_left(tile) {
let new_tracks = 0;
for (let i = 0; i < 6; i++) {
if (tile.tracks & (1 << i)) {
new_tracks |= 1 << _RAILROAD_ROTATED_LEFT[i];
}
}
tile.tracks = new_tracks;
if (tile.track_switch !== null) {
tile.track_switch = _RAILROAD_ROTATED_LEFT[tile.track_switch];
}
if (tile.entered_direction) {
tile.entered_direction = DIRECTIONS[tile.entered_direction].left;
}
},
rotate_right(tile) {
let new_tracks = 0;
for (let i = 0; i < 6; i++) {
if (tile.tracks & (1 << i)) {
new_tracks |= 1 << _RAILROAD_ROTATED_RIGHT[i];
}
}
tile.tracks = new_tracks;
if (tile.track_switch !== null) {
tile.track_switch = _RAILROAD_ROTATED_RIGHT[tile.track_switch];
}
if (tile.entered_direction) {
tile.entered_direction = DIRECTIONS[tile.entered_direction].right;
}
},
},
circuit_block: {
pick_palette_entry(tile) {
return 'circuit_block/xxx';
},
},
};
// Fill in some special behavior that boils down to rotating tiles which happen to be encoded as
// different tile types
for (let cycle of [
['force_floor_n', 'force_floor_e', 'force_floor_s', 'force_floor_w'],
['ice_nw', 'ice_ne', 'ice_se', 'ice_sw'],
['swivel_nw', 'swivel_ne', 'swivel_se', 'swivel_sw'],
]) {
for (let [i, name] of cycle.entries()) {
let left = cycle[(i - 1 + cycle.length) % cycle.length];
let right = cycle[(i + 1) % cycle.length];
SPECIAL_PALETTE_BEHAVIOR[name] = {
pick_palette_entry(tile) {
return name;
},
rotate_left(tile) {
tile.type = TILE_TYPES[left];
},
rotate_right(tile) {
tile.type = TILE_TYPES[right];
},
};
}
}
export class Editor extends PrimaryView {
constructor(conductor) {
super(conductor, document.body.querySelector('main#editor'));
this.viewport_el = this.root.querySelector('.editor-canvas .-container');
// Load editor state; we may need this before setup() since we create new levels before
// actually loading the editor proper
this.stash = load_json_from_storage("Lexy's Labyrinth editor");
if (! this.stash) {
this.stash = {
packs: {}, // key: { title, level_count, last_modified }
// More pack data is stored separately under the key, as {
// levels: [{key, title}],
// }
// Levels are also stored under separate keys, encoded as C2M.
};
}
this.pack_stash = null;
this.level_stash = null;
// FIXME don't hardcode size here, convey this to renderer some other way
this.renderer = new CanvasRenderer(this.conductor.tileset, 32);
this.renderer.perception = 2;
// FIXME need this in load_level which is called even if we haven't been setup yet
this.connections_g = mk_svg('g');
// This SVG draws vectors on top of the editor, like monster paths and button connections
this.svg_overlay = mk_svg('svg.level-editor-overlay', {viewBox: '0 0 32 32'}, this.connections_g);
this.viewport_el.append(this.renderer.canvas, this.svg_overlay);
}
setup() {
// Keyboard shortcuts
window.addEventListener('keydown', ev => {
if (! this.active)
return;
if (ev.key === ',') {
if (ev.shiftKey) {
this.rotate_palette_left();
}
else if (this.palette_selection) {
this.rotate_tile_left(this.palette_selection);
}
}
else if (ev.key === '.') {
if (ev.shiftKey) {
this.rotate_palette_right();
}
else if (this.palette_selection) {
this.rotate_tile_right(this.palette_selection);
}
}
});
// Level canvas and mouse handling
this.mouse_op = null;
this.viewport_el.addEventListener('mousedown', ev => {
this.cancel_mouse_operation();
if (ev.button === 0) {
// Left button: activate tool
let op_type = EDITOR_TOOLS[this.current_tool].op1;
if (! op_type)
return;
this.mouse_op = new op_type(this, ev);
ev.preventDefault();
ev.stopPropagation();
// FIXME eventually this should be automatic
this.renderer.draw();
}
else if (ev.button === 1) {
// Middle button: always pan
this.mouse_op = new PanOperation(this, ev);
ev.preventDefault();
ev.stopPropagation();
}
else if (ev.button === 2) {
// Right button: activate tool's alt mode
let op_type = EDITOR_TOOLS[this.current_tool].op2;
if (! op_type)
return;
this.mouse_op = new op_type(this, ev);
ev.preventDefault();
ev.stopPropagation();
this.renderer.draw();
}
});
// Once the mouse is down, we should accept mouse movement anywhere
window.addEventListener('mousemove', ev => {
if (! this.active)
return;
if (! this.mouse_op)
return;
if ((ev.buttons & this.mouse_op.button_mask) === 0) {
this.cancel_mouse_operation();
return;
}
this.mouse_op.do_mousemove(ev);
// FIXME !!!
this.renderer.draw();
});
// TODO should this happen for a mouseup anywhere?
this.viewport_el.addEventListener('mouseup', ev => {
if (this.mouse_op) {
this.mouse_op.do_commit();
this.mouse_op = null;
ev.stopPropagation();
ev.preventDefault();
}
});
// Disable context menu, which interferes with right-click tools
this.viewport_el.addEventListener('contextmenu', ev => {
ev.preventDefault();
});
window.addEventListener('blur', ev => {
this.cancel_mouse_operation();
});
// Toolbox
// Selected tile
this.selected_tile_el = this.root.querySelector('.controls #editor-tile');
this.selected_tile_el.addEventListener('click', ev => {
if (this.palette_selection && TILES_WITH_PROPS[this.palette_selection.type.name]) {
// FIXME use tile bounds
this.open_tile_prop_overlay(this.palette_selection, ev.clientX, ev.clientY);
}
});
// Tools themselves
let toolbox = mk('div.icon-button-set', {id: 'editor-toolbar'});
this.root.querySelector('.controls').append(toolbox);
this.tool_button_els = {};
for (let toolname of EDITOR_TOOL_ORDER) {
let tooldef = EDITOR_TOOLS[toolname];
let button = mk(
'button', {
type: 'button',
'data-tool': toolname,
},
mk('img', {
src: tooldef.icon,
alt: tooldef.name,
}),
mk('div.-help', mk('h3', tooldef.name), tooldef.desc),
);
this.tool_button_els[toolname] = button;
toolbox.append(button);
}
this.current_tool = 'pencil';
this.tool_button_els['pencil'].classList.add('-selected');
toolbox.addEventListener('click', ev => {
let button = ev.target.closest('.icon-button-set button');
if (! button)
return;
this.select_tool(button.getAttribute('data-tool'));
});
// Rotation buttons, which affect both the palette tile and the entire palette
this.palette_rotation_index = 0;
this.palette_actor_direction = 'south';
let rotate_left_button = mk('button.--image', {type: 'button'}, mk('img', {src: '/icons/rotate-left.png'}));
rotate_left_button.addEventListener('click', ev => {
this.rotate_palette_left();
});
// TODO finish this up: this.root.querySelector('.controls').append(rotate_left_button);
// Toolbar buttons for saving, exporting, etc.
let button_container = mk('div.-buttons');
this.root.querySelector('.controls').append(button_container);
let _make_button = (label, onclick) => {
let button = mk('button', {type: 'button'}, label);
button.addEventListener('click', onclick);
button_container.append(button);
return button;
};
_make_button("Pack properties...", ev => {
new EditorPackMetaOverlay(this.conductor, this.conductor.stored_game).open();
});
_make_button("Level properties...", ev => {
new EditorLevelMetaOverlay(this.conductor, this.stored_level).open();
});
this.save_button = _make_button("Save", ev => {
// TODO need feedback. or maybe not bc this should be replaced with autosave later
// TODO also need to update the pack data's last modified time
let stored_game = this.conductor.stored_game;
if (! stored_game.editor_metadata)
return;
// Update the pack index; we need to do this to update the last modified time anyway, so
// there's no point in checking whether anything actually changed
let pack_key = stored_game.editor_metadata.key;
this.stash.packs[pack_key].title = stored_game.title;
this.stash.packs[pack_key].last_modified = Date.now();
// Update the pack itself
// TODO maybe should keep this around, but there's a tricky order of operations thing
// with it
let pack_stash = load_json_from_storage(pack_key);
pack_stash.title = stored_game.title;
pack_stash.last_modified = Date.now();
// Serialize the level itself
let buf = c2g.synthesize_level(this.stored_level);
let stringy_buf = string_from_buffer_ascii(buf);
// Save everything at once, level first, to minimize chances of an error getting things
// out of sync
window.localStorage.setItem(this.stored_level.editor_metadata.key, stringy_buf);
save_json_to_storage(pack_key, pack_stash);
save_json_to_storage("Lexy's Labyrinth editor", this.stash);
});
if (this.stored_level) {
this.save_button.disabled = ! this.conductor.stored_game.editor_metadata;
}
_make_button("Share", ev => {
let buf = c2g.synthesize_level(this.stored_level);
// FIXME Not ideal, but btoa() wants a string rather than any of the myriad binary types
let stringy_buf = Array.from(new Uint8Array(buf)).map(n => String.fromCharCode(n)).join('');
// Make URL-safe and strip trailing padding
let data = btoa(stringy_buf).replace(/[+]/g, '-').replace(/[/]/g, '_').replace(/=+$/, '');
let url = new URL(location);
url.searchParams.delete('level');
url.searchParams.delete('setpath');
url.searchParams.append('level', data);
new EditorShareOverlay(this.conductor, url.toString()).open();
});
//_make_button("Toggle green objects");
// Tile palette
let palette_el = this.root.querySelector('.palette');
this.palette = {}; // name => element
for (let sectiondef of EDITOR_PALETTE) {
let section_el = mk('section');
palette_el.append(mk('h2', sectiondef.title), section_el);
for (let key of sectiondef.tiles) {
let entry;
if (SPECIAL_PALETTE_ENTRIES[key]) {
let tile = SPECIAL_PALETTE_ENTRIES[key];
entry = this.renderer.create_tile_type_canvas(tile.name, tile);
}
else {
entry = this.renderer.create_tile_type_canvas(key);
}
entry.setAttribute('data-palette-key', key);
entry.classList = 'palette-entry';
this.palette[key] = entry;
section_el.append(entry);
}
}
palette_el.addEventListener('click', ev => {
let entry = ev.target.closest('canvas.palette-entry');
if (! entry)
return;
let key = entry.getAttribute('data-palette-key');
if (SPECIAL_PALETTE_ENTRIES[key]) {
// Tile with preconfigured stuff on it
let tile = Object.assign({}, SPECIAL_PALETTE_ENTRIES[key]);
tile.type = TILE_TYPES[tile.name];
delete tile.name;
this.select_palette(tile);
}
else {
// Regular tile name
this.select_palette(key);
}
});
this.palette_selection = null;
this.select_palette('floor');
}
activate() {
super.activate();
this.renderer.draw();
}
_make_cell() {
let cell = new format_base.StoredCell;
cell.push({type: TILE_TYPES['floor']});
return cell;
}
_make_empty_level(number, size_x, size_y) {
let stored_level = new format_base.StoredLevel(number);
stored_level.title = "untitled level";
stored_level.size_x = size_x;
stored_level.size_y = size_y;
stored_level.viewport_size = 10;
for (let i = 0; i < size_x * size_y; i++) {
stored_level.linear_cells.push(this._make_cell());
}
stored_level.linear_cells[0].push({type: TILE_TYPES['player'], direction: 'south'});
return stored_level;
}
create_pack() {
// TODO get a dialog for asking about level meta first? or is jumping directly into the editor better?
let stored_level = this._make_empty_level(1, 32, 32);
let pack_key = `LLP-${Date.now()}`;
let level_key = `LLL-${Date.now()}`;
let stored_pack = new format_base.StoredPack(pack_key);
stored_pack.editor_metadata = {
key: pack_key,
};
stored_level.editor_metadata = {
key: level_key,
};
// FIXME should convert this to the storage-backed version when switching levels, rather
// than keeping it around?
stored_pack.level_metadata.push({
stored_level: stored_level,
key: level_key,
title: stored_level.title,
index: 0,
number: 1,
});
this.conductor.load_game(stored_pack);
this.stash.packs[pack_key] = {
title: "Untitled pack",
level_count: 1,
last_modified: Date.now(),
};
save_json_to_storage("Lexy's Labyrinth editor", this.stash);
save_json_to_storage(pack_key, {
levels: [{
key: level_key,
title: stored_level.title,
last_modified: Date.now(),
}],
});
let buf = c2g.synthesize_level(stored_level);
let stringy_buf = string_from_buffer_ascii(buf);
window.localStorage.setItem(level_key, stringy_buf);
this.conductor.switch_to_editor();
}
create_scratch_level() {
let stored_level = this._make_empty_level(1, 32, 32);
let stored_pack = new format_base.StoredPack(null);
stored_pack.title = "scratch pack";
stored_pack.level_metadata.push({
stored_level: stored_level,
});
this.conductor.load_game(stored_pack);
this.conductor.switch_to_editor();
}
load_editor_pack(pack_key) {
let pack_stash = load_json_from_storage(pack_key);
let stored_pack = new format_base.StoredPack(pack_key, meta => {
let buf = bytestring_to_buffer(localStorage.getItem(meta.key));
let stored_level = c2g.parse_level(buf, meta.number);
stored_level.editor_metadata = {
key: meta.key,
};
return stored_level;
});
// TODO should this also be in the pack's stash...?
stored_pack.title = this.stash.packs[pack_key].title;
stored_pack.editor_metadata = {
key: pack_key,
};
for (let [i, leveldata] of pack_stash.levels.entries()) {
stored_pack.level_metadata.push({
key: leveldata.key,
title: leveldata.title,
index: i,
number: i + 1,
});
}
this.conductor.load_game(stored_pack);
this.conductor.switch_to_editor();
}
append_new_level() {
let stored_pack = this.conductor.stored_game;
let index = stored_pack.level_metadata.length;
let number = index + 1;
let stored_level = this._make_empty_level(number, 32, 32);
let level_key = `LLL-${Date.now()}`;
stored_level.editor_metadata = {
key: level_key,
};
// FIXME should convert this to the storage-backed version when switching levels, rather
// than keeping it around?
stored_pack.level_metadata.push({
stored_level: stored_level,
key: level_key,
title: stored_level.title,
index: index,
number: number,
});
let pack_key = stored_pack.editor_metadata.key;
let stash_pack_entry = this.stash.packs[pack_key];
stash_pack_entry.level_count = number;
stash_pack_entry.last_modified = Date.now();
save_json_to_storage("Lexy's Labyrinth editor", this.stash);
let pack_stash = load_json_from_storage(pack_key);
pack_stash.levels.push({
key: level_key,
title: stored_level.title,
last_modified: Date.now(),
});
save_json_to_storage(pack_key, pack_stash);
let buf = c2g.synthesize_level(stored_level);
let stringy_buf = string_from_buffer_ascii(buf);
window.localStorage.setItem(level_key, stringy_buf);
this.conductor.change_level(index);
}
load_game(stored_game) {
}
_xxx_update_stored_level_cells(stored_level) {
// XXX need this for renderer compat, not used otherwise, PLEASE delete
stored_level.cells = [];
let row;
for (let [i, cell] of stored_level.linear_cells.entries()) {
if (i % stored_level.size_x === 0) {
row = [];
stored_level.cells.push(row);
}
row.push(cell);
}
}
load_level(stored_level) {
// TODO support a game too i guess
this.stored_level = stored_level;
this.update_viewport_size();
this._xxx_update_stored_level_cells(this.stored_level);
// Load connections
this.connections_g.textContent = '';
for (let [src, dest] of Object.entries(this.stored_level.custom_trap_wiring)) {
let [sx, sy] = this.stored_level.scalar_to_coords(src);
let [dx, dy] = this.stored_level.scalar_to_coords(dest);
this.connections_g.append(
mk_svg('rect.overlay-cxn', {x: sx, y: sy, width: 1, height: 1}),
mk_svg('line.overlay-cxn', {x1: sx + 0.5, y1: sy + 0.5, x2: dx + 0.5, y2: dy + 0.5}),
);
}
// TODO why are these in connections_g lol
for (let [i, region] of this.stored_level.camera_regions.entries()) {
let el = mk_svg('rect.overlay-camera', {x: region.x, y: region.y, width: region.width, height: region.height});
this.connections_g.append(el);
}
this.renderer.set_level(stored_level);
if (this.active) {
this.renderer.draw();
}
if (this.save_button) {
this.save_button.disabled = ! this.conductor.stored_game.editor_metadata;
}
}
update_viewport_size() {
this.renderer.set_viewport_size(this.stored_level.size_x, this.stored_level.size_y);
this.svg_overlay.setAttribute('viewBox', `0 0 ${this.stored_level.size_x} ${this.stored_level.size_y}`);
}
open_level_browser() {
new EditorLevelBrowserOverlay(this.conductor).open();
}
select_tool(tool) {
if (tool === this.current_tool)
return;
if (! this.tool_button_els[tool])
return;
this.tool_button_els[this.current_tool].classList.remove('-selected');
this.current_tool = tool;
this.tool_button_els[this.current_tool].classList.add('-selected');
}
select_palette(name_or_tile) {
let name, tile;
if (typeof name_or_tile === 'string') {
name = name_or_tile;
tile = { type: TILE_TYPES[name] };
if (tile.type.is_actor) {
tile.direction = 'south';
}
if (TILES_WITH_PROPS[name]) {
TILES_WITH_PROPS[name].configure_tile_defaults(tile);
}
}
else {
tile = Object.assign({}, name_or_tile);
name = tile.type.name;
}
// Deselect any previous selection
if (this.palette_selection_el) {
this.palette_selection_el.classList.remove('--selected');
}
// Store the tile
this.palette_selection = tile;
// Select it in the palette, if possible
let key = name;
if (SPECIAL_PALETTE_BEHAVIOR[name]) {
key = SPECIAL_PALETTE_BEHAVIOR[name].pick_palette_entry(tile);
}
this.palette_selection_el = this.palette[key] ?? null;
if (this.palette_selection_el) {
this.palette_selection_el.classList.add('--selected');
}
this.mark_tile_dirty(tile);
// Some tools obviously don't work with a palette selection, in which case changing tiles
// should default you back to the pencil
if (! EDITOR_TOOLS[this.current_tool].uses_palette) {
this.select_tool('pencil');
}
}
rotate_tile_left(tile) {
if (SPECIAL_PALETTE_BEHAVIOR[tile.type.name]) {
SPECIAL_PALETTE_BEHAVIOR[tile.type.name].rotate_left(tile);
}
else if (TILE_TYPES[tile.type.name].is_actor) {
tile.direction = DIRECTIONS[tile.direction ?? 'south'].left;
}
else {
return false;
}
this.mark_tile_dirty(tile);
return true;
}
rotate_tile_right(tile) {
if (SPECIAL_PALETTE_BEHAVIOR[tile.type.name]) {
SPECIAL_PALETTE_BEHAVIOR[tile.type.name].rotate_right(tile);
}
else if (TILE_TYPES[tile.type.name].is_actor) {
tile.direction = DIRECTIONS[tile.direction ?? 'south'].right;
}
else {
return false;
}
this.mark_tile_dirty(tile);
return true;
}
rotate_palette_left() {
this.palette_rotation_index += 1;
this.palette_rotation_index %= 4;
this.palette_actor_direction = DIRECTIONS[this.palette_actor_direction].left;
}
mark_tile_dirty(tile) {
// TODO partial redraws! until then, redraw everything
if (tile === this.palette_selection) {
// FIXME should redraw in an existing canvas
this.selected_tile_el.textContent = '';
this.selected_tile_el.append(this.renderer.create_tile_type_canvas(tile.type.name, tile));
}
else {
this.renderer.draw();
}
}
mark_cell_dirty(cell) {
this.renderer.draw();
}
is_in_bounds(x, y) {
return 0 <= x && x < this.stored_level.size_x && 0 <= y && y < this.stored_level.size_y;
}
cell(x, y) {
if (this.is_in_bounds(x, y)) {
return this.stored_level.linear_cells[this.stored_level.coords_to_scalar(x, y)];
}
else {
return null;
}
}
place_in_cell(x, y, tile) {
// TODO weird api?
if (! tile)
return;
let cell = this.cell(x, y);
// Replace whatever's on the same layer
// TODO probably not the best heuristic yet, since i imagine you can
// combine e.g. the tent with thin walls
// TODO should preserve wiring if possible too
for (let i = cell.length - 1; i >= 0; i--) {
if (cell[i].type.draw_layer === tile.type.draw_layer) {
cell.splice(i, 1);
}
}
cell.push(Object.assign({}, tile));
cell.sort((a, b) => a.type.draw_layer - b.type.draw_layer);
}
open_tile_prop_overlay(tile, x0, y0) {
this.cancel_mouse_operation();
// FIXME keep these around, don't recreate them constantly
let overlay_class = TILES_WITH_PROPS[tile.type.name];
let overlay = new overlay_class(this.conductor);
overlay.edit_tile(tile);
overlay.open();
// FIXME move this into TransientOverlay or some other base class
let root = overlay.root;
// Vertical position: either above or below, preferring the side that has more space
if (y0 > document.body.clientHeight / 2) {
// Above
root.classList.add('--above');
root.style.top = `${y0 - root.offsetHeight}px`;
}
else {
// Below
root.classList.remove('--above');
root.style.top = `${y0}px`;
}
// Horizontal position: centered, but kept within the screen
let left;
let margin = 8; // prefer to not quite touch the edges
if (document.body.clientWidth < root.offsetWidth + margin * 2) {
// It doesn't fit on the screen at all, so there's nothing we can do; just center it
left = (document.body.clientWidth - root.offsetWidth) / 2;
}
else {
left = Math.max(margin, Math.min(document.body.clientWidth - root.offsetWidth - margin,
x0 - root.offsetWidth / 2));
}
root.style.left = `${left}px`;
root.style.setProperty('--chevron-offset', `${x0 - left}px`);
}
cancel_mouse_operation() {
if (this.mouse_op) {
this.mouse_op.do_abort();
this.mouse_op = null;
}
}
resize_level(size_x, size_y, x0 = 0, y0 = 0) {
let new_cells = [];
for (let y = y0; y < y0 + size_y; y++) {
for (let x = x0; x < x0 + size_x; x++) {
new_cells.push(this.cell(x, y) ?? this._make_cell());
}
}
this.stored_level.linear_cells = new_cells;
this.stored_level.size_x = size_x;
this.stored_level.size_y = size_y;
this._xxx_update_stored_level_cells(this.stored_level);
this.update_viewport_size();
this.renderer.draw();
}
}