import * as fflate from '../vendor/fflate.js'; import { DIRECTIONS, LAYERS } from '../defs.js'; import * as format_base from '../format-base.js'; import * as c2g from '../format-c2g.js'; import * as dat from '../format-dat.js'; import { PrimaryView, MenuOverlay, 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 { mk, mk_svg, string_from_buffer_ascii, bytestring_to_buffer } from '../util.js'; import * as util from '../util.js'; import * as dialogs from './dialogs.js'; import { TOOLS, TOOL_ORDER, TOOL_SHORTCUTS, PALETTE, SPECIAL_PALETTE_ENTRIES, SPECIAL_PALETTE_BEHAVIOR, TILE_DESCRIPTIONS } from './editordefs.js'; import { SVGConnection, Selection } from './helpers.js'; import * as mouseops from './mouseops.js'; import { TILES_WITH_PROPS } from './tile-overlays.js'; // Edited levels are stored as follows. // StoredPack and StoredLevel both have an editor_metadata containing: // key // StoredPack's level_metadata contains: // stored_level (optional) // title // key // number // index // The editor's own storage contains: // packs: // key: // title // level_count // last_modified // current_level // And a pack's storage contains: // levels: // - key // title // last_modified const ZOOM_LEVELS = [0.0625, 0.125, 0.25, 0.5, 1, 2, 3, 4, 6, 8, 10, 12, 16]; export class Editor extends PrimaryView { constructor(conductor) { super(conductor, document.body.querySelector('main#editor')); // FIXME possibly rename these lol, adding that scroll container made "viewport" a bit // inappropriate this.actual_viewport_el = this.root.querySelector('.editor-canvas'); 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, current_level } // 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.tilesets['ll'], 32); this.renderer.perception = 'editor'; this.renderer.show_facing = true; // 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'}, mk_svg('defs', mk_svg('marker', {id: 'overlay-arrowhead', markerWidth: 4, markerHeight: 4, refX: 3, refY: 2, orient: 'auto'}, mk_svg('polygon', {points: '0 0, 4 2, 0 4'}), ), ), mk_svg('filter', {id: 'overlay-filter-outline'}, mk_svg('feMorphology', {'in': 'SourceAlpha', result: 'dilated', operator: 'dilate', radius: 0.03125}), mk_svg('feFlood', {'flood-color': '#0009', result: 'fill'}), mk_svg('feComposite', {'in': 'fill', in2: 'dilated', operator: 'in'}), mk_svg('feComposite', {'in': 'SourceGraphic'}), ), this.connections_g, ); this.viewport_el.append(this.renderer.canvas, this.svg_overlay); // This is done more correctly in setup(), but we need a sensible default so levels can be // created before switching to the editor this.bg_tile = {type: TILE_TYPES.floor}; this.level_changed_while_inactive = false; } setup() { // Add more bits to SVG overlay this.preview_g = mk_svg('g', {opacity: 0.5}); this.svg_cursor = mk_svg('rect.overlay-transient.overlay-cursor', {x: 0, y: 0, width: 1, height: 1}); this.svg_overlay.append(this.preview_g, this.svg_cursor); // Populate status bar (needs doing before the mouse stuff, which tries to update it) let statusbar = this.root.querySelector('#editor-statusbar'); this.statusbar_zoom = mk('output'); this.statusbar_zoom_input = mk('input', {type: 'range', min: 0, max: ZOOM_LEVELS.length - 1}); this.statusbar_zoom_input.addEventListener('input', ev => { let index = parseInt(ev.target.value, 10); if (index < 0) { index = 0; } else if (index >= ZOOM_LEVELS.length) { index = ZOOM_LEVELS.length - 1; } // Center the zoom on the center of the viewport let rect = this.actual_viewport_el.getBoundingClientRect(); this.set_canvas_zoom( ZOOM_LEVELS[index], (rect.left + rect.right) / 2, (rect.top + rect.bottom) / 2); }); this.statusbar_cursor = mk('div.-mouse', "—"); statusbar.append( mk('div.-zoom', mk_svg('svg.svg-icon', {viewBox: '0 0 16 16'}, mk_svg('use', {href: `#svg-icon-zoom`})), this.statusbar_zoom_input, this.statusbar_zoom, ), this.statusbar_cursor, ); // Keyboard shortcuts window.addEventListener('keydown', ev => { if (! this.active) return; if (ev.ctrlKey) { if (ev.key === 'a') { // Select all if (TOOLS[this.current_tool].affects_selection) { // If we're in the middle of using a selection tool, cancel it this.cancel_mouse_drag(); } let new_rect = new DOMRect(0, 0, this.stored_level.size_x, this.stored_level.size_y); let old_rect = this.selection.rect; if (! (old_rect && old_rect.x === new_rect.x && old_rect.y === new_rect.y && old_rect.width === new_rect.width && old_rect.height === new_rect.height)) { this.selection.set_from_rect(new_rect); this.commit_undo(); } } else if (ev.key === 'A') { // Deselect if (TOOLS[this.current_tool].affects_selection) { // If we're in the middle of using a selection tool, cancel it this.cancel_mouse_drag(); } if (! this.selection.is_empty) { this.selection.defloat(); this.selection.clear(); this.commit_undo(); } } else if (ev.key === 'z') { this.undo(); } else if (ev.key === 'Z' || ev.key === 'y') { this.redo(); } else { return; } } else { if (ev.key === 'Escape') { if (this.mouse_op && this.mouse_op.is_held) { this.mouse_op.do_abort(); } } else if (ev.key === ',') { if (ev.shiftKey) { this.rotate_palette_left(); } else if (this.fg_tile) { this.rotate_tile_left(this.fg_tile); this.redraw_foreground_tile(); } } else if (ev.key === '.') { if (ev.shiftKey) { this.rotate_palette_right(); } else if (this.fg_tile) { this.rotate_tile_right(this.fg_tile); this.redraw_foreground_tile(); } } else if (TOOL_SHORTCUTS[ev.key]) { this.select_tool(TOOL_SHORTCUTS[ev.key]); } else { return; } } // If we got here, we did something with the key ev.stopPropagation(); ev.preventDefault(); }); // Level canvas and mouse handling this.mouse_coords = null; this.mouse_op = null; this.viewport_el.addEventListener('mousedown', ev => { this.mouse_coords = [ev.clientX, ev.clientY]; this.cancel_mouse_drag(); let button = ev.button; // Macs also use ctrl-left-click to emulate right-click, even though everyone has a // two-button mouse, and that messes with us since we want to use ctrl as a modifier. // Defeat it by manually checking for the right button bit in ev.buttons if (button === 2 && (ev.buttons & 2) === 0) { button = 0; } let op_type = null; if (button === 0) { // Left button: activate tool op_type = TOOLS[this.current_tool].op1; } else if (button === 1) { // Middle button: always pan op_type = mouseops.PanOperation; // TODO how should this impact the hover? } else if (button === 2) { // Right button: activate tool's alt mode op_type = TOOLS[this.current_tool].op2; } if (! op_type) return; if (this.mouse_op && this.mouse_op instanceof op_type && this.mouse_op.physical_button === button) { // Don't replace with the same operation! } else { if (this.mouse_op) { this.mouse_op.do_destroy(); } this.mouse_op = new op_type(this, ev.clientX, ev.clientY, ev.button, button); } this.mouse_op.do_press(ev); ev.preventDefault(); ev.stopPropagation(); }); window.addEventListener('mousemove', ev => { if (! this.active) return; this.mouse_coords = [ev.clientX, ev.clientY]; // TODO move this into MouseOperation let [x, y] = this.renderer.cell_coords_from_event(ev); // TODO only do this stuff if the cell coords changed let cell = this.cell(x, y); if (cell) { this.svg_cursor.classList.add('--visible'); this.svg_cursor.setAttribute('x', x); this.svg_cursor.setAttribute('y', y); this.statusbar_cursor.textContent = `(${x}, ${y})`; // TODO don't /always/ do this. maybe make it optionally always visible, and have // an inspection tool that does it on point let terrain = cell[LAYERS.terrain]; if (terrain.type.name === 'button_gray') { } } else { this.svg_cursor.classList.remove('--visible'); this.statusbar_cursor.textContent = `—`; } if (! this.mouse_op) return; this.mouse_op.do_move(ev); }); this.actual_viewport_el.addEventListener('mouseleave', () => { if (this.mouse_op) { this.mouse_op.do_leave(); } }) // TODO should this happen for a mouseup anywhere? this.viewport_el.addEventListener('mouseup', ev => { if (! this.mouse_op) return; ev.stopPropagation(); ev.preventDefault(); this.mouse_op.do_commit(); // If this was an op for a different button, switch back to the primary if (this.mouse_op.physical_button !== 0) { this.init_default_mouse_op(); } }); // Disable context menu, which interferes with right-click tools this.viewport_el.addEventListener('contextmenu', ev => { ev.preventDefault(); }); window.addEventListener('blur', () => { this.cancel_mouse_drag(); }); window.addEventListener('mouseleave', () => { this.mouse_coords = null; }); // Mouse wheel to zoom this.set_canvas_zoom(1); this.viewport_el.addEventListener('wheel', ev => { // The delta is platform and hardware dependent and ultimately kind of useless, so just // treat each event as a click and hope for the best if (ev.deltaY === 0) return; ev.stopPropagation(); ev.preventDefault(); let index = ZOOM_LEVELS.findIndex(el => el >= this.zoom); if (index < 0) { index = ZOOM_LEVELS.length - 1; } let new_zoom; if (ev.deltaY > 0) { // Zoom out if (ZOOM_LEVELS[index] !== this.zoom) { // If we're between levels, pretend we're one level in index += 1; } if (index <= 0) return; new_zoom = ZOOM_LEVELS[index - 1]; } else { // Zoom in if (ZOOM_LEVELS[index] !== this.zoom) { index -= 1; } if (index >= ZOOM_LEVELS.length - 1) return; new_zoom = ZOOM_LEVELS[index + 1]; } // FIXME preserve the panning such that the point under the cursor doesn't move // (possibly difficult given that i can't pan at all if there are no scrollbars??) // FIXME add a widget to status bar this.set_canvas_zoom(new_zoom, ev.clientX, ev.clientY); }); // Toolbox // Selected tile and rotation buttons this.fg_tile_el = this.renderer.draw_single_tile_type('wall'); this.fg_tile_el.id = 'editor-tile'; this.fg_tile_el.addEventListener('click', () => { if (this.fg_tile && TILES_WITH_PROPS[this.fg_tile.type.name]) { this.open_tile_prop_overlay( this.fg_tile, null, this.fg_tile_el.getBoundingClientRect()); } }); this.bg_tile_el = this.renderer.draw_single_tile_type('floor'); this.bg_tile_el.addEventListener('click', () => { if (this.bg_tile && TILES_WITH_PROPS[this.bg_tile.type.name]) { this.open_tile_prop_overlay( this.bg_tile, null, this.bg_tile_el.getBoundingClientRect()); } }); // TODO ones for the palette too?? this.palette_rotation_index = 0; this.palette_actor_direction = 'south'; let rotate_right_button = mk('button.--image', {type: 'button'}, mk('img', {src: 'icons/rotate-right.png'})); rotate_right_button.addEventListener('click', () => { this.rotate_tile_right(this.fg_tile); this.redraw_foreground_tile(); }); let rotate_left_button = mk('button.--image', {type: 'button'}, mk('img', {src: 'icons/rotate-left.png'})); rotate_left_button.addEventListener('click', () => { this.rotate_tile_left(this.fg_tile); this.redraw_foreground_tile(); }); this.root.querySelector('.controls').append( mk('div.editor-tile-controls', rotate_right_button, this.fg_tile_el, rotate_left_button, this.bg_tile_el)); // 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 TOOL_ORDER) { let tooldef = TOOLS[toolname]; let header_text = tooldef.name; if (tooldef.shortcut) { let shortcut; if (tooldef.shortcut === tooldef.shortcut.toUpperCase()) { shortcut = `Shift-${tooldef.shortcut}`; } else { shortcut = tooldef.shortcut.toUpperCase(); } header_text += ` (${shortcut})`; } let button = mk( 'button', { type: 'button', 'data-tool': toolname, }, mk('img', { src: tooldef.icon, alt: tooldef.name, }), mk('div.-help.editor-big-tooltip', mk('h3', header_text), tooldef.desc), ); this.tool_button_els[toolname] = button; toolbox.append(button); } this.current_tool = null; this.select_tool('pencil'); toolbox.addEventListener('click', ev => { let button = ev.target.closest('.icon-button-set button'); if (! button) return; this.select_tool(button.getAttribute('data-tool')); }); // 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; }; this.undo_button = _make_button("Undo", () => { this.undo(); }); this.redo_button = _make_button("Redo", () => { this.redo(); }); _make_button("Pack properties...", () => { new dialogs.EditorPackMetaOverlay(this.conductor, this.conductor.stored_game).open(); }); _make_button("Level properties...", () => { new dialogs.EditorLevelMetaOverlay(this.conductor, this.stored_level).open(); }); this.save_button = _make_button("Save", () => { this.save_level(); }); let export_items = [ ["Share this level with a link", () => { // FIXME enable this once gliderbot can understand it //let data = util.b64encode(fflate.zlibSync(new Uint8Array(c2g.synthesize_level(this.stored_level)))); let data = util.b64encode(c2g.synthesize_level(this.stored_level)); let url = new URL(location); url.searchParams.delete('level'); url.searchParams.delete('setpath'); url.searchParams.append('level', data); new dialogs.EditorShareOverlay(this.conductor, url.toString()).open(); }], ["Download level as C2M (new CC2 format)", () => { // TODO support getting warnings + errors out of synthesis let buf = c2g.synthesize_level(this.stored_level); util.trigger_local_download((this.stored_level.title || 'untitled') + '.c2m', new Blob([buf])); }], ["Download pack as C2G (new CC2 format)", () => { let stored_pack = this.conductor.stored_game; // This is pretty heckin' best-effort for now; TODO move into format-c2g? let lines = []; let safe_title = (stored_pack.title || "untitled").replace(/[""]/g, "'").replace(/[\x00-\x1f]+/g, "_"); lines.push(`game "${safe_title}"`); let files = {}; let count = stored_pack.level_metadata.length; let levelnumlen = String(count).length; for (let [i, meta] of stored_pack.level_metadata.entries()) { let c2m; if (i === this.conductor.level_index) { // Use the current state of the current level even if it's not been saved c2m = new Uint8Array(c2g.synthesize_level(this.stored_level)); } else if (meta.key) { // This is already in localStorage as a c2m c2m = fflate.strToU8(localStorage.getItem(meta.key), true); } else { let stored_level = stored_pack.load_level(i); c2m = new Uint8Array(c2g.synthesize_level(stored_level)); } let safe_title = meta.title.replace(/[\x00-\x1f<>:""\/\\|?*]+/g, '_'); let dirchunk = i - i % 50; let dirname = ( String(dirchunk + 1).padStart(levelnumlen, '0') + '-' + String(Math.min(count, dirchunk + 50)).padStart(levelnumlen, '0')); let filename = `${dirname}/${i + 1} - ${safe_title}.c2m`; files[filename] = c2m; lines.push(`map "${filename}"`); } // TODO utf8 encode this safe_title = safe_title.replace(/[\x00-\x1f<>:""\/\\|?*]+/g, '_'); lines.push(""); files[safe_title + '.c2g'] = fflate.strToU8(lines.join("\n")); let u8array = fflate.zipSync(files); // TODO support getting warnings + errors out of synthesis util.trigger_local_download((stored_pack.title || 'untitled') + '.zip', new Blob([u8array])); }], ["Download level as CCL (old CC1 format)", () => { // TODO support getting warnings out of synthesis? let buf; try { buf = dat.synthesize_level(this.stored_level); } catch (errs) { if (errs instanceof dat.CCLEncodingErrors) { new dialogs.EditorExportFailedOverlay(this.conductor, errs.errors).open(); return; } throw errs; } util.trigger_local_download((this.stored_level.title || 'untitled') + '.ccl', new Blob([buf])); }], ]; this.export_menu = new MenuOverlay( this.conductor, export_items, item => item[0], item => item[1](), ); let export_menu_button = _make_button("Export ", ev => { this.export_menu.open(ev.currentTarget); }); export_menu_button.append( mk_svg('svg.svg-icon', {viewBox: '0 0 16 16'}, mk_svg('use', {href: `#svg-icon-menu-chevron`})), ); //_make_button("Toggle green objects"); // Tile palette let palette_el = this.root.querySelector('.palette'); this.palette = {}; // name => element for (let sectiondef of 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.draw_single_tile_type(tile.name, tile); } else { entry = this.renderer.draw_single_tile_type(key); } entry.setAttribute('data-palette-key', key); entry.classList = 'palette-entry'; this.palette[key] = entry; section_el.append(entry); } } palette_el.addEventListener('mousedown', ev => { let entry = ev.target.closest('canvas.palette-entry'); if (! entry) return; let fg; if (ev.button === 0) { fg = true; } else if (ev.button === 2) { fg = false; } else { return; } ev.preventDefault(); ev.stopPropagation(); 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; if (fg) { this.select_foreground_tile(tile, true); } else { if (tile.type.layer !== LAYERS.terrain) return; this.select_background_tile(tile, true); } } else { // Regular tile name if (fg) { this.select_foreground_tile(key, true); } else { if (TILE_TYPES[key].layer !== LAYERS.terrain) return; this.select_background_tile(key, true); } } }); // Disable context menu so right-click works palette_el.addEventListener('contextmenu', ev => { ev.preventDefault(); }); // Hover help palette_el.addEventListener('mouseover', ev => { let entry = ev.target.closest('canvas.palette-entry'); if (! entry) return; this.show_palette_tooltip(entry.getAttribute('data-palette-key')); }); palette_el.addEventListener('mouseout', ev => { let entry = ev.target.closest('canvas.palette-entry'); if (! entry) return; this.hide_palette_tooltip(); }); this.palette_tooltip = mk('div.editor-palette-tooltip.editor-big-tooltip', mk('h3'), mk('p')); this.root.append(this.palette_tooltip); this.fg_tile = null; // used for most drawing this.fg_tile_from_palette = false; this.palette_fg_selected_el = null; this.select_foreground_tile('wall', true); this.bg_tile = null; // used to populate new/cleared cells this.select_background_tile('floor', true); this.selection = new Selection(this); this.reset_undo(); } activate() { super.activate(); this._schedule_redraw_loop(); // Do some final heavyweight or DOM-related setup if the level changed while the editor // wasn't showing if (this.level_changed_while_inactive) { this.level_changed_while_inactive = false; this.redraw_entire_level(); // Reset the scroll position; this happens when loading a level, but if a level is // loaded before we're initially visible, all the DOM sizes are zero and it breaks this.reset_viewport_scroll(); } } deactivate() { if (this._redraw_handle) { window.cancelAnimationFrame(this._redraw_handle); this._redraw_handle = null; } super.deactivate(); } // ------------------------------------------------------------------------------------------------ // Level creation, management, and saving make_blank_cell(x, y) { let cell = new format_base.StoredCell; cell.x = x; cell.y = y; cell[LAYERS.terrain] = {...this.bg_tile}; 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; stored_level.blob_behavior = 2; // extra random for (let i = 0; i < size_x * size_y; 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'}; return stored_level; } _save_pack_to_stash(stored_pack) { if (! stored_pack.editor_metadata) { console.error("Asked to save a stored pack that's not part of the editor", stored_pack); return; } // Reload the stash in case a pack was created in another tab // TODO do this with events this.stash = load_json_from_storage("Lexy's Labyrinth editor") ?? this.stash; let pack_key = stored_pack.editor_metadata.key; this.stash.packs[pack_key] = { title: stored_pack.title, level_count: stored_pack.level_metadata.length, last_modified: Date.now(), }; save_json_to_storage("Lexy's Labyrinth editor", this.stash); } _save_level_to_storage(stored_level) { if (! stored_level.editor_metadata) { console.error("Asked to save a stored level that's not part of the editor", stored_level); return; } let buf = c2g.synthesize_level(stored_level); let stringy_buf = string_from_buffer_ascii(buf); window.localStorage.setItem(stored_level.editor_metadata.key, stringy_buf); } 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(); } create_pack() { let pack_key = `LLP-${Date.now()}`; let level_key = `LLL-${Date.now()}`; let stored_pack = new format_base.StoredPack(pack_key); stored_pack.title = "Untitled pack"; stored_pack.editor_metadata = { key: pack_key, }; let stored_level = this._make_empty_level(1, 32, 32); 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._save_pack_to_stash(stored_pack); save_json_to_storage(pack_key, { levels: [{ key: level_key, title: stored_level.title, last_modified: Date.now(), }], current_level_index: 0, }); this._save_level_to_storage(stored_level); 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, null, pack_stash.current_level_index); this.conductor.switch_to_editor(); } // Move, insert, or delete a level. If dest_index is null, the level will be deleted. If // source is a number, it's an index; otherwise, it's a level, assumed to be newly-created, and // will be given a new key and saved to localStorage. (Passing null and a level will, // of course, do nothing. Passing an out of bounds source index will also do nothing.) move_level(source, dest_index) { let stored_pack = this.conductor.stored_game; if (! stored_pack.editor_metadata) { return; } // Get the level, and pull it out of the list if necessary let stored_level, level_metadata, pack_stash_entry, source_index = null; let pack_stash = load_json_from_storage(stored_pack.editor_metadata.key); if (typeof source === 'number') { if (source === dest_index) return; source_index = source; if (source_index < 0 || source_index >= stored_pack.level_metadata.length) { console.warn("Asked to move a level with an out-of-bounds source:", source_index); return; } [level_metadata] = stored_pack.level_metadata.splice(source_index, 1); [pack_stash_entry] = pack_stash.levels.splice(source_index, 1); stored_level = level_metadata.stored_level ?? null; if (stored_level === null && source_index === this.conductor.level_index) { stored_level = this.conductor.stored_level; } } else { // This is a new level if (dest_index === null) // Nothing to do return; dest_index = Math.max(0, Math.min(stored_pack.level_metadata.length, dest_index)); stored_level = source; level_metadata = { stored_level: stored_level, key: `LLL-${Date.now()}`, title: stored_level.title, index: dest_index, number: dest_index + 1, }; pack_stash_entry = { key: level_metadata.key, title: stored_level.title, last_modified: Date.now(), }; stored_level.editor_metadata = { key: level_metadata.key, }; this._save_level_to_storage(stored_level); } if (dest_index === null) { // Erase the level from localStorage window.localStorage.removeItem(level_metadata.key); } else { // Add the level to the appropriate place if (stored_level) { stored_level.index = dest_index; stored_level.number = dest_index + 1; } level_metadata.index = dest_index; level_metadata.number = dest_index + 1; stored_pack.level_metadata.splice(dest_index, 0, level_metadata); pack_stash.levels.splice(dest_index, 0, pack_stash_entry); } // Renumber levels as necessary let delta, start_index, end_index; if (source_index === null) { // A level was inserted, so increment the number of every level after it delta = +1; start_index = dest_index + 1; end_index = stored_pack.level_metadata.length - 1; } else if (dest_index === null) { // A level was deleted, so decrement the number of every level after it delta = -1; start_index = source_index; end_index = stored_pack.level_metadata.length - 1; } else { // A level was moved, so it depends whether it was moved forwards or backwards if (source_index < dest_index) { delta = -1; start_index = source_index; end_index = dest_index - 1; } else { delta = +1; start_index = dest_index + 1; end_index = source_index; } } for (let i = start_index; i <= end_index; i++) { let meta = stored_pack.level_metadata[i]; meta.index += delta; meta.number += delta; if (meta.stored_level) { meta.stored_level.index += delta; meta.stored_level.number += delta; } } // Update the conductor's index too so it doesn't get confused if (this.conductor.level_index === source_index) { // FIXME refuse to delete the current level this.conductor.level_index = dest_index; } else if ( this.conductor.level_index === dest_index || (start_index <= this.conductor.level_index && this.conductor.level_index <= end_index)) { this.conductor.level_index += delta; // 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) { this.conductor.stored_level.index += delta; this.conductor.stored_level.number += delta; } } // Update the title and headers, since the level number might have changed this.conductor.update_level_title(); this.conductor.update_nav_buttons(); // Save the pack stash and editor stash, and we should be done! pack_stash.current_level_index = this.conductor.level_index; save_json_to_storage(stored_pack.editor_metadata.key, pack_stash); this._save_pack_to_stash(stored_pack); return stored_level; } duplicate_level(index) { // The most reliable way to clone a level is to reserialize its current state // TODO with autosave this shouldn't be necessary, just copy the existing serialization let stored_level = c2g.parse_level(c2g.synthesize_level(this.conductor.stored_game.load_level(index)), index + 2); return this.move_level(stored_level, index + 1); } save_level() { // 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_pack = this.conductor.stored_game; if (! stored_pack.editor_metadata) return; this.modified = false; this.undo_modification_offset = 0; // Update the pack itself // TODO maybe should keep this around, but there's a tricky order of operations thing // with it let pack_key = stored_pack.editor_metadata.key; let pack_stash = load_json_from_storage(pack_key); pack_stash.title = stored_pack.title; pack_stash.last_modified = Date.now(); pack_stash.levels[this.conductor.level_index].title = this.stored_level.title; pack_stash.levels[this.conductor.level_index].last_modified = Date.now(); // Save everything at once, level first, to minimize chances of an error getting things // out of sync this._save_level_to_storage(this.stored_level); save_json_to_storage(pack_key, pack_stash); this._save_pack_to_stash(stored_pack); if (this._level_browser) { this._level_browser.expire(this.conductor.level_index); } this._update_ui_after_edit(); } // ------------------------------------------------------------------------------------------------ // Level loading load_game(stored_game) { this._level_browser = null; } load_level(stored_level) { // TODO support a game too i guess this.stored_level = stored_level; this.update_viewport_size(); this.update_cell_coordinates(); this.modified = false; if (! this.active) { this.level_changed_while_inactive = true; } // Remember current level for an editor level if (this.conductor.stored_game.editor_metadata) { let pack_key = this.conductor.stored_game.editor_metadata.key; let pack_stash = load_json_from_storage(pack_key); pack_stash.current_level_index = this.conductor.level_index; save_json_to_storage(pack_key, pack_stash); } // Load connections // TODO what if the source tile is not connectable? // TODO there's a has_custom_connections flag, is that important here or is it just because // i can't test an object as a bool this.connections_g.textContent = ''; this.connections_elements = {}; for (let [src, dest] of Object.entries(this.stored_level.custom_connections)) { let [sx, sy] = this.stored_level.scalar_to_coords(src); let [dx, dy] = this.stored_level.scalar_to_coords(dest); let el = new SVGConnection(sx, sy, dx, dy).element; this.connections_elements[src] = el; this.connections_g.append(el); } // 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.redraw_entire_level(); } this.reset_viewport_scroll(); 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() { // We rely on each StoredCell having .x and .y for partial redrawing for (let [i, cell] of this.stored_level.linear_cells.entries()) { [cell.x, cell.y] = this.stored_level.scalar_to_coords(i); } } 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}`); } // ------------------------------------------------------------------------------------------------ set_canvas_zoom(zoom, origin_x = null, origin_y = null) { // Adjust scrolling so that the point under the mouse cursor remains fixed let scroll_adjust_x = null; let scroll_adjust_y = null; if (origin_x !== null && this.zoom) { // FIXME possible sign of a bad api let [frac_cell_x, frac_cell_y] = this.renderer.real_cell_coords_from_event({clientX: origin_x, clientY: origin_y}); // Zooming is really just resizing a DOM element, which doesn't affect either the // transparent border or the scroll position, so zooming from Z1 to Z2 will move a point // from X * Z1 to X * Z2. To keep it at the same client point, the scroll position // thus needs to change by X * (Z2 - Z1). scroll_adjust_x = frac_cell_x * (zoom - this.zoom) * this.renderer.tileset.size_x; scroll_adjust_y = frac_cell_y * (zoom - this.zoom) * this.renderer.tileset.size_y; } this.zoom = zoom; this.renderer.canvas.style.setProperty('--scale', this.zoom); this.actual_viewport_el.classList.toggle('--crispy', this.zoom >= 1); this.statusbar_zoom.textContent = `${this.zoom * 100}%`; let index = ZOOM_LEVELS.findIndex(el => el >= this.zoom); if (index < 0) { index = ZOOM_LEVELS.length - 1; } this.statusbar_zoom_input.value = index; // Only actually adjust the scroll position after changing the zoom, or it might not be // possible to scroll that far yet if (scroll_adjust_x !== null) { this.actual_viewport_el.scrollLeft += scroll_adjust_x; this.actual_viewport_el.scrollTop += scroll_adjust_y; } } reset_viewport_scroll() { // Position the level within the viewport; the default is no scroll, which will mostly show // empty space. Try to put a 1-cell margin around it; if it fits, center it; if not, put // the top-left corner at the top-left of the viewport. let canvas_width = this.renderer.canvas.offsetWidth; let canvas_height = this.renderer.canvas.offsetHeight; let padded_canvas_width = canvas_width * (1 + 2 / this.stored_level.size_x); let padded_canvas_height = canvas_height * (1 + 2 / this.stored_level.size_y); let area_width = this.viewport_el.offsetWidth; let area_height = this.viewport_el.offsetHeight; let viewport_width = this.actual_viewport_el.offsetWidth; let viewport_height = this.actual_viewport_el.offsetHeight; this.actual_viewport_el.scrollLeft = (area_width - Math.max(viewport_width, padded_canvas_width)) / 2; this.actual_viewport_el.scrollTop = (area_height - Math.max(viewport_height, padded_canvas_height)) / 2; } open_level_browser() { if (! this._level_browser) { this._level_browser = new dialogs.EditorLevelBrowserOverlay(this.conductor); } this._level_browser.open(); } select_tool(tool) { if (tool === this.current_tool) return; if (! this.tool_button_els[tool]) return; if (this.current_tool) { this.tool_button_els[this.current_tool].classList.remove('-selected'); } this.current_tool = tool; this.tool_button_els[this.current_tool].classList.add('-selected'); this.init_default_mouse_op(); } init_default_mouse_op() { if (this.mouse_op) { this.mouse_op.do_destroy(); this.mouse_op = null; } if (this.current_tool && TOOLS[this.current_tool].op1) { this.mouse_op = new TOOLS[this.current_tool].op1(this, ...(this.mouse_coords ?? [0, 0]), 0); // FIXME? } } show_palette_tooltip(key) { let desc = TILE_DESCRIPTIONS[key]; if (! desc && SPECIAL_PALETTE_ENTRIES[key]) { let name = SPECIAL_PALETTE_ENTRIES[key].name; desc = TILE_DESCRIPTIONS[name]; } if (! desc) { this.palette_tooltip.classList.remove('--visible'); return; } this.palette_tooltip.classList.add('--visible'); this.palette_tooltip.querySelector('h3').textContent = desc.name; this.palette_tooltip.querySelector('p').textContent = desc.desc; // Place it out of the way of the palette (so, overlaying the level) but roughly vertically // aligned let palette_rect = this.root.querySelector('.palette').getBoundingClientRect(); let entry_rect = this.palette[key].getBoundingClientRect(); let tip_height = this.palette_tooltip.offsetHeight; this.palette_tooltip.style.left = `${palette_rect.right}px`; this.palette_tooltip.style.top = `${Math.min(entry_rect.top, palette_rect.bottom - tip_height)}px`; } hide_palette_tooltip() { this.palette_tooltip.classList.remove('--visible'); } _name_or_tile_to_name_and_tile(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 = {...name_or_tile}; name = tile.type.name; } return [name, tile]; } select_foreground_tile(name_or_tile, from_palette = false) { let [name, tile] = this._name_or_tile_to_name_and_tile(name_or_tile); // Deselect any previous selection if (this.palette_fg_selected_el) { this.palette_fg_selected_el.classList.remove('--selected'); } // Store the tile this.fg_tile = tile; this.fg_tile_from_palette = from_palette; // 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_fg_selected_el = this.palette[key] ?? null; if (this.palette_fg_selected_el) { this.palette_fg_selected_el.classList.add('--selected'); } this.redraw_foreground_tile(); // Some tools obviously don't work with a palette selection, in which case changing tiles // should default you back to the pencil if (! TOOLS[this.current_tool].uses_palette) { this.select_tool('pencil'); } } select_background_tile(name_or_tile) { let [_name, tile] = this._name_or_tile_to_name_and_tile(name_or_tile); this.bg_tile = tile; this.redraw_background_tile(); } 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; } 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; } 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; } // ------------------------------------------------------------------------------------------------ // Drawing redraw_foreground_tile() { let ctx = this.fg_tile_el.getContext('2d'); ctx.clearRect(0, 0, this.fg_tile_el.width, this.fg_tile_el.height); this.renderer.draw_single_tile_type( this.fg_tile.type.name, this.fg_tile, this.fg_tile_el); if (this.mouse_op) { this.mouse_op.handle_tile_updated(); } } redraw_background_tile() { let ctx = this.bg_tile_el.getContext('2d'); ctx.clearRect(0, 0, this.bg_tile_el.width, this.bg_tile_el.height); this.renderer.draw_single_tile_type( this.bg_tile.type.name, this.bg_tile, this.bg_tile_el); if (this.mouse_op) { this.mouse_op.handle_tile_updated(true); } } mark_cell_dirty(cell) { this.mark_point_dirty(cell.x, cell.y); } mark_point_dirty(x, y) { if (! this._dirty_rect) { this._dirty_rect = new DOMRect(x, y, 1, 1); } else { let rect = this._dirty_rect; if (x < rect.left) { rect.width = rect.right - x; rect.x = x; } else if (x >= rect.right) { rect.width = x - rect.left + 1; } if (y < rect.top) { rect.height = rect.bottom - y; rect.y = y; } else if (y >= rect.bottom) { rect.height = y - rect.top + 1; } } } redraw_entire_level() { this.renderer.draw_static_region(0, 0, this.stored_level.size_x, this.stored_level.size_y); } _schedule_redraw_loop() { this._redraw_handle = window.requestAnimationFrame(this._redraw_dirty.bind(this)); this._dirty_rect = null; } // Automatically redraw only what's changed _redraw_dirty() { // TODO draw sparkle background under the starting player if (this._dirty_rect) { this.renderer.draw_static_region( this._dirty_rect.left, this._dirty_rect.top, this._dirty_rect.right, this._dirty_rect.bottom); } this._schedule_redraw_loop(); } // ------------------------------------------------------------------------------------------------ // Utility/inspection is_in_bounds(x, y) { return this.stored_level.is_point_within_bounds(x, y); } cell(x, y) { return this.stored_level.cell(x, y); } // ------------------------------------------------------------------------------------------------ // Mutation // DOES NOT commit the undo entry! place_in_cell(cell, tile) { // TODO weird api? if (! tile) return; if (! this.selection.contains(cell.x, cell.y)) return; // Replace whatever's on the same layer let layer = tile.type.layer; 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 (existing_tile && existing_tile.type === tile.type && // FIXME this is hacky garbage tile === this.fg_tile && this.fg_tile_from_palette && SPECIAL_PALETTE_BEHAVIOR[tile.type.name] && SPECIAL_PALETTE_BEHAVIOR[tile.type.name].combine_draw) { 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; } let new_tile = {...tile}; // Special case: preserve wires when replacing one wired tile with another if (new_tile.type.contains_wire && // FIXME this is hacky garbage tile === this.fg_tile && this.fg_tile_from_palette) { if (existing_tile.type.contains_wire) { new_tile.wire_directions = existing_tile.wire_directions; } else if (existing_tile.type.name === 'logic_gate') { // Extract the wires from logic gates new_tile.wire_directions = 0; for (let dir of existing_tile.type.get_wires(existing_tile)) { if (dir) { new_tile.wire_directions |= DIRECTIONS[dir].bit; } } } } this._assign_tile(cell, layer, new_tile, existing_tile); } erase_tile(cell, tile = null) { // TODO respect selection if (tile === null) { tile = this.fg_tile; } let existing_tile = cell[tile.type.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 (existing_tile && existing_tile.type === tile.type && // FIXME this is hacky garbage tile === this.fg_tile && this.fg_tile_from_palette && SPECIAL_PALETTE_BEHAVIOR[tile.type.name] && SPECIAL_PALETTE_BEHAVIOR[tile.type.name].combine_erase) { let old_tile = {...existing_tile}; let new_tile = existing_tile; let remove = SPECIAL_PALETTE_BEHAVIOR[tile.type.name].combine_erase(tile, new_tile); if (! remove) { this._assign_tile(cell, tile.type.layer, new_tile, old_tile); return; } } let new_tile = null; if (tile.type.layer === LAYERS.terrain) { new_tile = {...this.bg_tile}; } 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)); } } 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(); } // Create a connection between two cells and update the UI accordingly. If dest is null or // undefined, delete any existing connection instead. set_custom_connection(src, dest) { let prev = this.stored_level.custom_connections[src]; this._do( () => this._set_custom_connection(src, dest), () => this._set_custom_connection(src, prev), ); } _set_custom_connection(src, dest) { if (this.connections_elements[src]) { this.connections_elements[src].remove(); } if ((dest ?? null) === null) { delete this.stored_level.custom_connections[src]; delete this.connections_elements[src]; } else { this.stored_level.custom_connections[src] = dest; let el = new SVGConnection( ...this.stored_level.scalar_to_coords(src), ...this.stored_level.scalar_to_coords(dest), ).element; this.connections_elements[src] = el; this.connections_g.append(el); } } // ------------------------------------------------------------------------------------------------ // 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 (modifies) { this.undo_entry.modifies = true; } } _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 = []; // Number of steps we'd need to take (negative if redo, positive if undo) to reach a // pristine saved state. May also be null, meaning the pristine state is in a redo branch // that has been overwritten and is thus unreachable. this.undo_modification_offset = 0; this._update_ui_after_edit(); } 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 = []; console.warn("lingering 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._track_undo_offset(-1, entry.modifies); this._update_ui_after_edit(); } 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._track_undo_offset(+1, entry.modifies); this._update_ui_after_edit(); } _track_undo_offset(delta, modifies) { if (this.undo_modification_offset === null) { return; } else if (this.undo_modification_offset === 0) { if (modifies) { this.modified = true; this.undo_modification_offset += delta; } } else { this.undo_modification_offset += delta; if (this.undo_modification_offset === 0) { this.modified = false; } } } // TODO give these names/labels? commit_undo() { if (this.undo_entry.length === 0) return; if (this.undo_entry.modifies) { this.modified = true; } this.undo_stack.push(this.undo_entry); this.undo_entry = []; // Doing an action always erases the redo stack if (this.redo_stack.length > 0) { this.redo_stack.length = 0; if (this.undo_modification_offset < 0) { // Pristine state was in the future, so it's now unreachable this.undo_modification_offset = null; } } this._update_ui_after_edit(); } _update_ui_after_edit() { this.undo_button.disabled = this.undo_stack.length === 0; this.redo_button.disabled = this.redo_stack.length === 0; this.save_button.disabled = ! ( this.stored_level && this.modified && this.conductor.stored_game.editor_metadata); } // ------------------------------------------------------------------------------------------------ // Misc UI stuff open_tile_prop_overlay(tile, cell, rect) { this.cancel_mouse_drag(); // 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, cell); overlay.open(); // Fixed-size balloon positioning // FIXME move this into TransientOverlay or some other base class let root = overlay.root; let spacing = 2; // Vertical position: either above or below, preferring the side that has more space if (rect.top - 0 > document.body.clientHeight - rect.bottom) { // Above root.classList.add('--above'); root.style.top = `${rect.top - root.offsetHeight - spacing}px`; } else { // Below root.classList.remove('--above'); root.style.top = `${rect.bottom + spacing}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, (rect.left + rect.right - root.offsetWidth) / 2)); } root.style.left = `${left}px`; root.style.setProperty('--chevron-offset', `${(rect.left + rect.right) / 2 - left}px`); } cancel_mouse_drag() { if (this.mouse_op && this.mouse_op.is_held) { this.mouse_op.do_abort(); } } }