Add undo/redo support to the editor

This commit is contained in:
Eevee (Evelyn Woods) 2021-01-25 15:26:56 -07:00
parent 884d6d9164
commit acfad66974
2 changed files with 396 additions and 144 deletions

View File

@ -13,19 +13,41 @@ class TileEditorOverlay extends TransientOverlay {
edit_tile(tile, cell) { edit_tile(tile, cell) {
this.tile = tile; this.tile = tile;
this.cell = cell; this.cell = cell;
this.needs_undo_entry = false;
} }
// Please call this BEFORE actually modifying the tile; it's important for undo!
mark_dirty() { mark_dirty() {
if (this.cell) { 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); this.editor.mark_cell_dirty(this.cell);
} }
else { else {
// TODO i guess i'm just kind of assuming it's the palette selection, but it doesn't // TODO i guess i'm just kind of assuming it's the palette selection, but it doesn't
// necessarily have to be, if i... do something later // necessarily have to be, if i... do something later
this.editor.redraw_palette_selection(); // The change hasn't happened yet! Don't redraw until we return to the event loop
setTimeout(() => this.editor.redraw_palette_selection(), 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) { static configure_tile_defaults(tile) {
// FIXME maybe this should be on the tile type, so it functions as documentation there? // FIXME maybe this should be on the tile type, so it functions as documentation there?
} }
@ -57,8 +79,8 @@ class LetterTileEditor extends TileEditorOverlay {
list.addEventListener('change', ev => { list.addEventListener('change', ev => {
if (this.tile) { if (this.tile) {
this.tile.overlaid_glyph = this.root.elements['glyph'].value;
this.mark_dirty(); this.mark_dirty();
this.tile.overlaid_glyph = this.root.elements['glyph'].value;
} }
}); });
} }
@ -150,13 +172,13 @@ class FrameBlockTileEditor extends TileEditorOverlay {
if (! this.tile) if (! this.tile)
return; return;
this.mark_dirty();
if (ev.target.checked) { if (ev.target.checked) {
this.tile.arrows.add(ev.target.value); this.tile.arrows.add(ev.target.value);
} }
else { else {
this.tile.arrows.delete(ev.target.value); this.tile.arrows.delete(ev.target.value);
} }
this.mark_dirty();
}); });
this.root.append(arrow_list); this.root.append(arrow_list);
} }
@ -201,7 +223,10 @@ class RailroadTileEditor extends TileEditorOverlay {
track_list.append(mk('li', mk('label', input, svg_icons[i]))); track_list.append(mk('li', mk('label', input, svg_icons[i])));
} }
track_list.addEventListener('change', ev => { track_list.addEventListener('change', ev => {
if (this.tile) { if (! this.tile)
return;
this.mark_dirty();
let bit = 1 << ev.target.value; let bit = 1 << ev.target.value;
if (ev.target.checked) { if (ev.target.checked) {
this.tile.tracks |= bit; this.tile.tracks |= bit;
@ -209,8 +234,6 @@ class RailroadTileEditor extends TileEditorOverlay {
else { else {
this.tile.tracks &= ~bit; this.tile.tracks &= ~bit;
} }
this.mark_dirty();
}
}); });
this.root.append(track_list); this.root.append(track_list);
@ -224,8 +247,8 @@ class RailroadTileEditor extends TileEditorOverlay {
// TODO if they pick a track that's missing it should add it // TODO if they pick a track that's missing it should add it
switch_list.addEventListener('change', ev => { switch_list.addEventListener('change', ev => {
if (this.tile) { if (this.tile) {
this.tile.track_switch = parseInt(ev.target.value, 10);
this.mark_dirty(); this.mark_dirty();
this.tile.track_switch = parseInt(ev.target.value, 10);
} }
}); });
this.root.append(switch_list); this.root.append(switch_list);
@ -260,6 +283,6 @@ export const TILES_WITH_PROPS = {
railroad: RailroadTileEditor, railroad: RailroadTileEditor,
// TODO various wireable tiles (hmm not sure how that ui works) // TODO various wireable tiles (hmm not sure how that ui works)
// TODO initial value of counter // TODO initial value of counter
// TODO cloner arrows // TODO cloner arrows (should this be automatic unless you set them explicitly?)
// TODO later, custom floor/wall selection // TODO later, custom floor/wall selection
}; };

View File

@ -270,6 +270,7 @@ class EditorLevelBrowserOverlay extends DialogOverlay {
}, },
}); });
// FIXME ring buffer?
this.undo_stack = []; this.undo_stack = [];
// Left buttons // Left buttons
@ -647,11 +648,8 @@ class PencilOperation extends DrawOperation {
if (this.alt_mode) { if (this.alt_mode) {
// Erase // Erase
if (this.modifier === 'shift') { if (this.modifier === 'shift') {
// Aggressive mode: erase the entire cell let new_cell = this.editor.make_blank_cell(x, y);
for (let layer = 0; layer < LAYERS.MAX; layer++) { this.editor.replace_cell(cell, new_cell);
cell[layer] = null;
}
cell[LAYERS.terrain] = {type: TILE_TYPES.floor};
} }
else if (template) { else if (template) {
// Erase whatever's on the same layer // Erase whatever's on the same layer
@ -664,24 +662,26 @@ class PencilOperation extends DrawOperation {
return; return;
if (this.modifier === 'shift') { if (this.modifier === 'shift') {
// Aggressive mode: erase whatever's already in the cell // Aggressive mode: erase whatever's already in the cell
for (let layer = 0; layer < LAYERS.MAX; layer++) { let new_cell = this.editor.make_blank_cell(x, y);
cell[layer] = null; new_cell[template.type.layer] = {...template};
} this.editor.replace_cell(cell, new_cell);
let type = this.editor.palette_selection.type;
if (type.layer !== LAYERS.terrain) {
cell[LAYERS.terrain] = {type: TILE_TYPES.floor};
}
this.editor.place_in_cell(x, y, template);
} }
else { else {
// Default operation: only erase whatever's on the same layer // Default operation: only erase whatever's on the same layer
this.editor.place_in_cell(x, y, template); this.editor.place_in_cell(cell, template);
} }
} }
this.editor.mark_cell_dirty(cell); }
cleanup() {
this.editor.commit_undo();
} }
} }
// TODO also, delete
// TODO also, non-rectangular selections
// TODO also, better marching ants, hard to see on gravel
// TODO press esc to cancel pending selection
class SelectOperation extends MouseOperation { class SelectOperation extends MouseOperation {
start() { start() {
if (! this.editor.selection.is_empty && this.editor.selection.contains(this.gx0, this.gy0)) { if (! this.editor.selection.is_empty && this.editor.selection.contains(this.gx0, this.gy0)) {
@ -700,7 +700,11 @@ class SelectOperation extends MouseOperation {
} }
step(mx, my, gxf, gyf, gx, gy) { step(mx, my, gxf, gyf, gx, gy) {
if (this.mode === 'float') { if (this.mode === 'float') {
if (! this.has_moved) { if (this.has_moved) {
this.editor.selection.move_by(Math.floor(gx - this.gx1), Math.floor(gy - this.gy1));
return;
}
if (this.make_copy) { if (this.make_copy) {
if (this.editor.selection.is_floating) { if (this.editor.selection.is_floating) {
// Stamp the floating selection but keep it floating // Stamp the floating selection but keep it floating
@ -714,12 +718,9 @@ class SelectOperation extends MouseOperation {
this.editor.selection.enfloat(); this.editor.selection.enfloat();
} }
} }
this.editor.selection.move_by(Math.floor(gx - this.gx1), Math.floor(gy - this.gy1));
}
else { else {
this.update_pending_selection(); this.update_pending_selection();
} }
this.has_moved = true; this.has_moved = true;
} }
@ -729,8 +730,20 @@ class SelectOperation extends MouseOperation {
commit() { commit() {
if (this.mode === 'float') { if (this.mode === 'float') {
// Make selection move undoable
let dx = Math.floor(this.gx1 - this.gx0);
let dy = Math.floor(this.gy1 - this.gy0);
if (dx || dy) {
this.editor._done(
() => this.editor.selection.move_by(dx, dy),
() => this.editor.selection.move_by(-dx, -dy),
);
}
} }
else { else {
// If there's an existing floating selection (which isn't what we're operating on),
// commit it before doing anything else
this.editor.selection.defloat();
if (! this.has_moved) { if (! this.has_moved) {
// Plain click clears selection // Plain click clears selection
this.pending_selection.discard(); this.pending_selection.discard();
@ -740,10 +753,11 @@ class SelectOperation extends MouseOperation {
this.pending_selection.commit(); this.pending_selection.commit();
} }
} }
this.editor.commit_undo();
} }
abort() { abort() {
if (this.mode === 'float') { if (this.mode === 'float') {
// Nothing to do really // FIXME revert the move?
} }
else { else {
this.pending_selection.discard(); this.pending_selection.discard();
@ -754,7 +768,7 @@ class SelectOperation extends MouseOperation {
class ForceFloorOperation extends DrawOperation { class ForceFloorOperation extends DrawOperation {
start() { start() {
// Begin by placing an all-way force floor under the mouse // Begin by placing an all-way force floor under the mouse
this.editor.place_in_cell(this.gx0, this.gy0, {type: TILE_TYPES.force_floor_all}); this.editor.place_in_cell(this.cell(this.gx0, this.gy0), {type: TILE_TYPES.force_floor_all});
} }
step(mx, my, gxf, gyf) { step(mx, my, gxf, gyf) {
// Walk the mouse movement and change each we touch to match the direction we // Walk the mouse movement and change each we touch to match the direction we
@ -795,7 +809,7 @@ class ForceFloorOperation extends DrawOperation {
if (i === 2) { if (i === 2) {
let prevcell = this.editor.cell(prevx, prevy); let prevcell = this.editor.cell(prevx, prevy);
if (prevcell[LAYERS.terrain].type.name.startsWith('force_floor_')) { if (prevcell[LAYERS.terrain].type.name.startsWith('force_floor_')) {
this.editor.place_in_cell(prevcell.x, prevcell.y, {type: TILE_TYPES[name]}); this.editor.place_in_cell(prevcell, {type: TILE_TYPES[name]});
} }
} }
@ -807,12 +821,15 @@ class ForceFloorOperation extends DrawOperation {
{ {
name = 'ice'; name = 'ice';
} }
this.editor.place_in_cell(x, y, {type: TILE_TYPES[name]}); this.editor.place_in_cell(cell, {type: TILE_TYPES[name]});
prevx = x; prevx = x;
prevy = y; prevy = y;
} }
} }
cleanup() {
this.editor.commit_undo();
}
} }
// TODO entered cell should get blank railroad? // TODO entered cell should get blank railroad?
@ -878,23 +895,24 @@ class TrackOperation extends DrawOperation {
let cell = this.cell(prevx, prevy); let cell = this.cell(prevx, prevy);
let terrain = cell[0]; let terrain = cell[0];
if (terrain.type.name === 'railroad') { if (terrain.type.name === 'railroad') {
let new_terrain = {...terrain};
if (this.alt_mode) { if (this.alt_mode) {
// Erase // Erase
// TODO fix track switch? // TODO fix track switch?
// TODO if this leaves tracks === 0, replace with floor? // TODO if this leaves tracks === 0, replace with floor?
terrain.tracks &= ~bit; new_terrain.tracks &= ~bit;
} }
else { else {
// Draw // Draw
terrain.tracks |= bit; new_terrain.tracks |= bit;
} }
this.editor.mark_cell_dirty(cell); this.editor.place_in_cell(cell, new_terrain);
} }
else if (! this.alt_mode) { else if (! this.alt_mode) {
terrain = { type: TILE_TYPES['railroad'] }; terrain = { type: TILE_TYPES['railroad'] };
terrain.type.populate_defaults(terrain); terrain.type.populate_defaults(terrain);
terrain.tracks |= bit; terrain.tracks |= bit;
this.editor.place_in_cell(prevx, prevy, terrain); this.editor.place_in_cell(cell, terrain);
} }
prevx = x; prevx = x;
@ -902,6 +920,9 @@ class TrackOperation extends DrawOperation {
this.entry_direction = DIRECTIONS[exit_direction].opposite; this.entry_direction = DIRECTIONS[exit_direction].opposite;
} }
} }
cleanup() {
this.editor.commit_undo();
}
} }
class WireOperation extends DrawOperation { class WireOperation extends DrawOperation {
@ -936,15 +957,16 @@ class WireOperation extends DrawOperation {
let terrain = cell[LAYERS.terrain]; let terrain = cell[LAYERS.terrain];
if (terrain.type.name === 'floor') { if (terrain.type.name === 'floor') {
terrain = {...terrain};
if (this.alt_mode) { if (this.alt_mode) {
terrain.wire_tunnel_directions &= ~bit; terrain.wire_tunnel_directions &= ~bit;
} }
else { else {
terrain.wire_tunnel_directions |= bit; terrain.wire_tunnel_directions |= bit;
} }
this.editor.mark_cell_dirty(cell); this.editor.place_in_cell(cell, terrain);
this.editor.commit_undo();
} }
return;
} }
} }
step(mx, my, gxf, gyf) { step(mx, my, gxf, gyf) {
@ -1053,6 +1075,7 @@ class WireOperation extends DrawOperation {
if (['floor', 'steel', 'button_pink', 'button_black', 'teleport_blue', 'teleport_red', 'light_switch_on', 'light_switch_off', 'circuit_block'].indexOf(tile.type.name) < 0) 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; continue;
tile = {...tile};
tile.wire_directions = tile.wire_directions ?? 0; tile.wire_directions = tile.wire_directions ?? 0;
if (this.alt_mode) { if (this.alt_mode) {
// Erase // Erase
@ -1062,7 +1085,7 @@ class WireOperation extends DrawOperation {
// Draw // Draw
tile.wire_directions |= DIRECTIONS[wire_direction].bit; tile.wire_directions |= DIRECTIONS[wire_direction].bit;
} }
this.editor.mark_cell_dirty(cell); this.editor.place_in_cell(cell, tile);
break; break;
} }
@ -1070,6 +1093,9 @@ class WireOperation extends DrawOperation {
prevqy = qy; prevqy = qy;
} }
} }
cleanup() {
this.editor.commit_undo();
}
} }
// Tiles the "adjust" tool will turn into each other // Tiles the "adjust" tool will turn into each other
@ -1098,6 +1124,7 @@ const ADJUST_TOGGLES_CCW = {};
['no_player1_sign', 'no_player2_sign'], ['no_player1_sign', 'no_player2_sign'],
['flame_jet_off', 'flame_jet_on'], ['flame_jet_off', 'flame_jet_on'],
['light_switch_off', 'light_switch_on'], ['light_switch_off', 'light_switch_on'],
['stopwatch_bonus', 'stopwatch_penalty'],
]) ])
{ {
for (let [i, tile] of cycle.entries()) { for (let [i, tile] of cycle.entries()) {
@ -1127,6 +1154,7 @@ class AdjustOperation extends MouseOperation {
continue; continue;
let rotated; let rotated;
tile = {...tile}; // TODO little inefficient
if (this.alt_mode) { if (this.alt_mode) {
// Reverse, go counterclockwise // Reverse, go counterclockwise
rotated = this.editor.rotate_tile_left(tile); rotated = this.editor.rotate_tile_left(tile);
@ -1135,7 +1163,8 @@ class AdjustOperation extends MouseOperation {
rotated = this.editor.rotate_tile_right(tile); rotated = this.editor.rotate_tile_right(tile);
} }
if (rotated) { if (rotated) {
this.editor.mark_cell_dirty(cell); this.editor.place_in_cell(cell, tile);
this.editor.commit_undo();
break; break;
} }
@ -1143,7 +1172,8 @@ class AdjustOperation extends MouseOperation {
let other = (this.alt_mode ? ADJUST_TOGGLES_CCW : ADJUST_TOGGLES_CW)[tile.type.name]; let other = (this.alt_mode ? ADJUST_TOGGLES_CCW : ADJUST_TOGGLES_CW)[tile.type.name];
if (other) { if (other) {
tile.type = TILE_TYPES[other]; tile.type = TILE_TYPES[other];
this.editor.mark_cell_dirty(cell); this.editor.place_in_cell(cell, tile);
this.editor.commit_undo();
break; break;
} }
} }
@ -1154,6 +1184,8 @@ class AdjustOperation extends MouseOperation {
} }
// FIXME currently allows creating outside the map bounds and moving beyond the right/bottom, sigh // FIXME currently allows creating outside the map bounds and moving beyond the right/bottom, sigh
// FIXME undo
// TODO view is not especially visible
class CameraOperation extends MouseOperation { class CameraOperation extends MouseOperation {
start(ev) { start(ev) {
this.offset_x = 0; this.offset_x = 0;
@ -2464,12 +2496,26 @@ class Selection {
} }
create_pending() { create_pending() {
this.defloat();
return new PendingSelection(this); return new PendingSelection(this);
} }
set_from_rect(rect) { 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.rect = rect;
this.element.classList.add('--visible'); this.element.classList.add('--visible');
this.element.setAttribute('x', this.rect.x); this.element.setAttribute('x', this.rect.x);
@ -2495,28 +2541,42 @@ class Selection {
} }
clear() { clear() {
this.defloat(); let rect = this.rect;
if (! rect)
return;
this.editor._do(
() => this._clear(),
() => this._set_from_rect(rect),
false,
);
}
_clear() {
this.rect = null; this.rect = null;
this.element.classList.remove('--visible'); this.element.classList.remove('--visible');
} }
*iter_cells() { *iter_coords() {
if (! this.rect) if (! this.rect)
return; return;
let stored_level = this.editor.stored_level;
for (let x = this.rect.left; x < this.rect.right; x++) { for (let x = this.rect.left; x < this.rect.right; x++) {
for (let y = this.rect.top; y < this.rect.bottom; y++) { for (let y = this.rect.top; y < this.rect.bottom; y++) {
yield [x, 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) { enfloat(copy = false) {
if (this.floated_cells) if (this.floated_cells)
console.error("Trying to float a selection that's already floating"); console.error("Trying to float a selection that's already floating");
this.floated_cells = []; let floated_cells = [];
let tileset = this.editor.renderer.tileset; let tileset = this.editor.renderer.tileset;
let stored_level = this.editor.stored_level; let stored_level = this.editor.stored_level;
let bbox = this.rect; let bbox = this.rect;
@ -2526,24 +2586,33 @@ class Selection {
this.editor.renderer.canvas, this.editor.renderer.canvas,
bbox.x * tileset.size_x, bbox.y * tileset.size_y, bbox.width * tileset.size_x, bbox.height * tileset.size_y, 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); 0, 0, bbox.width * tileset.size_x, bbox.height * tileset.size_y);
for (let [x, y] of this.iter_cells()) { for (let [x, y, n] of this.iter_coords()) {
let n = stored_level.coords_to_scalar(x, y); let cell = stored_level.linear_cells[n];
if (copy) { if (copy) {
this.floated_cells.push(stored_level.linear_cells[n].map(tile => tile ? {...tile} : null)); floated_cells.push(cell.map(tile => tile ? {...tile} : null));
} }
else { else {
this.floated_cells.push(stored_level.linear_cells[n]); floated_cells.push(cell);
stored_level.linear_cells[n] = this.editor._make_cell(x, y); this.editor.replace_cell(cell, this.editor.make_blank_cell(x, y));
this.editor.mark_cell_dirty(stored_level.linear_cells[n]);
} }
} }
this.floated_element = mk_svg('g', mk_svg('foreignObject', { let floated_element = mk_svg('g', mk_svg('foreignObject', {
x: 0, y: 0, x: 0, y: 0,
width: canvas.width, height: canvas.height, width: canvas.width, height: canvas.height,
transform: `scale(${1/tileset.size_x} ${1/tileset.size_y})`, transform: `scale(${1/tileset.size_x} ${1/tileset.size_y})`,
}, canvas)); }, canvas));
this.floated_element.setAttribute('transform', `translate(${bbox.x} ${bbox.y})`); floated_element.setAttribute('transform', `translate(${bbox.x} ${bbox.y})`);
this.svg_group.append(this.floated_element);
// 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) { stamp_float(copy = false) {
@ -2553,16 +2622,14 @@ class Selection {
let bbox = this.rect; let bbox = this.rect;
let stored_level = this.editor.stored_level; let stored_level = this.editor.stored_level;
let i = 0; let i = 0;
for (let [x, y] of this.iter_cells()) { for (let [x, y, n] of this.iter_coords()) {
let n = stored_level.coords_to_scalar(x, y);
let cell = this.floated_cells[i]; let cell = this.floated_cells[i];
if (copy) { if (copy) {
cell = cell.map(tile => tile ? {...tile} : null); cell = cell.map(tile => tile ? {...tile} : null);
} }
cell.x = x; cell.x = x;
cell.y = y; cell.y = y;
stored_level.linear_cells[n] = cell; this.editor.replace_cell(stored_level.linear_cells[n], cell);
this.editor.mark_cell_dirty(cell);
i += 1; i += 1;
} }
} }
@ -2572,6 +2639,21 @@ class Selection {
return; return;
this.stamp_float(); 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.remove();
this.floated_element = null; this.floated_element = null;
this.floated_cells = null; this.floated_cells = null;
@ -2801,6 +2883,12 @@ export class Editor extends PrimaryView {
button_container.append(button); button_container.append(button);
return button; return button;
}; };
this.undo_button = _make_button("Undo", ev => {
this.undo();
});
this.redo_button = _make_button("Redo", ev => {
this.redo();
});
_make_button("Pack properties...", ev => { _make_button("Pack properties...", ev => {
new EditorPackMetaOverlay(this.conductor, this.conductor.stored_game).open(); new EditorPackMetaOverlay(this.conductor, this.conductor.stored_game).open();
}); });
@ -2965,6 +3053,8 @@ export class Editor extends PrimaryView {
this.select_palette('floor', true); this.select_palette('floor', true);
this.selection = new Selection(this); this.selection = new Selection(this);
this.reset_undo();
} }
activate() { activate() {
@ -2981,9 +3071,10 @@ export class Editor extends PrimaryView {
super.deactivate(); super.deactivate();
} }
// ------------------------------------------------------------------------------------------------
// Level creation, management, and saving // Level creation, management, and saving
_make_cell(x, y) { make_blank_cell(x, y) {
let cell = new format_base.StoredCell; let cell = new format_base.StoredCell;
cell.x = x; cell.x = x;
cell.y = y; cell.y = y;
@ -2998,7 +3089,7 @@ export class Editor extends PrimaryView {
stored_level.size_y = size_y; stored_level.size_y = size_y;
stored_level.viewport_size = 10; stored_level.viewport_size = 10;
for (let i = 0; i < size_x * size_y; i++) { for (let i = 0; i < size_x * size_y; i++) {
stored_level.linear_cells.push(this._make_cell(...stored_level.scalar_to_coords(i))); stored_level.linear_cells.push(this.make_blank_cell(...stored_level.scalar_to_coords(i)));
} }
stored_level.linear_cells[0][LAYERS.actor] = {type: TILE_TYPES['player'], direction: 'south'}; stored_level.linear_cells[0][LAYERS.actor] = {type: TILE_TYPES['player'], direction: 'south'};
return stored_level; return stored_level;
@ -3240,6 +3331,8 @@ export class Editor extends PrimaryView {
{ {
this.conductor.level_index += delta; this.conductor.level_index += delta;
// Update the current level if it's not stored in the metadata yet // Update the current level if it's not stored in the metadata yet
// FIXME if you delete the level before the current one, this gets decremented twice?
// can't seem to reproduce
if (! stored_level) { if (! stored_level) {
this.conductor.stored_level.index += delta; this.conductor.stored_level.index += delta;
this.conductor.stored_level.number += delta; this.conductor.stored_level.number += delta;
@ -3293,6 +3386,7 @@ export class Editor extends PrimaryView {
} }
} }
// ------------------------------------------------------------------------------------------------
// Level loading // Level loading
load_game(stored_game) { load_game(stored_game) {
@ -3314,6 +3408,7 @@ export class Editor extends PrimaryView {
} }
// Load connections // Load connections
// TODO cloners too
this.connections_g.textContent = ''; this.connections_g.textContent = '';
for (let [src, dest] of Object.entries(this.stored_level.custom_trap_wiring)) { for (let [src, dest] of Object.entries(this.stored_level.custom_trap_wiring)) {
let [sx, sy] = this.stored_level.scalar_to_coords(src); let [sx, sy] = this.stored_level.scalar_to_coords(src);
@ -3337,6 +3432,11 @@ export class Editor extends PrimaryView {
if (this.save_button) { if (this.save_button) {
this.save_button.disabled = ! this.conductor.stored_game.editor_metadata; this.save_button.disabled = ! this.conductor.stored_game.editor_metadata;
} }
if (this._done_setup) {
// XXX this doesn't work yet if setup hasn't run because the undo button won't exist
this.reset_undo();
}
} }
update_cell_coordinates() { update_cell_coordinates() {
@ -3351,6 +3451,8 @@ export class Editor extends PrimaryView {
this.svg_overlay.setAttribute('viewBox', `0 0 ${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() { open_level_browser() {
if (! this._level_browser) { if (! this._level_browser) {
this._level_browser = new EditorLevelBrowserOverlay(this.conductor); this._level_browser = new EditorLevelBrowserOverlay(this.conductor);
@ -3478,7 +3580,8 @@ export class Editor extends PrimaryView {
this.palette_actor_direction = DIRECTIONS[this.palette_actor_direction].left; this.palette_actor_direction = DIRECTIONS[this.palette_actor_direction].left;
} }
// -- Drawing -- // ------------------------------------------------------------------------------------------------
// Drawing
redraw_palette_selection() { redraw_palette_selection() {
// FIXME should redraw in an existing canvas // FIXME should redraw in an existing canvas
@ -3488,24 +3591,28 @@ export class Editor extends PrimaryView {
} }
mark_cell_dirty(cell) { mark_cell_dirty(cell) {
this.mark_point_dirty(cell.x, cell.y);
}
mark_point_dirty(x, y) {
if (! this._dirty_rect) { if (! this._dirty_rect) {
this._dirty_rect = new DOMRect(cell.x, cell.y, 1, 1); this._dirty_rect = new DOMRect(x, y, 1, 1);
} }
else { else {
let rect = this._dirty_rect; let rect = this._dirty_rect;
if (cell.x < rect.left) { if (x < rect.left) {
rect.width = rect.right - cell.x; rect.width = rect.right - x;
rect.x = cell.x; rect.x = x;
} }
else if (cell.x >= rect.right) { else if (x >= rect.right) {
rect.width = cell.x - rect.left + 1; rect.width = x - rect.left + 1;
} }
if (cell.y < rect.top) { if (y < rect.top) {
rect.height = rect.bottom - cell.y; rect.height = rect.bottom - y;
rect.y = cell.y; rect.y = y;
} }
else if (cell.y >= rect.bottom) { else if (y >= rect.bottom) {
rect.height = cell.y - rect.top + 1; rect.height = y - rect.top + 1;
} }
} }
} }
@ -3530,7 +3637,8 @@ export class Editor extends PrimaryView {
this._schedule_redraw_loop(); this._schedule_redraw_loop();
} }
// -- Utility/inspection -- // ------------------------------------------------------------------------------------------------
// Utility/inspection
is_in_bounds(x, y) { is_in_bounds(x, y) {
return 0 <= x && x < this.stored_level.size_x && 0 <= y && y < this.stored_level.size_y; return 0 <= x && x < this.stored_level.size_x && 0 <= y && y < this.stored_level.size_y;
@ -3545,39 +3653,39 @@ export class Editor extends PrimaryView {
} }
} }
// -- Mutation -- // ------------------------------------------------------------------------------------------------
// Mutation
place_in_cell(x, y, tile) { // DOES NOT commit the undo entry!
place_in_cell(cell, tile) {
// TODO weird api? // TODO weird api?
if (! tile) if (! tile)
return; return;
if (! this.selection.contains(x, y)) if (! this.selection.contains(cell.x, cell.y))
return; return;
let cell = this.cell(x, y);
this.mark_cell_dirty(cell);
// Replace whatever's on the same layer // Replace whatever's on the same layer
// TODO should preserve wiring if possible too // TODO should preserve wiring if possible too
let existing_tile = cell[tile.type.layer]; let layer = tile.type.layer;
if (existing_tile) { let existing_tile = cell[layer];
// If we find a tile of the same type as the one being drawn, see if it has custom
// combine behavior (only the case if it came from the palette) // If we find a tile of the same type as the one being drawn, see if it has custom combine
if (existing_tile.type === tile.type && // behavior (only the case if it came from the palette)
if (existing_tile && existing_tile.type === tile.type &&
// FIXME this is hacky garbage // FIXME this is hacky garbage
tile === this.palette_selection && this.palette_selection_from_palette && tile === this.palette_selection && this.palette_selection_from_palette &&
SPECIAL_PALETTE_BEHAVIOR[tile.type.name] && SPECIAL_PALETTE_BEHAVIOR[tile.type.name] &&
SPECIAL_PALETTE_BEHAVIOR[tile.type.name].combine_draw) SPECIAL_PALETTE_BEHAVIOR[tile.type.name].combine_draw)
{ {
SPECIAL_PALETTE_BEHAVIOR[tile.type.name].combine_draw(tile, existing_tile); let old_tile = {...existing_tile};
let new_tile = existing_tile;
SPECIAL_PALETTE_BEHAVIOR[tile.type.name].combine_draw(tile, new_tile);
this._assign_tile(cell, layer, new_tile, old_tile);
return; return;
} }
// Otherwise erase it this._assign_tile(cell, layer, {...tile}, existing_tile);
cell[tile.type.layer] = null;
}
cell[tile.type.layer] = {...tile};
} }
erase_tile(cell, tile = null) { erase_tile(cell, tile = null) {
@ -3589,10 +3697,10 @@ export class Editor extends PrimaryView {
this.mark_cell_dirty(cell); this.mark_cell_dirty(cell);
let existing_tile = cell[tile.type.layer]; let existing_tile = cell[tile.type.layer];
if (existing_tile) {
// If we find a tile of the same type as the one being drawn, see if it has custom // If we find a tile of the same type as the one being drawn, see if it has custom combine
// combine behavior (only the case if it came from the palette) // behavior (only the case if it came from the palette)
if (existing_tile.type === tile.type && if (existing_tile && existing_tile.type === tile.type &&
// FIXME this is hacky garbage // FIXME this is hacky garbage
tile === this.palette_selection && this.palette_selection_from_palette && tile === this.palette_selection && this.palette_selection_from_palette &&
SPECIAL_PALETTE_BEHAVIOR[tile.type.name] && SPECIAL_PALETTE_BEHAVIOR[tile.type.name] &&
@ -3603,17 +3711,154 @@ export class Editor extends PrimaryView {
return; return;
} }
// Otherwise erase it let new_tile = null;
cell[tile.type.layer] = null;
}
// Don't allow erasing the floor entirely
if (tile.type.layer === LAYERS.terrain) { if (tile.type.layer === LAYERS.terrain) {
cell[LAYERS.terrain] = {type: TILE_TYPES.floor}; new_tile = {type: TILE_TYPES.floor};
}
this._assign_tile(cell, tile.type.layer, new_tile, existing_tile);
}
replace_cell(cell, new_cell) {
// Save the coordinates so it doesn't matter what they are when undoing
let x = cell.x, y = cell.y;
let n = this.stored_level.coords_to_scalar(x, y);
this._do(
() => {
this.stored_level.linear_cells[n] = new_cell;
new_cell.x = x;
new_cell.y = y;
this.mark_cell_dirty(new_cell);
},
() => {
this.stored_level.linear_cells[n] = cell;
cell.x = x;
cell.y = y;
this.mark_cell_dirty(cell);
},
);
}
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_blank_cell(x, y));
} }
} }
// -- Misc?? -- let original_cells = this.stored_level.linear_cells;
let original_size_x = this.stored_level.size_x;
let original_size_y = this.stored_level.size_y;
this._do(() => {
this.stored_level.linear_cells = new_cells;
this.stored_level.size_x = size_x;
this.stored_level.size_y = size_y;
this.update_viewport_size();
this.update_cell_coordinates();
this.redraw_entire_level();
}, () => {
this.stored_level.linear_cells = original_cells;
this.stored_level.size_x = original_size_x;
this.stored_level.size_y = original_size_y;
this.update_viewport_size();
this.update_cell_coordinates();
this.redraw_entire_level();
});
this.commit_undo();
}
// ------------------------------------------------------------------------------------------------
// Undo/redo
_do(redo, undo, modifies = true) {
redo();
this._done(redo, undo, modifies);
}
_done(redo, undo, modifies = true) {
// TODO parallel arrays would be smaller
this.undo_entry.push([undo, redo]);
if (this.redo_stack.length > 0) {
this.redo_stack.length = 0;
}
}
_assign_tile(cell, layer, new_tile, old_tile) {
this._do(
() => {
cell[layer] = new_tile;
this.mark_cell_dirty(cell);
},
() => {
cell[layer] = old_tile;
this.mark_cell_dirty(cell);
},
);
}
reset_undo() {
this.undo_entry = [];
this.undo_stack = [];
this.redo_stack = [];
this._update_undo_redo_enabled();
}
undo() {
// We shouldn't really have an uncommitted entry lying around at a time when the user can
// click the undo button, but just in case, prefer that to the undo stack
let entry;
if (this.undo_entry.length > 0) {
entry = this.undo_entry;
this.undo_entry = [];
}
else if (this.undo_stack.length > 0) {
entry = this.undo_stack.pop();
this.redo_stack.push(entry);
}
else {
return;
}
for (let i = entry.length - 1; i >= 0; i--) {
entry[i][0]();
}
this._update_undo_redo_enabled();
}
redo() {
if (this.redo_stack.length === 0)
return;
this.commit_undo();
let entry = this.redo_stack.pop();
this.undo_stack.push(entry);
for (let [undo, redo] of entry) {
redo();
}
this._update_undo_redo_enabled();
}
commit_undo() {
if (this.undo_entry.length > 0) {
this.undo_stack.push(this.undo_entry);
this.undo_entry = [];
}
this._update_undo_redo_enabled();
}
_update_undo_redo_enabled() {
this.undo_button.disabled = this.undo_stack.length === 0;
this.redo_button.disabled = this.redo_stack.length === 0;
}
// ------------------------------------------------------------------------------------------------
// Misc UI stuff
open_tile_prop_overlay(tile, cell, rect) { open_tile_prop_overlay(tile, cell, rect) {
this.cancel_mouse_operation(); this.cancel_mouse_operation();
@ -3658,20 +3903,4 @@ export class Editor extends PrimaryView {
this.mouse_op = null; 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(x, y));
}
}
this.stored_level.linear_cells = new_cells;
this.stored_level.size_x = size_x;
this.stored_level.size_y = size_y;
this.update_viewport_size();
this.update_cell_coordinates();
this.redraw_entire_level();
}
} }