From 53ed2f09483c3b44e292dc39197753bdadc1ae39 Mon Sep 17 00:00:00 2001 From: "Eevee (Evelyn Woods)" Date: Sun, 16 May 2021 17:52:31 -0600 Subject: [PATCH] Add support for rotating or flipping a level or selection --- js/defs.js | 8 ++ js/editor/editordefs.js | 312 +++++++++++++++++++++++++++------------- js/editor/helpers.js | 35 ++++- js/editor/main.js | 261 ++++++++++++++++++++++++++++----- js/editor/mouseops.js | 1 + js/renderer-canvas.js | 25 +++- 6 files changed, 505 insertions(+), 137 deletions(-) diff --git a/js/defs.js b/js/defs.js index e1b0bef..a683ccb 100644 --- a/js/defs.js +++ b/js/defs.js @@ -10,6 +10,8 @@ export const DIRECTIONS = { left: 'west', right: 'east', opposite: 'south', + mirrored: 'north', + flipped: 'south', }, south: { movement: [0, 1], @@ -20,6 +22,8 @@ export const DIRECTIONS = { left: 'east', right: 'west', opposite: 'north', + mirrored: 'south', + flipped: 'north', }, west: { movement: [-1, 0], @@ -30,6 +34,8 @@ export const DIRECTIONS = { left: 'south', right: 'north', opposite: 'east', + mirrored: 'east', + flipped: 'west', }, east: { movement: [1, 0], @@ -40,6 +46,8 @@ export const DIRECTIONS = { left: 'north', right: 'south', opposite: 'west', + mirrored: 'west', + flipped: 'east', }, }; // Should match the bit ordering above, and CC2's order diff --git a/js/editor/editordefs.js b/js/editor/editordefs.js index 189ac33..4be0ffe 100644 --- a/js/editor/editordefs.js +++ b/js/editor/editordefs.js @@ -284,7 +284,47 @@ export const PALETTE = [{ ], }]; -// TODO loading this from json might actually be faster +// Palette entries that aren't names of real tiles, but pre-configured ones. The faux tile names +// listed here should generally be returned from the real tile's pick_palette_entry() +export const SPECIAL_PALETTE_ENTRIES = { + 'thin_walls/south': { name: 'thin_walls', edges: DIRECTIONS['south'].bit }, + 'frame_block/0': { name: 'frame_block', direction: 'south', arrows: new Set }, + 'frame_block/1': { name: 'frame_block', direction: 'north', arrows: new Set(['north']) }, + 'frame_block/2a': { name: 'frame_block', direction: 'north', arrows: new Set(['north', 'east']) }, + 'frame_block/2o': { name: 'frame_block', direction: 'south', arrows: new Set(['north', 'south']) }, + 'frame_block/3': { name: 'frame_block', direction: 'south', arrows: new Set(['north', 'east', 'south']) }, + 'frame_block/4': { name: 'frame_block', direction: 'south', arrows: new Set(['north', 'east', 'south', 'west']) }, + // FIXME need to handle entered_direction intelligently, but also allow setting it explicitly + 'railroad/straight': { name: 'railroad', tracks: 1 << 5, track_switch: null, entered_direction: 'north' }, + 'railroad/curve': { name: 'railroad', tracks: 1 << 0, track_switch: null, entered_direction: 'north' }, + 'railroad/switch': { name: 'railroad', tracks: 0, track_switch: 0, entered_direction: 'north' }, + 'logic_gate/not': { name: 'logic_gate', direction: 'north', gate_type: 'not' }, + 'logic_gate/diode': { name: 'logic_gate', direction: 'north', gate_type: 'diode' }, + 'logic_gate/and': { name: 'logic_gate', direction: 'north', gate_type: 'and' }, + 'logic_gate/or': { name: 'logic_gate', direction: 'north', gate_type: 'or' }, + 'logic_gate/xor': { name: 'logic_gate', direction: 'north', gate_type: 'xor' }, + 'logic_gate/nand': { name: 'logic_gate', direction: 'north', gate_type: 'nand' }, + 'logic_gate/latch-cw': { name: 'logic_gate', direction: 'north', gate_type: 'latch-cw' }, + 'logic_gate/latch-ccw': { name: 'logic_gate', direction: 'north', gate_type: 'latch-ccw' }, + 'logic_gate/counter': { name: 'logic_gate', direction: 'north', gate_type: 'counter', memory: 0 }, + 'circuit_block/xxx': { name: 'circuit_block', direction: 'south', wire_directions: 0xf }, + 'sokoban_block/red': { name: 'sokoban_block', color: 'red' }, + 'sokoban_button/red': { name: 'sokoban_button', color: 'red' }, + 'sokoban_wall/red': { name: 'sokoban_wall', color: 'red' }, + 'sokoban_block/blue': { name: 'sokoban_block', color: 'blue' }, + 'sokoban_button/blue': { name: 'sokoban_button', color: 'blue' }, + 'sokoban_wall/blue': { name: 'sokoban_wall', color: 'blue' }, + 'sokoban_block/yellow': { name: 'sokoban_block', color: 'yellow' }, + 'sokoban_button/yellow':{ name: 'sokoban_button', color: 'yellow' }, + 'sokoban_wall/yellow': { name: 'sokoban_wall', color: 'yellow' }, + 'sokoban_block/green': { name: 'sokoban_block', color: 'green' }, + 'sokoban_button/green': { name: 'sokoban_button', color: 'green' }, + 'sokoban_wall/green': { name: 'sokoban_wall', color: 'green' }, + 'one_way_walls/south': { name: 'one_way_walls', edges: DIRECTIONS['south'].bit }, +}; + +// Editor-specific tile properties. Every tile has a help entry, but some complex tiles have extra +// editor behavior as well export const TILE_DESCRIPTIONS = { // Basics player: { @@ -296,6 +336,7 @@ export const TILE_DESCRIPTIONS = { name: "Cerise", cc2_name: "Melinda", desc: "The player, a gel rabbat who enjoys Lexy. Walks on ice. Stopped by dirt and gravel. Reuses yellow keys.", + min_version: 2, }, hint: { name: "Hint", @@ -925,52 +966,29 @@ export const TILE_DESCRIPTIONS = { }, }; -export const SPECIAL_PALETTE_ENTRIES = { - 'thin_walls/south': { name: 'thin_walls', edges: DIRECTIONS['south'].bit }, - 'frame_block/0': { name: 'frame_block', direction: 'south', arrows: new Set }, - 'frame_block/1': { name: 'frame_block', direction: 'north', arrows: new Set(['north']) }, - 'frame_block/2a': { name: 'frame_block', direction: 'north', arrows: new Set(['north', 'east']) }, - 'frame_block/2o': { name: 'frame_block', direction: 'south', arrows: new Set(['north', 'south']) }, - 'frame_block/3': { name: 'frame_block', direction: 'south', arrows: new Set(['north', 'east', 'south']) }, - 'frame_block/4': { name: 'frame_block', direction: 'south', arrows: new Set(['north', 'east', 'south', 'west']) }, - // FIXME need to handle entered_direction intelligently, but also allow setting it explicitly - 'railroad/straight': { name: 'railroad', tracks: 1 << 5, track_switch: null, entered_direction: 'north' }, - 'railroad/curve': { name: 'railroad', tracks: 1 << 0, track_switch: null, entered_direction: 'north' }, - 'railroad/switch': { name: 'railroad', tracks: 0, track_switch: 0, entered_direction: 'north' }, - 'logic_gate/not': { name: 'logic_gate', direction: 'north', gate_type: 'not' }, - 'logic_gate/diode': { name: 'logic_gate', direction: 'north', gate_type: 'diode' }, - 'logic_gate/and': { name: 'logic_gate', direction: 'north', gate_type: 'and' }, - 'logic_gate/or': { name: 'logic_gate', direction: 'north', gate_type: 'or' }, - 'logic_gate/xor': { name: 'logic_gate', direction: 'north', gate_type: 'xor' }, - 'logic_gate/nand': { name: 'logic_gate', direction: 'north', gate_type: 'nand' }, - 'logic_gate/latch-cw': { name: 'logic_gate', direction: 'north', gate_type: 'latch-cw' }, - 'logic_gate/latch-ccw': { name: 'logic_gate', direction: 'north', gate_type: 'latch-ccw' }, - 'logic_gate/counter': { name: 'logic_gate', direction: 'north', gate_type: 'counter', memory: 0 }, - 'circuit_block/xxx': { name: 'circuit_block', direction: 'south', wire_directions: 0xf }, - 'sokoban_block/red': { name: 'sokoban_block', color: 'red' }, - 'sokoban_button/red': { name: 'sokoban_button', color: 'red' }, - 'sokoban_wall/red': { name: 'sokoban_wall', color: 'red' }, - 'sokoban_block/blue': { name: 'sokoban_block', color: 'blue' }, - 'sokoban_button/blue': { name: 'sokoban_button', color: 'blue' }, - 'sokoban_wall/blue': { name: 'sokoban_wall', color: 'blue' }, - 'sokoban_block/yellow': { name: 'sokoban_block', color: 'yellow' }, - 'sokoban_button/yellow':{ name: 'sokoban_button', color: 'yellow' }, - 'sokoban_wall/yellow': { name: 'sokoban_wall', color: 'yellow' }, - 'sokoban_block/green': { name: 'sokoban_block', color: 'green' }, - 'sokoban_button/green': { name: 'sokoban_button', color: 'green' }, - 'sokoban_wall/green': { name: 'sokoban_wall', color: 'green' }, - 'one_way_walls/south': { name: 'one_way_walls', edges: DIRECTIONS['south'].bit }, -}; -const _RAILROAD_ROTATED_LEFT = [3, 0, 1, 2, 5, 4]; -const _RAILROAD_ROTATED_RIGHT = [1, 2, 3, 0, 5, 4]; -// TODO merge this with the editor descriptions into one big dict of tile stuff only relevant to the editor -export const SPECIAL_PALETTE_BEHAVIOR = { + +export function transform_direction_bitmask(bits, dirprop) { + let new_bits = 0; + for (let dirinfo of Object.values(DIRECTIONS)) { + if (bits & dirinfo.bit) { + new_bits |= DIRECTIONS[dirinfo[dirprop]].bit; + } + } + return new_bits; +} + +// Editor-specific tile properties. +// - pick_palette_entry: given a tile, return the palette key to select when it's eyedropped +// - adjust_forward, adjust_backward: alterations that can be made with the adjust tool or ,/. keys, +// but that aren't real rotations (and thus aren't used for rotate/flip/etc) +// - rotate_left, rotate_right, flip, mirror: transform a tile +// - combine_draw, combine_erase: special handling for composite tiles, when drawing or erasing +// using a 'pristine' tile chosen from the palette +// All the tile modification functions edit in-place with no undo support; that's up to the caller. +export const SPECIAL_TILE_BEHAVIOR = { floor_letter: { - pick_palette_entry() { - return 'floor_letter'; - }, _arrows: ["⬆", "➡", "⬇", "⬅"], - rotate_left(tile) { + adjust_backward(tile) { // Rotate through arrows and ASCII separately let arrow_index = this._arrows.indexOf(tile.overlaid_glyph); if (arrow_index >= 0) { @@ -985,7 +1003,7 @@ export const SPECIAL_PALETTE_BEHAVIOR = { } tile.overlaid_glyph = String.fromCharCode(cp); }, - rotate_right(tile) { + adjust_forward(tile) { let arrow_index = this._arrows.indexOf(tile.overlaid_glyph); if (arrow_index >= 0) { tile.overlaid_glyph = this._arrows[(arrow_index + 1) % 4]; @@ -999,22 +1017,23 @@ export const SPECIAL_PALETTE_BEHAVIOR = { } tile.overlaid_glyph = String.fromCharCode(cp); }, + // TODO rotate arrows at least }, thin_walls: { pick_palette_entry() { return 'thin_walls/south'; }, rotate_left(tile) { - if (tile.edges & 0x01) { - tile.edges |= 0x10; - } - tile.edges >>= 1; + tile.edges = transform_direction_bitmask(tile.edges, 'left'); }, rotate_right(tile) { - tile.edges <<= 1; - if (tile.edges & 0x10) { - tile.edges = (tile.edges & ~0x10) | 0x01; - } + tile.edges = transform_direction_bitmask(tile.edges, 'right'); + }, + mirror(tile) { + tile.edges = transform_direction_bitmask(tile.edges, 'mirrored'); + }, + flip(tile) { + tile.edges = transform_direction_bitmask(tile.edges, 'flipped'); }, combine_draw(palette_tile, existing_tile) { existing_tile.edges |= palette_tile.edges; @@ -1040,20 +1059,28 @@ export const SPECIAL_PALETTE_BEHAVIOR = { return `frame_block/${tile.arrows.size}`; } }, + _transform(tile, dirprop) { + tile.direction = DIRECTIONS[tile.direction][dirprop]; + tile.arrows = new Set(Array.from(tile.arrows, arrow => DIRECTIONS[arrow][dirprop])); + }, rotate_left(tile) { - tile.direction = DIRECTIONS[tile.direction].left; - tile.arrows = new Set(Array.from(tile.arrows, arrow => DIRECTIONS[arrow].left)); + this._transform(tile, 'left'); }, rotate_right(tile) { - tile.direction = DIRECTIONS[tile.direction].right; - tile.arrows = new Set(Array.from(tile.arrows, arrow => DIRECTIONS[arrow].right)); + this._transform(tile, 'right'); + }, + mirror(tile) { + this._transform(tile, 'mirrored'); + }, + flip(tile) { + this._transform(tile, 'flipped'); }, }, logic_gate: { pick_palette_entry(tile) { return `logic_gate/${tile.gate_type}`; }, - rotate_left(tile) { + adjust_backward(tile) { if (tile.gate_type === 'counter') { tile.memory = (tile.memory + 9) % 10; } @@ -1061,7 +1088,7 @@ export const SPECIAL_PALETTE_BEHAVIOR = { tile.direction = DIRECTIONS[tile.direction].left; } }, - rotate_right(tile) { + adjust_forward(tile) { if (tile.gate_type === 'counter') { tile.memory = (tile.memory + 1) % 10; } @@ -1069,6 +1096,43 @@ export const SPECIAL_PALETTE_BEHAVIOR = { tile.direction = DIRECTIONS[tile.direction].right; } }, + // Note that the counter gate can neither rotate nor flip + rotate_left(tile) { + if (tile.gate_type !== 'counter') { + tile.direction = DIRECTIONS[tile.direction].left; + } + }, + rotate_right(tile) { + if (tile.gate_type !== 'counter') { + tile.direction = DIRECTIONS[tile.direction].right; + } + }, + mirror(tile) { + if (tile.gate_type === 'counter') + return; + + if (tile.gate_type === 'latch_cw') { + tile.gate_type = 'latch_ccw'; + } + else if (tile.gate_type === 'latch_ccw') { + tile.gate_type = 'latch_cw'; + } + + tile.direction = DIRECTIONS[tile.direction].mirrored; + }, + flip(tile) { + if (tile.gate_type === 'counter') + return; + + if (tile.gate_type === 'latch_cw') { + tile.gate_type = 'latch_ccw'; + } + else if (tile.gate_type === 'latch_ccw') { + tile.gate_type = 'latch_cw'; + } + + tile.direction = DIRECTIONS[tile.direction].flipped; + }, }, railroad: { pick_palette_entry(tile) { @@ -1082,40 +1146,52 @@ export const SPECIAL_PALETTE_BEHAVIOR = { } return 'railroad/switch'; }, - rotate_left(tile) { + // track order: 0 NE, 1 SE, 2 SW, 3 NW, 4 EW, 5 NS + _tracks_left: [3, 0, 1, 2, 5, 4], + _tracks_right: [1, 2, 3, 0, 5, 4], + _tracks_mirror: [3, 2, 1, 0, 4, 5], + _tracks_flip: [1, 0, 3, 2, 4, 5], + _transform_tracks(tile, track_mapping) { let new_tracks = 0; for (let i = 0; i < 6; i++) { if (tile.tracks & (1 << i)) { - new_tracks |= 1 << _RAILROAD_ROTATED_LEFT[i]; + new_tracks |= 1 << track_mapping[i]; } } tile.tracks = new_tracks; if (tile.track_switch !== null) { - tile.track_switch = _RAILROAD_ROTATED_LEFT[tile.track_switch]; + tile.track_switch = track_mapping[tile.track_switch]; } + }, + rotate_left(tile) { + this._transform_tracks(tile, this._tracks_left); if (tile.entered_direction) { tile.entered_direction = DIRECTIONS[tile.entered_direction].left; } }, rotate_right(tile) { - let new_tracks = 0; - for (let i = 0; i < 6; i++) { - if (tile.tracks & (1 << i)) { - new_tracks |= 1 << _RAILROAD_ROTATED_RIGHT[i]; - } - } - tile.tracks = new_tracks; - - if (tile.track_switch !== null) { - tile.track_switch = _RAILROAD_ROTATED_RIGHT[tile.track_switch]; - } + this._transform_tracks(tile, this._tracks_right); if (tile.entered_direction) { tile.entered_direction = DIRECTIONS[tile.entered_direction].right; } }, + mirror(tile) { + this._transform_tracks(tile, this._tracks_mirror); + + if (tile.entered_direction) { + tile.entered_direction = DIRECTIONS[tile.entered_direction].mirrored; + } + }, + flip(tile) { + this._transform_tracks(tile, this._tracks_flip); + + if (tile.entered_direction) { + tile.entered_direction = DIRECTIONS[tile.entered_direction].flipped; + } + }, combine_draw(palette_tile, existing_tile) { existing_tile.tracks |= palette_tile.tracks; // If we have a switch already, the just-placed track becomes the current one @@ -1198,32 +1274,76 @@ export const SPECIAL_PALETTE_BEHAVIOR = { }, }, }; -SPECIAL_PALETTE_BEHAVIOR['one_way_walls'] = { - ...SPECIAL_PALETTE_BEHAVIOR['thin_walls'], - ...SPECIAL_PALETTE_BEHAVIOR['one_way_walls'], +SPECIAL_TILE_BEHAVIOR['one_way_walls'] = { + ...SPECIAL_TILE_BEHAVIOR['thin_walls'], + ...SPECIAL_TILE_BEHAVIOR['one_way_walls'], }; // Fill in some special behavior that boils down to rotating tiles which happen to be encoded as // different tile types -for (let cycle of [ - ['force_floor_n', 'force_floor_e', 'force_floor_s', 'force_floor_w'], - ['ice_nw', 'ice_ne', 'ice_se', 'ice_sw'], - ['swivel_nw', 'swivel_ne', 'swivel_se', 'swivel_sw'], - ['terraformer_n', 'terraformer_e', 'terraformer_s', 'terraformer_w'], - ['turntable_cw', 'turntable_ccw'], -]) { - for (let [i, name] of cycle.entries()) { - let left = cycle[(i - 1 + cycle.length) % cycle.length]; - let right = cycle[(i + 1) % cycle.length]; - SPECIAL_PALETTE_BEHAVIOR[name] = { - pick_palette_entry() { - return name; - }, - rotate_left(tile) { +function add_special_tile_cycle(rotation_order, mirror_mapping, flip_mapping) { + let names = new Set(rotation_order); + + // Make the flip and mirror mappings symmetrical + for (let map of [mirror_mapping, flip_mapping]) { + for (let [key, value] of Object.entries(map)) { + names.add(key); + names.add(value); + if (! (value in map)) { + map[value] = key; + } + } + } + + for (let name of names) { + let behavior = {}; + + let i = rotation_order.indexOf(name); + if (i >= 0) { + let left = rotation_order[(i - 1 + rotation_order.length) % rotation_order.length]; + let right = rotation_order[(i + 1) % rotation_order.length]; + behavior.rotate_left = function rotate_left(tile) { tile.type = TILE_TYPES[left]; - }, - rotate_right(tile) { + }; + behavior.rotate_right = function rotate_right(tile) { tile.type = TILE_TYPES[right]; - }, - }; + }; + } + + if (name in mirror_mapping) { + let mirror = mirror_mapping[name]; + behavior.mirror = function mirror(tile) { + tile.type = TILE_TYPES[mirror]; + }; + } + + if (name in flip_mapping) { + let flip = flip_mapping[name]; + behavior.flip = function flip(tile) { + tile.type = TILE_TYPES[flip]; + }; + } + + SPECIAL_TILE_BEHAVIOR[name] = behavior; } } + +add_special_tile_cycle( + ['force_floor_n', 'force_floor_e', 'force_floor_s', 'force_floor_w'], + {force_floor_e: 'force_floor_w'}, + {force_floor_n: 'force_floor_s'}, +); +add_special_tile_cycle( + ['ice_nw', 'ice_ne', 'ice_se', 'ice_sw'], + {ice_nw: 'ice_ne', ice_sw: 'ice_se'}, + {ice_nw: 'ice_sw', ice_ne: 'ice_se'}, +); +add_special_tile_cycle( + ['swivel_nw', 'swivel_ne', 'swivel_se', 'swivel_sw'], + {swivel_nw: 'swivel_ne', swivel_sw: 'swivel_se'}, + {swivel_nw: 'swivel_sw', swivel_ne: 'swivel_se'}, +); +add_special_tile_cycle( + [], // turntables don't rotate, but they do flip/mirror + {turntable_cw: 'turntable_ccw'}, + {turntable_cw: 'turntable_ccw'}, +); diff --git a/js/editor/helpers.js b/js/editor/helpers.js index 29e3a62..d6234e1 100644 --- a/js/editor/helpers.js +++ b/js/editor/helpers.js @@ -72,6 +72,7 @@ export class Selection { this.floated_cells = null; this.floated_element = null; + this.floated_canvas = null; } get is_empty() { @@ -117,6 +118,15 @@ export class Selection { this.element.setAttribute('y', this.rect.y); this.element.setAttribute('width', this.rect.width); this.element.setAttribute('height', this.rect.height); + + if (this.floated_element) { + let tileset = this.editor.renderer.tileset; + this.floated_canvas.width = rect.width * tileset.size_x; + this.floated_canvas.height = rect.height * tileset.size_y; + let foreign_obj = this.floated_element.querySelector('foreignObject'); + foreign_obj.setAttribute('width', this.floated_canvas.width); + foreign_obj.setAttribute('height', this.floated_canvas.height); + } } move_by(dx, dy) { @@ -157,8 +167,8 @@ export class Selection { return; let stored_level = this.editor.stored_level; - for (let x = this.rect.left; x < this.rect.right; x++) { - for (let y = this.rect.top; y < this.rect.bottom; y++) { + for (let y = this.rect.top; y < this.rect.bottom; y++) { + for (let x = this.rect.left; x < this.rect.right; x++) { let n = stored_level.coords_to_scalar(x, y); yield [x, y, n]; } @@ -202,6 +212,7 @@ export class Selection { // it forever this.editor._do( () => { + this.floated_canvas = canvas; this.floated_element = floated_element; this.floated_cells = floated_cells; this.svg_group.append(floated_element); @@ -235,11 +246,13 @@ export class Selection { this.stamp_float(); let element = this.floated_element; + let canvas = this.floated_canvas; let cells = this.floated_cells; this.editor._do( () => this._defloat(), () => { this.floated_cells = cells; + this.floated_canvas = canvas; this.floated_element = element; this.svg_group.append(element); }, @@ -250,9 +263,27 @@ export class Selection { _defloat() { this.floated_element.remove(); this.floated_element = null; + this.floated_canvas = null; this.floated_cells = null; } + // Redraw the selection canvas from scratch + redraw() { + if (! this.floated_canvas) + return; + + // FIXME uhoh, how do i actually do this? we have no renderer of our own, we have a + // separate canvas, and all the renderer stuff expects to get ahold of a level. i guess + // refactor it to draw a block of cells? + this.editor.renderer.draw_static_generic({ + x0: 0, y0: 0, + x1: this.rect.width, y1: this.rect.height, + cells: this.floated_cells, + width: this.rect.width, + ctx: this.floated_canvas.getContext('2d'), + }); + } + // TODO allow floating/dragging, ctrl-dragging to copy, anchoring... // TODO make more stuff respect this (more things should go through Editor for undo reasons anyway) } diff --git a/js/editor/main.js b/js/editor/main.js index f0b31c0..d60a0ac 100644 --- a/js/editor/main.js +++ b/js/editor/main.js @@ -11,7 +11,7 @@ import { mk, mk_svg, string_from_buffer_ascii, bytestring_to_buffer } from '../u 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 { TOOLS, TOOL_ORDER, TOOL_SHORTCUTS, PALETTE, SPECIAL_PALETTE_ENTRIES, SPECIAL_TILE_BEHAVIOR, TILE_DESCRIPTIONS, transform_direction_bitmask } from './editordefs.js'; import { SVGConnection, Selection } from './helpers.js'; import * as mouseops from './mouseops.js'; import { TILES_WITH_PROPS } from './tile-overlays.js'; @@ -448,6 +448,33 @@ export class Editor extends PrimaryView { this.redo_button = _make_button("Redo", () => { this.redo(); }); + let edit_items = [ + ["Rotate CCW", () => { + this.rotate_level_left(); + }], + ["Rotate CW", () => { + this.rotate_level_right(); + }], + ["Mirror", () => { + this.mirror_level(); + }], + ["Flip", () => { + this.flip_level(); + }], + ]; + this.edit_menu = new MenuOverlay( + this.conductor, + edit_items, + item => item[0], + item => item[1](), + ); + let edit_menu_button = _make_button("Edit ", ev => { + this.edit_menu.open(ev.currentTarget); + }); + edit_menu_button.append( + mk_svg('svg.svg-icon', {viewBox: '0 0 16 16'}, + mk_svg('use', {href: `#svg-icon-menu-chevron`})), + ); _make_button("Pack properties...", () => { new dialogs.EditorPackMetaOverlay(this.conductor, this.conductor.stored_game).open(); }); @@ -1064,6 +1091,12 @@ export class Editor extends PrimaryView { this.svg_overlay.setAttribute('viewBox', `0 0 ${this.stored_level.size_x} ${this.stored_level.size_y}`); } + update_after_size_change() { + this.update_viewport_size(); + this.update_cell_coordinates(); + this.redraw_entire_level(); + } + // ------------------------------------------------------------------------------------------------ set_canvas_zoom(zoom, origin_x = null, origin_y = null) { @@ -1210,8 +1243,9 @@ export class Editor extends PrimaryView { // Select it in the palette, if possible let key = name; - if (SPECIAL_PALETTE_BEHAVIOR[name]) { - key = SPECIAL_PALETTE_BEHAVIOR[name].pick_palette_entry(tile); + let behavior = SPECIAL_TILE_BEHAVIOR[name]; + if (behavior && behavior.pick_palette_entry) { + key = SPECIAL_TILE_BEHAVIOR[name].pick_palette_entry(tile); } this.palette_fg_selected_el = this.palette[key] ?? null; if (this.palette_fg_selected_el) { @@ -1235,32 +1269,51 @@ export class Editor extends PrimaryView { this.redraw_background_tile(); } - rotate_tile_left(tile) { - if (SPECIAL_PALETTE_BEHAVIOR[tile.type.name]) { - SPECIAL_PALETTE_BEHAVIOR[tile.type.name].rotate_left(tile); + // Transform an individual tile in various ways. No undo handling (as the tile may or may not + // even be part of the level). + _transform_tile(tile, adjust_method, transform_method, direction_property) { + let did_anything = true; + + let behavior = SPECIAL_TILE_BEHAVIOR[tile.type.name]; + if (adjust_method && behavior && behavior[adjust_method]) { + behavior[adjust_method](tile); + } + else if (behavior && behavior[transform_method]) { + behavior[transform_method](tile); } else if (TILE_TYPES[tile.type.name].is_actor) { - tile.direction = DIRECTIONS[tile.direction ?? 'south'].left; + tile.direction = DIRECTIONS[tile.direction ?? 'south'][direction_property]; } else { - return false; + did_anything = false; } - return true; + if (tile.wire_directions) { + tile.wire_directions = transform_direction_bitmask( + tile.wire_directions, direction_property); + did_anything = true; + } + if (tile.wire_tunnel_directions) { + tile.wire_tunnel_directions = transform_direction_bitmask( + tile.wire_tunnel_directions, direction_property); + did_anything = true; + } + + return did_anything; } - - 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_tile_left(tile, include_faux_adjustments = true) { + return this._transform_tile( + tile, include_faux_adjustments ? 'adjust_backward' : null, 'rotate_left', 'left'); + } + rotate_tile_right(tile, include_faux_adjustments = true) { + return this._transform_tile( + tile, include_faux_adjustments ? 'adjust_forward' : null, 'rotate_right', 'right'); + } + mirror_tile(tile) { + return this._transform_tile(tile, null, 'mirror', 'mirrored'); + } + flip_tile(tile) { + return this._transform_tile(tile, null, 'flip', 'flipped'); } rotate_palette_left() { @@ -1371,12 +1424,12 @@ export class Editor extends PrimaryView { 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) + SPECIAL_TILE_BEHAVIOR[tile.type.name] && + SPECIAL_TILE_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); + SPECIAL_TILE_BEHAVIOR[tile.type.name].combine_draw(tile, new_tile); this._assign_tile(cell, layer, new_tile, old_tile); return; } @@ -1418,12 +1471,12 @@ export class Editor extends PrimaryView { 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) + SPECIAL_TILE_BEHAVIOR[tile.type.name] && + SPECIAL_TILE_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); + let remove = SPECIAL_TILE_BEHAVIOR[tile.type.name].combine_erase(tile, new_tile); if (! remove) { this._assign_tile(cell, tile.type.layer, new_tile, old_tile); return; @@ -1474,20 +1527,160 @@ export class Editor extends PrimaryView { 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.update_after_size_change(); }, () => { 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.update_after_size_change(); }); this.commit_undo(); } + // Rearranges cells in the current selection or whole level, based on a few callbacks. + // DOES NOT commit. + // (These don't save undo entries for individual tiles, either, because they're expected to be + // completely reversible, and undo is done by performing the opposite transform rather than + // reloading a copy of a previous state.) + _rearrange_cells(swap_dimensions, downgrade_coords, upgrade_tile) { + let old_cells, old_w; + let w, h; + let new_cells = []; + if (this.selection.is_empty) { + // Do it to the whole level + w = this.stored_level.size_x; + h = this.stored_level.size_y; + old_w = w; + if (swap_dimensions) { + [w, h] = [h, w]; + this.stored_level.size_x = w; + this.stored_level.size_y = h; + } + old_cells = this.stored_level.linear_cells; + this.stored_level.linear_cells = new_cells; + } + else { + // Do it to the selection + w = this.selection.rect.width; + h = this.selection.rect.height; + old_w = w; + if (swap_dimensions) { + [w, h] = [h, w]; + this.selection._set_from_rect(new DOMRect( + this.selection.rect.x, this.selection.rect.y, w, h)); + } + old_cells = this.selection.floated_cells; + this.selection.floated_cells = new_cells; + } + + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + let [old_x, old_y] = downgrade_coords(x, y, w, h); + let cell = old_cells[old_y * old_w + old_x]; + for (let tile of cell) { + if (tile) { + upgrade_tile(tile); + } + } + new_cells.push(cell); + } + } + } + + rotate_level_right() { + this._do_transform( + true, + () => this._rotate_level_right(), + () => this._rotate_level_left(), + ); + } + rotate_level_left() { + this._do_transform( + true, + () => this._rotate_level_left(), + () => this._rotate_level_right(), + ); + } + rotate_level_180() { + } + mirror_level() { + this._do_transform( + false, + () => this._mirror_level(), + () => this._mirror_level(), + ); + } + flip_level() { + this._do_transform( + false, + () => this._flip_level(), + () => this._flip_level(), + ); + } + _do_transform(affects_size, redo, undo) { + // FIXME apply transform to connections if appropriate, somehow, ?? i don't even know how + // those interact with floating selection yet :S + if (! this.selection.is_empty && ! this.selection.is_floating) { + this.selection.enfloat(); + } + this._do( + () => { + redo(); + this._post_transform_cleanup(affects_size); + }, + () => { + undo(); + this._post_transform_cleanup(affects_size); + }, + ); + this.commit_undo(); + } + _post_transform_cleanup(affects_size) { + if (this.selection.is_empty) { + if (affects_size) { + this.update_after_size_change(); + } + else { + this.redraw_entire_level(); + } + } + else { + // FIXME what if it affects size? + this.selection.redraw(); + } + } + // TODO mirror diagonally? + + // Internal-use versions of the above. These DO NOT create undo entries. + _rotate_level_left() { + this._rearrange_cells( + true, + (x, y, w, h) => [h - 1 - y, x], + tile => this.rotate_tile_left(tile, false), + ); + } + _rotate_level_right() { + this._rearrange_cells( + true, + (x, y, w, h) => [y, w - 1 - x], + tile => this.rotate_tile_right(tile, false), + ); + } + _mirror_level() { + this._rearrange_cells( + false, + (x, y, w, h) => [w - 1 - x, y], + tile => this.mirror_tile(tile), + ); + } + _flip_level() { + this._rearrange_cells( + false, + (x, y, w, h) => [x, h - 1 - y], + tile => this.flip_tile(tile), + ); + } + // 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) { diff --git a/js/editor/mouseops.js b/js/editor/mouseops.js index e24e60f..255f4b2 100644 --- a/js/editor/mouseops.js +++ b/js/editor/mouseops.js @@ -1031,6 +1031,7 @@ const ADJUST_TOGGLES_CCW = {}; ['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()) { diff --git a/js/renderer-canvas.js b/js/renderer-canvas.js index 7ac04b9..bec8614 100644 --- a/js/renderer-canvas.js +++ b/js/renderer-canvas.js @@ -334,12 +334,27 @@ export class CanvasRenderer { // Used by the editor and map previews. Draws a region of the level (probably a StoredLevel), // assuming nothing is moving. draw_static_region(x0, y0, x1, y1, destx = x0, desty = y0) { - this._adjust_viewport_if_dirty(); + this.draw_static_generic({x0, y0, x1, y1, destx, desty}); + } - let packet = new CanvasRendererDrawPacket(this, this.ctx, this.perception); + // Most generic possible form of drawing a static region; mainly useful if you want to use a + // different canvas or draw a custom block of cells + // TODO does this actually need any state at all? could it just be, dare i ask, a function? + draw_static_generic({ + x0, y0, x1, y1, destx = x0, desty = y0, cells = null, width = null, + ctx = this.ctx, perception = this.perception, show_facing = this.show_facing, + }) { + if (ctx === this.ctx) { + this._adjust_viewport_if_dirty(); + } + + width = width ?? this.level.size_x; + cells = cells ?? this.level.linear_cells; + + let packet = new CanvasRendererDrawPacket(this, ctx, perception); for (let x = x0; x <= x1; x++) { for (let y = y0; y <= y1; y++) { - let cell = this.level.cell(x, y); + let cell = cells[y * width + x]; if (! cell) continue; @@ -350,11 +365,11 @@ export class CanvasRenderer { // For actors (i.e., blocks), perception only applies if there's something // of potential interest underneath - if (this.perception !== 'normal' && tile.type.is_block && ! seen_anything_interesting) { + if (perception !== 'normal' && tile.type.is_block && ! seen_anything_interesting) { packet.perception = 'normal'; } else { - packet.perception = this.perception; + packet.perception = perception; } if (tile.type.layer < LAYERS.actor && ! (