Improve the connection tool somewhat; show implicit connections

For example, you can now make connections with the connection tool.
Remarkable.

Unfortunately, implicit connections aren't updated as you edit the level
yet.

Also came with some refactors for searching a level and whatnot.
This commit is contained in:
Eevee (Evelyn Woods) committed 2024-04-18 00:56:20 -06:00
1 parent 5e2dfdd926
commit 3cf81b53ad
10 files changed
+324 -176

No files matched your search

+29 -8
View File
@@ -4,12 +4,15 @@ import { BitVector, mk, mk_svg } from '../util.js';
export class SVGConnection {
constructor(sx, sy, dx, dy) {
this.source = mk_svg('circle.-source', {r: 0.5});
this.source = mk_svg('circle.-source', {r: 0.5, cx: sx + 0.5, cy: sy + 0.5});
this.line = mk_svg('line.-arrow', {});
this.dest = mk_svg('rect.-dest', {width: 1, height: 1});
this.dest = mk_svg('rect.-dest', {x: dx, y: dy, width: 1, height: 1});
this.element = mk_svg('g.overlay-connection', this.source, this.line, this.dest);
this.set_source(sx, sy);
this.set_dest(dx, dy);
this.sx = sx;
this.sy = sy;
this.dx = dx;
this.dy = dy;
this._update_line_endpoints();
}
set_source(sx, sy) {
@@ -17,17 +20,35 @@ export class SVGConnection {
this.sy = sy;
this.source.setAttribute('cx', sx + 0.5);
this.source.setAttribute('cy', sy + 0.5);
this.line.setAttribute('x1', sx + 0.5);
this.line.setAttribute('y1', sy + 0.5);
this._update_line_endpoints();
}
set_dest(dx, dy) {
this.dx = dx;
this.dy = dy;
this.line.setAttribute('x2', dx + 0.5);
this.line.setAttribute('y2', dy + 0.5);
this.dest.setAttribute('x', dx);
this.dest.setAttribute('y', dy);
this._update_line_endpoints();
}
_update_line_endpoints() {
// Start the line at the edge of the circle, so, add 0.5 in the direction of the line
let vx = this.dx - this.sx;
let vy = this.dy - this.sy;
let line_length = Math.sqrt(vx*vx + vy*vy);
let trim_x = 0;
let trim_y = 0;
if (line_length >= 1) {
trim_x = 0.5 * vx / line_length;
trim_y = 0.5 * vy / line_length;
}
this.line.setAttribute('x1', this.sx + 0.5 + trim_x);
this.line.setAttribute('y1', this.sy + 0.5 + trim_y);
// Technically this isn't quite right, since the ending is a square and the arrowhead will
// poke into it a bit from angles near 45°, but that requires a bit more trig than seems
// worth it
this.line.setAttribute('x2', this.dx + 0.5 - trim_x);
this.line.setAttribute('y2', this.dy + 0.5 - trim_y);
}
}
+97 -25
View File
@@ -1,5 +1,6 @@
import * as fflate from '../vendor/fflate.js';
import * as algorithms from '../algorithms.js';
import { DIRECTIONS, LAYERS } from '../defs.js';
import * as format_base from '../format-base.js';
import * as c2g from '../format-c2g.js';
@@ -81,7 +82,7 @@ export class Editor extends PrimaryView {
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.connections_g = mk_svg('g', {'data-name': 'connections'});
// 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',
@@ -89,9 +90,13 @@ export class Editor extends PrimaryView {
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'}),
this._filter_morphology_element = mk_svg('feMorphology', {'in': 'SourceAlpha', result: 'dilated', operator: 'dilate', radius: 0.03125}),
this._filter_morphology_element2 = mk_svg('feMorphology', {'in': 'SourceAlpha', result: 'dilated2', operator: 'dilate', radius: 0.0625}),
mk_svg('feFlood', {'flood-color': '#000'}),
mk_svg('feComposite', {in2: 'dilated', operator: 'in', result: 'fill'}),
mk_svg('feFlood', {'flood-color': '#fffc'}),
mk_svg('feComposite', {in2: 'dilated2', operator: 'in', result: 'fill2'}),
mk_svg('feComposite', {'in': 'fill', in2: 'fill2'}),
mk_svg('feComposite', {'in': 'SourceGraphic'}),
),
),
@@ -1043,16 +1048,16 @@ export class Editor extends PrimaryView {
// 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)) {
this.connections_arrows = {};
for (let [src, dest] of 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);
let arrow = new SVGConnection(sx, sy, dx, dy);
this.connections_arrows[src] = arrow;
arrow.element.setAttribute(
'data-source', this.stored_level.linear_cells[src][LAYERS.terrain].type.name);
this.connections_g.append(arrow.element);
}
// TODO why are these in connections_g lol
for (let [i, region] of this.stored_level.camera_regions.entries()) {
@@ -1060,6 +1065,9 @@ export class Editor extends PrimaryView {
this.connections_g.append(el);
}
// Load *implicit* connections
this.recreate_implicit_connections();
this.renderer.set_level(stored_level);
if (this.active) {
this.redraw_entire_level();
@@ -1112,6 +1120,9 @@ export class Editor extends PrimaryView {
this.svg_overlay.style.setProperty('--scale', this.zoom);
this.actual_viewport_el.classList.toggle('--crispy', this.zoom >= 1);
this.statusbar_zoom.textContent = `${this.zoom * 100}%`;
// The arrow outline isn't CSS, so we have to fix it manually
this._filter_morphology_element.setAttribute('radius', 0.03125 / this.zoom);
this._filter_morphology_element2.setAttribute('radius', 0.0625 / this.zoom);
let index = ZOOM_LEVELS.findIndex(el => el >= this.zoom);
if (index < 0) {
@@ -1737,35 +1748,96 @@ export class Editor extends PrimaryView {
);
}
// ------------------------------------------------------------------------------------------------
// Connections (buttons to things they control)
// 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];
let prev = this.stored_level.custom_connections.get(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];
if (this.connections_arrows[src]) {
this.connections_arrows[src].element.remove();
}
this.stored_level.custom_connections.delete(src)
delete this.connections_arrows[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);
this.stored_level.custom_connections.set(src, dest);
if (this.connections_arrows[src]) {
this.connections_arrows[src].set_dest(
...this.stored_level.scalar_to_coords(dest));
}
else {
let arrow = new SVGConnection(
...this.stored_level.scalar_to_coords(src),
...this.stored_level.scalar_to_coords(dest));
this.connections_arrows[src] = arrow;
this.connections_g.append(arrow.element);
}
this.connections_arrows[src].element.setAttribute(
'data-source', this.stored_level.linear_cells[src][LAYERS.terrain].type.name);
}
}
// TODO also use this to indicate traps or flame jets that are initially toggled (trickier with
// flame jets since that can make them look like they're the wrong tile...)
recreate_implicit_connections() {
this.implicit_connections = new Map;
for (let el of this.connections_g.querySelectorAll(':scope > .--implicit')) {
el.remove();
}
for (let [n, cell] of this.stored_level.linear_cells.entries()) {
if (this.stored_level.custom_connections.has(n))
continue;
let terrain = cell[LAYERS.terrain];
if (! terrain.type.connects_to)
continue;
let find_func;
if (terrain.type.connect_order === 'forward') {
find_func = algorithms.find_terrain_linear;
}
else if (terrain.type.connect_order === 'diamond') {
find_func = algorithms.find_terrain_diamond;
}
let target_cell = null;
for (let [found_tile, found_cell] of find_func(this.stored_level, cell, terrain.type.connects_to)) {
target_cell = found_cell;
break;
}
if (target_cell === null)
continue;
let svg = new SVGConnection(
...this.stored_level.scalar_to_coords(n),
target_cell.x, target_cell.y);
this.implicit_connections.set(n, {
index: this.stored_level.coords_to_scalar(target_cell.x, target_cell.y),
svg_connection: svg,
});
svg.element.classList.add('--implicit');
svg.element.setAttribute('data-source', terrain.type.name);
this.connections_g.append(svg.element);
}
}
update_implicit_connection(cell, tile, previous_type) {
// TODO actually this should also update explicit ones, if the source/dest types are changed
// in such a way as to make the connection invalid
}
// ------------------------------------------------------------------------------------------------
// Undo/redo
+72 -4
View File
@@ -17,6 +17,7 @@ import { TILES_WITH_PROPS } from './tile-overlays.js';
// - 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) {
@@ -57,7 +58,7 @@ export class MouseOperation {
// 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-mouseop', this.constructor.name);
el.setAttribute('data-name', this.constructor.name);
if (! this.is_hover_visible) {
el.style.display = 'none';
}
@@ -834,14 +835,40 @@ export class TrackOperation extends MouseOperation {
}
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.stored_level.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
let cell = this.cell(x, y);
let terrain = cell[LAYERS.terrain];
// TODO just use the editor's existing implicits for this?
let other = null;
let swap = false;
if (terrain.type.name === 'button_red') {
@@ -870,8 +897,17 @@ export class ConnectOperation extends MouseOperation {
}
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) {
@@ -896,14 +932,46 @@ export class ConnectOperation extends MouseOperation {
}
}
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.stored_level.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() {
this.pending_cxn.element.remove();
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() {