// Types that handle mouse activity for a given tool, whether the mouse button is current held or // not. (When the mouse button is /not/ held, then only the operation bound to the left mouse // button gets events.) import { DIRECTIONS, LAYERS } from '../defs.js'; import TILE_TYPES from '../tiletypes.js'; import { mk, mk_svg, walk_grid } from '../util.js'; import { SVGConnection } from './helpers.js'; import { TILES_WITH_PROPS } from './tile-overlays.js'; const MOUSE_BUTTON_MASKS = [1, 4, 2]; // MouseEvent.button/buttons are ordered differently export class MouseOperation { constructor(editor, client_x, client_y, physical_button) { this.editor = editor; this.is_held = false; this.physical_button = physical_button; this.alt_mode = physical_button !== 0; this.ctrl = false; this.shift = false; //this._update_modifiers(ev); // FIXME how do i get this initially?? let [frac_cell_x, frac_cell_y] = this.editor.renderer.real_cell_coords_from_event({clientX: client_x, clientY: client_y}); let cell_x = Math.floor(frac_cell_x); let cell_y = Math.floor(frac_cell_y); // Client coordinates of the previous mouse event this.prev_client_x = client_x; this.prev_client_y = client_y; // Cell coordinates this.prev_cell_x = cell_x; this.prev_cell_y = cell_y; // Fractional cell coordinates this.prev_frac_cell_x = frac_cell_x; this.prev_frac_cell_y = frac_cell_y; // Same as above, but for the most recent click (so drag ops know where they started) this.click_client_x = null; this.click_client_y = null; this.click_cell_x = null; this.click_cell_y = null; this.click_frac_cell_x = null; this.click_frac_cell_y = null; // Start out with a hover effect // FIXME no good, subclass hasn't finished setting itself up yet //this.do_move({clientX: client_x, clientY: client_y}); } cell(x, y) { return this.editor.cell(Math.floor(x), Math.floor(y)); } do_press(ev) { this.is_held = true; this._update_modifiers(ev); this.client_x = ev.clientX; this.client_y = ev.clientY; [this.click_frac_cell_x, this.click_frac_cell_y] = this.editor.renderer.real_cell_coords_from_event(ev); this.click_cell_x = Math.floor(this.click_frac_cell_x); this.click_cell_y = Math.floor(this.click_frac_cell_y); this.prev_client_x = this.client_x; this.prev_client_y = this.client_y; this.prev_frac_cell_x = this.click_frac_cell_x; this.prev_frac_cell_y = this.click_frac_cell_y; this.prev_cell_x = this.click_cell_x; this.prev_cell_y = this.click_cell_y; this.handle_press(this.click_cell_x, this.click_cell_y, ev); } do_move(ev) { this._update_modifiers(ev); let [frac_cell_x, frac_cell_y] = this.editor.renderer.real_cell_coords_from_event(ev); let cell_x = Math.floor(frac_cell_x); let cell_y = Math.floor(frac_cell_y); if (this.is_held && (ev.buttons & MOUSE_BUTTON_MASKS[this.physical_button]) === 0) { this.do_abort(); } if (this.is_held) { // Continue a drag even if the mouse goes outside the viewport this.handle_drag(ev.clientX, ev.clientY, frac_cell_x, frac_cell_y, cell_x, cell_y); } else { // This is a hover, which has separate behavior for losing track of the mouse. Note // that we can't just check if the cell coordinates are valid; we also need to know that // the mouse is actually over the visible viewport (the canvas may have scrolled!) let in_bounds = false; if (this.editor.is_in_bounds(cell_x, cell_y)) { let rect = this.editor.actual_viewport_el.getBoundingClientRect(); let cx = ev.clientX, cy = ev.clientY; if (rect.left <= cx && cx < rect.right && rect.top <= cy && cy < rect.bottom) { in_bounds = true; } } if (in_bounds) { this.show(); this.handle_hover(ev.clientX, ev.clientY, frac_cell_x, frac_cell_y, cell_x, cell_y); } else { this.hide(); this.handle_leave(); } } this.prev_client_x = ev.clientX; this.prev_client_y = ev.clientY; this.prev_frac_cell_x = frac_cell_x; this.prev_frac_cell_y = frac_cell_y; this.prev_cell_x = cell_x; this.prev_cell_y = cell_y; } do_leave() { this.hide(); } show() { if (! this.is_hover_visible) { this.is_hover_visible = true; this.editor.preview_g.style.display = ''; } } hide() { if (this.is_hover_visible) { this.is_hover_visible = false; this.editor.preview_g.style.display = 'none'; } } _update_modifiers(ev) { this.ctrl = ev.ctrlKey; this.shift = ev.shiftKey; } do_commit() { if (! this.is_held) return; this.commit_press(); this.cleanup_press(); this.is_held = false; } do_abort() { if (! this.is_held) return; this.abort_press(); this.cleanup_press(); this.is_held = false; } do_destroy() { this.do_abort(); this.cleanup_hover(); } *iter_touched_cells(frac_cell_x, frac_cell_y) { for (let pt of walk_grid( this.prev_frac_cell_x, this.prev_frac_cell_y, frac_cell_x, frac_cell_y, // 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 -- // Called when the mouse button is first pressed handle_press(x, y, ev) {} // Called when the mouse is moved while the button is held down handle_drag(client_x, client_y, frac_cell_x, frac_cell_y, cell_x, cell_y) {} // Called when releasing the mouse button commit_press() {} // Called when aborting a held mouse, e.g. by pressing Esc or losing focus abort_press() {} // Called after either of the above cases cleanup_press() {} // Called when the mouse is moved while the button is NOT held down handle_hover(client_x, client_y, frac_cell_x, frac_cell_y, cell_x, cell_y) {} // Called when the foreground or background tile changes (after it's been redrawn) handle_tile_updated(is_bg = false) {} // Called when the mouse leaves the level or viewport while the button is NOT held down handle_leave() {} // Called when the hover ends?? cleanup_hover() {} } export class PanOperation extends MouseOperation { handle_drag(client_x, client_y) { let target = this.editor.actual_viewport_el; let dx = this.prev_client_x - client_x; let dy = this.prev_client_y - client_y; target.scrollLeft += dx; target.scrollTop += dy; } } export class EyedropOperation extends MouseOperation { constructor(...args) { super(...args); // Last coordinates we clicked on // FIXME whoops, storing this state locally doesn't work since we're destroyed between // clicks lol! clean fix is to make an op immediately and persist it even when mouse isn't // down? then the hover stuff could be rolled into the tool too? kind of a big change tho // so for now let's cheat and hack it onto the editor itself this.last_eyedropped_coords = null; this.last_layer = null; } eyedrop(x, y) { let cell = this.cell(x, y); if (! cell) { this.last_eyedropped_coords = null; return; } // If we're picking the background, we always use the terrain if (this.ctrl) { this.editor.select_background_tile(cell[LAYERS.terrain]); return; } // Pick the topmost thing, unless we're clicking on a cell repeatedly, in which case we // continue from below the last thing we picked let layer_offset = 0; if (this.last_eyedropped_coords && this.last_eyedropped_coords[0] === x && this.last_eyedropped_coords[1] === y) { layer_offset = this.last_layer; } for (let l = LAYERS.MAX - 1; l >= 0; l--) { // This scheme means we'll cycle back around after hitting the bottom let layer = (l + layer_offset) % LAYERS.MAX; let tile = cell[layer]; if (! tile) continue; this.editor.select_foreground_tile(tile); this.last_eyedropped_coords = [x, y]; this.last_layer = layer; return; } } handle_press(x, y) { this.eyedrop(x, y); } handle_drag(x, y) { // FIXME should only re-eyedrop if we enter a new cell or click again this.eyedrop(x, y); } } export class PencilOperation extends MouseOperation { constructor(...args) { super(...args); this.image = mk_svg('image', { id: 'svg-editor-preview-tile', x: 0, y: 0, width: 1, height: 1, }); this.editor.preview_g.append(this.image); this.handle_tile_updated(); } // Hover: draw the tile in the pointed-to cell handle_tile_updated(is_bg = false) { if (is_bg) return; this.image.setAttribute('href', this.editor.fg_tile_el.toDataURL()); } handle_hover(_mx, _my, _cxf, _cyf, cell_x, cell_y) { this.image.setAttribute('x', cell_x); this.image.setAttribute('y', cell_y); } cleanup_hover() { this.image.remove(); } handle_press(x, y) { this.draw_in_cell(x, y); } handle_drag(client_x, client_y, frac_cell_x, frac_cell_y, _cell_x, _cell_y) { for (let [x, y] of this.iter_touched_cells(frac_cell_x, frac_cell_y)) { this.draw_in_cell(x, y); } } draw_in_cell(x, y) { let template = this.editor.fg_tile; let cell = this.cell(x, y); if (this.ctrl) { // Erase if (this.shift) { // Wipe the whole cell let new_cell = this.editor.make_blank_cell(x, y); this.editor.replace_cell(cell, new_cell); } else if (template) { // Erase whatever's on the same layer as the fg tile this.editor.erase_tile(cell); } } else { // Draw if (! template) return; if (this.shift) { // Aggressive mode: replace whatever's already in the cell let new_cell = this.editor.make_blank_cell(x, y); new_cell[template.type.layer] = {...template}; this.editor.replace_cell(cell, new_cell); } else { // Default operation: only replace whatever's on the same layer this.editor.place_in_cell(cell, template); } } } cleanup_press() { this.editor.commit_undo(); } } // FIXME still to do on this: // - doesn't know to update canvas size or erase results when a new level is loaded OR when the // level size changes (and for that matter the selection tool doesn't either) // - hold shift to replace all of the same tile in the whole level? (need to know when shift is // toggled) // - right-click to pick, same logic as pencil (which needs improving) // - ctrl-click to erase // - reset the preview after a fill? is that ever necessary? export class FillOperation extends MouseOperation { constructor(...args) { super(...args); let renderer = this.editor.renderer; this.canvas = mk('canvas', { width: renderer.canvas.width, height: renderer.canvas.height, }); this.foreign_object = mk_svg('foreignObject', { x: 0, y: 0, width: this.canvas.width, height: this.canvas.height, transform: `scale(${1/renderer.tileset.size_x} ${1/renderer.tileset.size_y})`, }, this.canvas); this.editor.preview_g.append(this.foreign_object); // array of (true: in flood, false: definitely not), or null if not yet populated this.fill_state = null; // Last coordinates we updated from // FIXME probably not necessary now? this.last_known_coords = null; // Palette tile we last flooded with this.last_known_tile = this.editor.fg_tile; } handle_hover(_mx, _my, _gxf, _gyf, cell_x, cell_y) { this.last_known_coords = [cell_x, cell_y]; this.last_known_tile = this.editor.fg_tile; this._floodfill_from(cell_x, cell_y); } _floodfill_from(x0, y0) { let i0 = this.editor.stored_level.coords_to_scalar(x0, y0); if (this.fill_state && this.fill_state[i0]) { // This cell is already part of the pending fill, so there's nothing to do return; } let stored_level = this.editor.stored_level; let tile = this.editor.fg_tile; let layer = tile.type.layer; let tile0 = stored_level.linear_cells[i0][layer] ?? null; let type0 = tile0 ? tile0.type : null; if (! this.editor.selection.contains(x0, y0)) { this.fill_state = null; this._redraw(); return; } // Aaand, floodfill this.fill_state = new Array(stored_level.linear_cells.length); this.fill_state[i0] = true; let pending = [i0]; let steps = 0; while (pending.length > 0) { let old_pending = pending; pending = []; for (let i of old_pending) { let [x, y] = stored_level.scalar_to_coords(i); // Check neighbors for (let dirinfo of Object.values(DIRECTIONS)) { let [dx, dy] = dirinfo.movement; let nx = x + dx; let ny = y + dy; let j = stored_level.coords_to_scalar(nx, ny) if (! this.editor.selection.contains(nx, ny)) { this.fill_state[j] = false; continue; } let cell = this.editor.cell(nx, ny); if (cell) { if (this.fill_state[j] !== undefined) continue; let tile = cell[layer] ?? null; let type = tile ? tile.type : null; if (type === type0) { this.fill_state[j] = true; pending.push(j); } else { this.fill_state[j] = false; } } } steps += 1; if (steps > 10000) { console.error("more steps than should be possible"); return; } } } this._redraw(); } _redraw() { // Draw all the good tiles let ctx = this.canvas.getContext('2d'); ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); if (! this.fill_state) return; let stored_level = this.editor.stored_level; let tileset = this.editor.renderer.tileset; let source = this.editor.fg_tile_el; for (let [i, ok] of this.fill_state.entries()) { if (! ok) continue; let [x, y] = stored_level.scalar_to_coords(i); ctx.drawImage(source, x * tileset.size_x, y * tileset.size_y); } } handle_tile_updated(is_bg = false) { if (is_bg) // TODO return; // Figure out whether the floodfill results changed. If the new tile is on the same layer // as the old tile, we can reuse the results and just redraw. If not, recompute everything // (unless we're hidden, in which case blow it away and just do nothing). if (this.editor.fg_tile.type.layer === this.last_known_tile.type.layer) { if (this.fill_state) { this._redraw(); } } else { this.fill_state = null; if (this.last_known_coords && ! this.hidden) { this._floodfill_from(...this.last_known_coords); } } } cleanup_hover() { this.foreign_object.remove(); } handle_press() { // Filling is a single-click thing, and all the work was done while hovering if (! this.fill_state) { // Something has gone terribly awry (or they clicked outside the level) return; } let stored_level = this.editor.stored_level; let template = this.editor.fg_tile; for (let [i, ok] of this.fill_state.entries()) { if (! ok) continue; let cell = this.editor.cell(...stored_level.scalar_to_coords(i)); this.editor.place_in_cell(cell, template); } this.editor.commit_undo(); } } // TODO also, delete // TODO also, non-rectangular selections // TODO also, better marching ants, hard to see on gravel export class SelectOperation extends MouseOperation { handle_press() { if (! this.editor.selection.is_empty && this.editor.selection.contains(this.click_cell_x, this.click_cell_y)) { // Move existing selection this.mode = 'float'; if (this.ctrl) { this.make_copy = true; } } else { // Create new selection this.mode = 'create'; this.pending_selection = this.editor.selection.create_pending(); this.update_pending_selection(); } this.has_moved = false; } handle_drag(client_x, client_y, frac_cell_x, frac_cell_y, cell_x, cell_y) { if (this.mode === 'float') { if (this.has_moved) { this.editor.selection.move_by(Math.floor(cell_x - this.prev_cell_x), Math.floor(cell_y - this.prev_cell_y)); return; } if (this.make_copy) { if (this.editor.selection.is_floating) { // Stamp the floating selection but keep it floating this.editor.selection.stamp_float(true); } else { this.editor.selection.enfloat(true); } } else if (! this.editor.selection.is_floating) { this.editor.selection.enfloat(); } } else { this.update_pending_selection(); } this.has_moved = true; } update_pending_selection() { this.pending_selection.set_extrema(this.click_cell_x, this.click_cell_y, this.prev_cell_x, this.prev_cell_y); } commit_press() { if (this.mode === 'float') { // Make selection move undoable let dx = Math.floor(this.prev_cell_x - this.click_cell_x); let dy = Math.floor(this.prev_cell_y - this.click_cell_y); if (dx || dy) { this.editor._done( () => this.editor.selection.move_by(dx, dy), () => this.editor.selection.move_by(-dx, -dy), ); } } 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) { // Plain click clears selection this.pending_selection.discard(); this.editor.selection.clear(); } else { this.pending_selection.commit(); } } this.editor.commit_undo(); } abort_press() { if (this.mode === 'float') { // FIXME revert the move? } else { this.pending_selection.discard(); } } } export class ForceFloorOperation extends MouseOperation { handle_press(x, y) { // Begin by placing an all-way force floor under the mouse this.editor.place_in_cell(this.cell(x, y), {type: TILE_TYPES.force_floor_all}); } handle_drag(client_x, client_y, frac_cell_x, frac_cell_y) { // 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(frac_cell_x, frac_cell_y)) { i += 1; // 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[LAYERS.terrain].type.name.startsWith('force_floor_')) { this.editor.place_in_cell(prevcell, {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[LAYERS.terrain].type.name.startsWith('force_floor_') && cell[LAYERS.terrain].type.name !== name) { name = 'ice'; } this.editor.place_in_cell(cell, {type: TILE_TYPES[name]}); prevx = x; prevy = y; } } cleanup_press() { this.editor.commit_undo(); } } // 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? export class TrackOperation extends MouseOperation { handle_press() { // Do nothing to start; we only lay track when the mouse leaves a cell this.entry_direction = null; } handle_drag(client_x, client_y, frac_cell_x, frac_cell_y) { // 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(frac_cell_x, frac_cell_y)) { 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') { let new_terrain = {...terrain}; if (this.ctrl) { // Erase // TODO fix track switch? // TODO if this leaves tracks === 0, replace with floor? new_terrain.tracks &= ~bit; } else { // Draw new_terrain.tracks |= bit; } this.editor.place_in_cell(cell, new_terrain); } else if (! this.ctrl) { terrain = { type: TILE_TYPES['railroad'] }; terrain.type.populate_defaults(terrain); terrain.tracks |= bit; this.editor.place_in_cell(cell, terrain); } prevx = x; prevy = y; this.entry_direction = DIRECTIONS[exit_direction].opposite; } } cleanup_press() { this.editor.commit_undo(); } } export class ConnectOperation extends MouseOperation { handle_press(x, y) { // TODO restrict to button/cloner unless holding shift // TODO what do i do when you erase a button/cloner? can i detect if you're picking it up? let src = this.editor.stored_level.coords_to_scalar(x, y); if (this.alt_mode) { // Auto connect using Lynx rules let cell = this.cell(x, y); let terrain = cell[LAYERS.terrain]; let other = null; let swap = false; if (terrain.type.name === 'button_red') { other = this.search_for(src, 'cloner', 1); } else if (terrain.type.name === 'cloner') { other = this.search_for(src, 'button_red', -1); swap = true; } else if (terrain.type.name === 'button_brown') { other = this.search_for(src, 'trap', 1); } else if (terrain.type.name === 'trap') { other = this.search_for(src, 'button_brown', -1); swap = true; } if (other !== null) { if (swap) { this.editor.set_custom_connection(other, src); } else { this.editor.set_custom_connection(src, other); } this.editor.commit_undo(); } return; } this.pending_cxn = new SVGConnection(x, y, x, y); this.editor.svg_overlay.append(this.pending_cxn.element); } // FIXME this is hella the sort of thing that should be on Editor, or in algorithms search_for(i0, name, dir) { let l = this.editor.stored_level.linear_cells.length; let i = i0; while (true) { i += dir; if (i < 0) { i += l; } else if (i >= l) { i -= l; } if (i === i0) return null; let cell = this.editor.stored_level.linear_cells[i]; let tile = cell[LAYERS.terrain]; if (tile.type.name === name) { return i; } } } handle_drag(client_x, client_y, frac_cell_x, frac_cell_y, cell_x, cell_y) { } commit_press() { } abort_press() { this.pending_cxn.element.remove(); } cleanup_press() { } } export class WireOperation extends MouseOperation { handle_press() { if (this.alt_mode) { // Place or remove wire tunnels // TODO this could just be a separate tool now let cell = this.cell(this.click_frac_cell_x, this.click_frac_cell_y); if (! cell) return; let direction; // Use the offset from the center to figure out which edge of the tile to affect let xoff = this.click_frac_cell_x % 1 - 0.5; let yoff = this.click_frac_cell_y % 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; let terrain = cell[LAYERS.terrain]; if (terrain.type.name === 'floor') { terrain = {...terrain}; // TODO if this ever supports drag, remember whether we're adding or removing // initially if (terrain.wire_tunnel_directions & bit) { terrain.wire_tunnel_directions &= ~bit; } else { terrain.wire_tunnel_directions |= bit; } this.editor.place_in_cell(cell, terrain); this.editor.commit_undo(); } } } handle_drag(client_x, client_y, frac_cell_x, frac_cell_y) { if (this.alt_mode) { // 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.prev_frac_cell_x * 4, this.prev_frac_cell_y * 4, frac_cell_x * 4, frac_cell_y * 4, // See comment in iter_touched_cells -1, -1, this.editor.stored_level.size_x * 4, this.editor.stored_level.size_y * 4)) { 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 (! tile) continue; if (! tile.type.contains_wire) continue; tile = {...tile}; tile.wire_directions = tile.wire_directions ?? 0; if (this.ctrl) { // Erase tile.wire_directions &= ~DIRECTIONS[wire_direction].bit; } else { // Draw tile.wire_directions |= DIRECTIONS[wire_direction].bit; } this.editor.place_in_cell(cell, tile); break; } prevqx = qx; prevqy = qy; } } cleanup_press() { this.editor.commit_undo(); } } // 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'], ['stopwatch_bonus', 'stopwatch_penalty'], ['turntable_cw', 'turntable_ccw'], ]) { for (let [i, tile] of cycle.entries()) { let other = cycle[(i + 1) % cycle.length]; ADJUST_TOGGLES_CW[tile] = other; ADJUST_TOGGLES_CCW[other] = tile; } } } export class AdjustOperation extends MouseOperation { handle_press() { let cell = this.cell(this.prev_cell_x, this.prev_cell_y); if (this.ctrl) { for (let tile of cell) { if (tile && TILES_WITH_PROPS[tile.type.name] !== undefined) { this.editor.open_tile_prop_overlay( tile, cell, this.editor.renderer.get_cell_rect(cell.x, cell.y)); break; } } return; } let start_layer = this.shift ? 0 : LAYERS.MAX - 1; for (let layer = start_layer; layer >= 0; layer--) { let tile = cell[layer]; if (! tile) continue; let rotated; tile = {...tile}; // TODO little inefficient 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.place_in_cell(cell, tile); this.editor.commit_undo(); 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.place_in_cell(cell, tile); this.editor.commit_undo(); 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 // FIXME undo // TODO view is not especially visible export class CameraOperation extends MouseOperation { handle_press(x, y, 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.click_cell_x, this.click_cell_y, 1, 1); this.target = mk_svg('rect.overlay-camera', { x: this.click_cell_x, y: this.prev_cell_y, 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.click_client_x < rect.left + 16); let grab_right = (this.click_client_x > rect.right - 16); let grab_top = (this.click_client_y < rect.top + 16); let grab_bottom = (this.click_client_y > 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}`; } handle_drag(client_x, client_y, frac_cell_x, frac_cell_y, cell_x, cell_y) { // FIXME not right if we zoom, should use frac_cell_x let dx = Math.floor((client_x - this.click_client_x) / this.editor.renderer.tileset.size_x + 0.5); let dy = Math.floor((client_y - this.click_client_y) / this.editor.renderer.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(cell_x, this.click_cell_x); this.region.y = Math.min(cell_y, this.click_cell_y); this.region.width = Math.max(cell_x, this.click_cell_x) + 1 - this.region.x; this.region.height = Math.max(cell_y, this.click_cell_y) + 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_press() { 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_press() { 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_press() { this.editor.viewport_el.style.cursor = ''; this.size_text.remove(); } } export class CameraEraseOperation extends MouseOperation { handle_press(x, y, 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); } } }