lexys-labyrinth/js/editor/mouseops.js
2024-04-21 03:53:57 -06:00

1580 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// 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';
// TODO some minor grievances
// - the track overlay doesn't explain "direction" (may not be necessary anyway), allows picking a
// bad initial switch direction
// - track tool should add a switch to a track on right-click, if possible (and also probably delete
// it on ctrl-right-click?)
// - no preview tile with force floor or track tool
// - no ice drawing tool
// - cursor box shows with selection tool which seems inappropriate
// - controls do not exactly stand out and are just plain text
// - set trap as initially open? feels like a weird hack. but it does appear in cc2lp1
const MOUSE_BUTTON_MASKS = [1, 4, 2]; // MouseEvent.button/buttons are ordered differently
export class MouseOperation {
constructor(editor, 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;
// Client coordinates of the previous mouse event
this.prev_client_x = null;
this.prev_client_y = null;
// Cell coordinates
this.prev_cell_x = null;
this.prev_cell_y = null;
// Fractional cell coordinates
this.prev_frac_cell_x = null;
this.prev_frac_cell_y = null;
// 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;
this.cursor_element = null;
this.preview_element = null;
// Assume we're hidden until proven otherwise
// TODO obviously suboptimal when switching tools with the mouse over the canvas... maybe
// editor should send us a fake mousemove in that case, idk
this.hide();
}
// Register an SVG element as the cursor. This will be automatically shown and hidden when
// appropriate, and its position will be updated to match the position of the cursor
set_cursor_element(el) {
this.cursor_element = el;
el.setAttribute('data-name', this.constructor.name);
if (! this.is_hover_visible) {
el.style.display = 'none';
}
this.editor.svg_overlay.append(el);
}
// Register an SVG element as the preview. Similar to the cursor element, but it's assumed to
// be positioned relative to the whole level, and won't be moved around
set_preview_element(el) {
this.preview_element = el;
el.setAttribute('data-name', this.constructor.name);
if (! this.is_hover_visible) {
el.style.display = 'none';
}
this.editor.svg_overlay.append(el);
}
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.cursor_element) {
this.cursor_element.setAttribute('transform', `translate(${cell_x} ${cell_y})`);
}
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;
if (this.cursor_element) {
this.cursor_element.style.display = '';
}
if (this.preview_element) {
this.preview_element.style.display = '';
}
}
}
hide() {
if (this.is_hover_visible) {
this.is_hover_visible = false;
if (this.cursor_element) {
this.cursor_element.style.display = 'none';
}
if (this.preview_element) {
this.preview_element.style.display = 'none';
}
}
}
_update_modifiers(ev) {
this.ctrl = ev.ctrlKey;
this.shift = ev.shiftKey;
this.alt = ev.altKey;
}
clear_modifiers() {
this.ctrl = false;
this.shift = false;
this.alt = false;
}
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();
if (this.cursor_element) {
this.cursor_element.remove();
}
if (this.preview_element) {
this.preview_element.remove();
}
}
*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() {}
}
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;
}
}
// FIXME handle moving the mouse while the button is down; should continuously eyedrop
// FIXME also the pencil cursor doesn't move when right-dragging, because it's not active and
// doesn't receive events any more whoops? but wait, panning DOES hide the cursor, so what the hell
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;
}
let n = this.editor.coords_to_scalar(x, y);
// If we're picking the background, we always use the terrain
if (this.ctrl) {
this.editor.select_background_tile(cell[LAYERS.terrain], n);
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, n);
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);
// Our cursor has two parts, so it's really a group
this.tile_element = mk_svg('image', {
id: 'svg-editor-preview-tile',
x: 0,
y: 0,
width: 1,
height: 1,
opacity: 0.5,
});
this.set_cursor_element(mk_svg('g',
this.tile_element,
mk_svg('rect.overlay-pencil-cursor', {x: 0, y: 0, width: 1, height: 1}),
));
this.handle_tile_updated();
}
// Hover: draw the tile in the pointed-to cell
handle_tile_updated(is_bg = false) {
if (is_bg)
return;
this.tile_element.setAttribute('href', this.editor.fg_tile_el.toDataURL());
}
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
// - wait, no. ctrl to like, fill the terrain layer regardless of the current tile's layer? atm
// you can't flood with an item usefully, it just fills the whole level
// - 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.set_preview_element(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})`,
opacity: 0.5,
}, this.canvas));
// 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.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);
}
}
}
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? there's no delete??
// FIXME don't show the overlay text until has_moved
// TODO cursor: 'cell' by default...?
export class SelectOperation extends MouseOperation {
handle_press() {
if (this.shift) {
this.mode = 'select';
if (this.ctrl) {
// Subtract from selection (the normal way is ctrl, but ctrl-shift works even to
// start dragging inside an existing selection)
this.pending_selection = this.editor.selection.create_pending('subtract');
}
else {
// Extend selection
this.pending_selection = this.editor.selection.create_pending('add');
}
this.update_pending_selection();
}
else if (! this.editor.selection.is_empty &&
this.editor.selection.contains(this.click_cell_x, this.click_cell_y))
{
// Move existing selection
this.mode = 'float';
this.make_copy = this.ctrl;
}
else {
this.mode = 'select';
if (this.ctrl) {
// Subtract from selection (must initiate click outside selection, or it'll float)
this.pending_selection = this.editor.selection.create_pending('subtract');
}
else {
// Create new selection
this.pending_selection = this.editor.selection.create_pending('new');
}
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 { // create/extend
if (this.has_moved) {
// Drag either creates or extends the selection
// If there's an existing floating selection (which isn't what we're operating on),
// commit it before doing anything else
this.editor.selection.commit_floating();
this.pending_selection.commit();
}
else {
// Plain click clears selection. But first, if there's a floating selection and
// it's moved, commit that movement as a separate undo entry
if (this.editor.selection.is_floating) {
let float_moved = this.editor.selection.has_moved;
if (float_moved) {
this.editor.commit_undo();
}
this.editor.selection.commit_floating();
}
this.pending_selection.discard();
this.editor.selection.clear();
}
}
this.editor.commit_undo();
}
abort_press() {
if (this.mode === 'float') {
// FIXME revert the move?
}
else {
this.pending_selection.discard();
}
}
do_destroy() {
// Don't let a floating selection persist when switching tools
this.editor.selection.commit_floating();
this.editor.commit_undo();
super.do_destroy();
}
}
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 {
constructor(...args) {
super(...args);
// This is the SVGConnection structure but with only the source circle
this.connectable_circle = mk_svg('circle.-source', {r: 0.5});
this.connectable_cursor = mk_svg('g.overlay-connection', this.connectable_circle);
this.connectable_cursor.style.display = 'none';
// TODO how do i distinguish from existing ones
this.connectable_cursor.style.stroke = 'lime';
this.editor.svg_overlay.append(this.connectable_cursor);
}
handle_hover(client_x, client_y, frac_cell_x, frac_cell_y, cell_x, cell_y) {
let cell = this.cell(cell_x, cell_y);
let terrain = cell[LAYERS.terrain];
if (terrain.type.connects_to) {
this.connectable_cursor.style.display = '';
this.connectable_circle.setAttribute('cx', cell_x + 0.5);
this.connectable_circle.setAttribute('cy', cell_y + 0.5);
}
else {
this.connectable_cursor.style.display = 'none';
}
}
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.coords_to_scalar(x, y);
let cell = this.cell(x, y);
let terrain = cell[LAYERS.terrain];
if (this.alt_mode) {
// Auto connect using Lynx rules
// TODO just use the editor's existing implicits for this?
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;
}
// Otherwise, this is the start of a drag
if (! terrain.type.connects_to)
return;
this.pending_cxn = new SVGConnection(x, y, x, y);
this.pending_source = src;
this.pending_type = terrain.type.name;
this.editor.svg_overlay.append(this.pending_cxn.element);
// Hide the normal cursor for the duration
this.connectable_cursor.style.display = 'none';
}
// 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) {
if (! this.pending_cxn)
return;
this.pending_cxn.set_dest(cell_x, cell_y);
let cell = this.cell(cell_x, cell_y);
if (TILE_TYPES[this.pending_type].connects_to.has(cell[LAYERS.terrain].type.name)) {
this.pending_target = this.editor.coords_to_scalar(cell_x, cell_y);
this.pending_cxn.element.style.opacity = 0.5;
}
else {
this.pending_target = null;
this.pending_cxn.element.style.opacity = '';
}
}
commit_press() {
// TODO
if (! this.pending_cxn)
return;
if (this.pending_target !== null) {
this.editor.set_custom_connection(this.pending_source, this.pending_target);
}
this.pending_cxn.element.remove();
this.pending_cxn = null;
}
abort_press() {
if (this.pending_cxn) {
this.pending_cxn.element.remove();
this.pending_cxn = null;
}
}
cleanup_press() {
}
do_destroy() {
this.connectable_cursor.remove();
super.do_destroy();
}
}
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;
}
}
}
// Little wrapper that allows calling (simple) callbacks on tile types from the editor.
// Note that editor tiles don't even have .cell, so we have to fake that too
class DummyRunningLevel {
constructor(editor, tile, cell) {
this.editor = editor;
this.tile = tile;
this.cell = cell;
}
_set_tile_prop(tile, key, value) {
if (tile !== this.tile) {
console.error("DummyRunningLevel._set_tile_prop called on an unknown tile:", tile, "expected:", this.tile);
return;
}
this.editor.place_in_cell(this.cell, {...tile, key: value});
}
transmute_tile(tile, type_name) {
if (tile !== this.tile) {
console.error("DummyRunningLevel.transmute_tile called on an unknown tile:", tile, "expected:", this.tile);
return;
}
let type = TILE_TYPES[type_name];
if (! type) {
console.error("DummyRunningLevel.transmute_tile called with bad type:", type_name);
return;
}
if (tile.type.layer !== type.layer) {
console.error("DummyRunningLevel.transmute_tile refusing to change tile layers:", tile, type_name);
return;
}
this.editor.place_in_cell(this.cell, {...tile, type});
}
}
// Tiles with special behavior when clicked
const ADJUST_SPECIAL = {
button_green(editor) {
// Toggle green objects
editor._do(
() => ADJUST_SPECIAL._button_green(editor),
() => ADJUST_SPECIAL._button_green(editor),
);
// TODO play button sound?
},
_button_green(editor) {
for (let cell of editor.stored_level.linear_cells) {
for (let tile of cell) {
if (tile && tile.type.green_toggle_counterpart) {
tile.type = TILE_TYPES[tile.type.green_toggle_counterpart];
editor.mark_cell_dirty(cell);
}
}
}
},
button_gray(editor, tile, cell) {
// Toggle gray objects... er... objects affected by gray buttons
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
if (dx === 0 && dy === 0)
continue;
let other_cell = editor.cell(cell.x + dx, cell.y + dy);
if (! other_cell)
continue;
for (let other of other_cell) {
if (other && other.type.on_gray_button && other.type.is_gray_button_editor_safe) {
other.type.on_gray_button(other, new DummyRunningLevel(editor, other, other_cell));
}
}
}
}
},
};
// TODO maybe better visual feedback of what will happen when you click?
// - rotate terrain (cw, ccw)
// - change terrain
// - rotate actor (cw, ccw)
// - press button
export class AdjustOperation extends MouseOperation {
constructor(...args) {
super(...args);
this.gray_button_preview = mk_svg('g.overlay-transient', {'data-source': 'AdjustOperation'});
this.gray_button_preview.append(mk_svg('rect.overlay-adjust-gray-button-radius', {
x: -2,
y: -2,
width: 5,
height: 5,
}));
this.editor.svg_overlay.append(this.gray_button_preview);
}
handle_hover(client_x, client_y, frac_cell_x, frac_cell_y, cell_x, cell_y) {
let cell = this.cell(cell_x, cell_y);
let terrain = cell[LAYERS.terrain];
if (terrain.type.name === 'button_gray') {
this.gray_button_preview.classList.add('--visible');
this.gray_button_preview.setAttribute('transform', `translate(${cell_x} ${cell_y})`);
for (let el of this.gray_button_preview.querySelectorAll('rect.overlay-adjust-gray-button-shroud')) {
el.remove();
}
// The easiest way I can find to preview this is to slap an overlay on everything NOT
// affected by the button. Try to consolidate some of the resulting rectangles though
for (let dy = -2; dy <= 2; dy++) {
let last_rect, last_dx;
for (let dx = -2; dx <= 2; dx++) {
let target = this.cell(cell_x + dx, cell_y + dy);
if (target && target !== cell && target[LAYERS.terrain].type.on_gray_button)
continue;
if (last_rect && last_dx === dx - 1) {
last_rect.setAttribute('width', 1 + parseInt(last_rect.getAttribute('width'), 10));
}
else {
last_rect = mk_svg('rect.overlay-adjust-gray-button-shroud', {
x: dx,
y: dy,
width: 1,
height: 1,
});
this.gray_button_preview.append(last_rect);
}
last_dx = dx;
}
}
}
else {
this.gray_button_preview.classList.remove('--visible');
}
}
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 toggled = (this.alt_mode ? ADJUST_TOGGLES_CCW : ADJUST_TOGGLES_CW)[tile.type.name];
if (toggled) {
tile.type = TILE_TYPES[toggled];
this.editor.place_in_cell(cell, tile);
this.editor.commit_undo();
break;
}
// Other special tile behavior
let special = ADJUST_SPECIAL[tile.type.name];
if (special) {
special(this.editor, tile, cell);
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
do_destroy() {
this.gray_button_preview.remove();
super.do_destroy();
}
}
// 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');
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);
}
}
}