diff --git a/js/editor-tile-overlays.js b/js/editor-tile-overlays.js index 27ba96f..2277f19 100644 --- a/js/editor-tile-overlays.js +++ b/js/editor-tile-overlays.js @@ -85,6 +85,82 @@ class HintTileEditor extends TileEditorOverlay { } } +class DirectionalBlockTileEditor extends TileEditorOverlay { + constructor(conductor) { + super(conductor); + + let svg_icons = []; + for (let center of [[16, 0], [16, 16], [0, 16], [0, 0]]) { + let symbol = mk_svg('svg', {viewBox: '0 0 16 16'}, + mk_svg('circle', {cx: center[0], cy: center[1], r: 3}), + mk_svg('circle', {cx: center[0], cy: center[1], r: 13}), + ); + svg_icons.push(symbol); + } + svg_icons.push(mk_svg('svg', {viewBox: '0 0 16 16'}, + mk_svg('rect', {x: -2, y: 3, width: 20, height: 10}), + )); + svg_icons.push(mk_svg('svg', {viewBox: '0 0 16 16'}, + mk_svg('rect', {x: 3, y: -2, width: 10, height: 20}), + )); + + this.root.append(mk('h3', "Arrows")); + let arrow_list = mk('ol.editor-directional-block-tile-arrows.editor-tile-editor-svg-parts'); + // Arrange the arrows in a grid + for (let [direction, icon] of [ + [null, mk_svg('path', {d: 'M 8,16 v -8 h 8'})], + ['north', mk_svg('path', {d: 'M 0,12 h 16 l -8,-8 z'})], + [null, mk_svg('path', {d: 'M 0,8 h 8 v 8'})], + ['west', mk_svg('path', {d: 'M 12,16 v -16 l -8,8 z'})], + [null, null], + ['east', mk_svg('path', {d: 'M 4,0 v 16 l 8,-8 z'})], + [null, mk_svg('path', {d: 'M 16,8 h -8 v -8'})], + ['south', mk_svg('path', {d: 'M 16,4 h -16 l 8,8 z'})], + [null, mk_svg('path', {d: 'M 8,0 v 8 h -8'})], + ]) { + let li = mk('li'); + let svg; + if (icon) { + svg = mk_svg('svg', {viewBox: '0 0 16 16'}, icon); + } + if (direction === null) { + if (svg) { + li.append(svg); + } + } + else { + let input = mk('input', {type: 'checkbox', name: 'direction', value: direction}); + li.append(mk('label', input, svg)); + } + arrow_list.append(li); + } + arrow_list.addEventListener('change', ev => { + if (! this.tile) + return; + + if (ev.target.checked) { + this.tile.arrows.add(ev.target.value); + } + else { + this.tile.arrows.delete(ev.target.value); + } + this.editor.mark_tile_dirty(this.tile); + }); + this.root.append(arrow_list); + } + + edit_tile(tile) { + super.edit_tile(tile); + + for (let input of this.root.elements['direction']) { + input.checked = tile.arrows.has(input.value); + } + } + + static configure_tile_defaults(tile) { + } +} + class RailroadTileEditor extends TileEditorOverlay { constructor(conductor) { super(conductor); @@ -105,7 +181,7 @@ class RailroadTileEditor extends TileEditorOverlay { )); this.root.append(mk('h3', "Tracks")); - let track_list = mk('ul.editor-railroad-tile-tracks'); + let track_list = mk('ul.editor-railroad-tile-tracks.editor-tile-editor-svg-parts'); // Shown as two rows, this puts the straight parts first and the rest in a circle let track_order = [4, 1, 2, 5, 0, 3]; for (let i of track_order) { @@ -168,6 +244,7 @@ class RailroadTileEditor extends TileEditorOverlay { export const TILES_WITH_PROPS = { floor_letter: LetterTileEditor, hint: HintTileEditor, + directional_block: DirectionalBlockTileEditor, railroad: RailroadTileEditor, // TODO various wireable tiles // TODO initial value of counter diff --git a/js/main-editor.js b/js/main-editor.js index 33003ea..2861160 100644 --- a/js/main-editor.js +++ b/js/main-editor.js @@ -612,36 +612,21 @@ class AdjustOperation extends MouseOperation { } return; } - for (let tile of cell) { - // Rotate railroads, which are a bit complicated - if (tile.type.name === 'railroad') { - let new_tracks = 0; - let rotated_tracks = [1, 2, 3, 0, 5, 4]; - for (let [i, new_bit] of rotated_tracks.entries()) { - if (tile.tracks & (1 << i)) { - new_tracks |= (1 << new_bit); - } - } - tile.tracks = new_tracks; + // FIXME implement shift to always target floor, or maybe start from bottom + for (let i = cell.length - 1; i >= 0; i--) { + let tile = cell[i]; - if (tile.switch_track !== null) { - tile.switch_track = rotated_tracks[tile.switch_track]; - } - tile.entered_direction = DIRECTIONS[tile.entered_direction].right; + if (this.editor.rotate_tile_right(tile)) { + this.editor.mark_tile_dirty(tile); + break; } - // TODO also directional blocks - // Toggle tiles that go in obvious pairs let other = ADJUST_TOGGLES_CW[tile.type.name]; if (other) { tile.type = TILE_TYPES[other]; - continue; - } - - // Rotate actors - if (TILE_TYPES[tile.type.name].is_actor) { - tile.direction = DIRECTIONS[tile.direction ?? 'south'].right; + this.editor.mark_tile_dirty(tile); + break; } } } @@ -839,6 +824,7 @@ const EDITOR_TOOLS = { icon: 'icons/tool-pencil.png', name: "Pencil", desc: "Place, erase, and select tiles.\nLeft click: draw\nRight click: erase\nShift: Replace all layers\nCtrl-click: eyedrop", + uses_palette: true, op1: PencilOperation, //op2: EraseOperation, //hover: show current selection under cursor @@ -848,18 +834,21 @@ const EDITOR_TOOLS = { icon: 'icons/tool-line.png', name: "Line", desc: "Draw straight lines", + uses_palette: true, }, box: { // TODO not implemented icon: 'icons/tool-box.png', name: "Box", desc: "Fill a rectangular area with tiles", + uses_palette: true, }, fill: { // TODO not implemented icon: 'icons/tool-fill.png', name: "Fill", desc: "Flood-fill an area with tiles", + uses_palette: true, }, 'force-floors': { icon: 'icons/tool-force-floors.png', @@ -941,8 +930,8 @@ const EDITOR_PALETTE = [{ 'door_blue', 'door_red', 'door_yellow', 'door_green', 'water', 'turtle', 'fire', - 'ice', 'ice_nw', 'ice_ne', 'ice_sw', 'ice_se', - 'force_floor_n', 'force_floor_s', 'force_floor_w', 'force_floor_e', 'force_floor_all', + 'ice', 'ice_nw', // 'ice_ne', 'ice_sw', 'ice_se', + 'force_floor_n', /* 'force_floor_s', 'force_floor_w', 'force_floor_e', */ 'force_floor_all', ], }, { title: "Items", @@ -980,39 +969,193 @@ const EDITOR_PALETTE = [{ tiles: [ 'dirt_block', 'ice_block', - /* - * FIXME this won't work for all kinds of reasons - { name: 'directional_block', arrows: new Set }, - { name: 'directional_block', arrows: new Set(['north']) }, - { name: 'directional_block', arrows: new Set(['north', 'east']) }, - { name: 'directional_block', arrows: new Set(['north', 'south']) }, - { name: 'directional_block', arrows: new Set(['north', 'east', 'south']) }, - { name: 'directional_block', arrows: new Set(['north', 'east', 'south', 'west']) }, - */ - 'bomb', - 'button_gray', - 'button_green', + 'directional_block/0', + 'directional_block/1', + 'directional_block/2a', + 'directional_block/2o', + 'directional_block/3', + 'directional_block/4', + 'green_floor', 'green_wall', 'green_chip', 'green_bomb', - 'button_yellow', + 'button_green', 'button_blue', + 'button_yellow', + 'bomb', + 'button_red', 'cloner', 'button_brown', 'trap', 'button_orange', 'flame_jet_off', 'flame_jet_on', - 'button_pink', - 'button_black', - 'purple_floor', - 'purple_wall', + 'transmogrifier', + 'teleport_blue', 'teleport_red', 'teleport_green', 'teleport_yellow', - 'transmogrifier', + 'railroad/straight', + 'railroad/curve', + 'railroad/switch', + ], + // TODO missing: + // - wires, wire tunnels probably a dedicated tool, placing tunnels like a tile makes no sense + // - stopwatches normal tiles + // - swivel special rotate logic, like ice corners + // - canopy normal tile; layering problem + // - thin walls special rotate logic, like force floors; layering problem + // TODO should tiles that respond to wiring and/or gray buttons be highlighted, highlightable? +}, { + title: "Logic", + tiles: [ + 'logic_gate/not', + 'logic_gate/and', + 'logic_gate/or', + 'logic_gate/xor', + 'logic_gate/nand', + 'logic_gate/latch-cw', + 'logic_gate/latch-ccw', + 'logic_gate/counter', + 'button_pink', + 'button_black', + 'purple_floor', + 'purple_wall', + 'button_gray', ], }]; +const SPECIAL_PALETTE_ENTRIES = { + 'directional_block/0': { name: 'directional_block', arrows: new Set }, + 'directional_block/1': { name: 'directional_block', arrows: new Set(['north']) }, + 'directional_block/2a': { name: 'directional_block', arrows: new Set(['north', 'east']) }, + 'directional_block/2o': { name: 'directional_block', arrows: new Set(['north', 'south']) }, + 'directional_block/3': { name: 'directional_block', arrows: new Set(['north', 'east', 'south']) }, + 'directional_block/4': { name: 'directional_block', arrows: new Set(['north', 'east', 'south', 'west']) }, + // FIXME these should be additive/subtractive, but a track picked up from the level should replace + '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/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' }, +}; +const _RAILROAD_ROTATED_LEFT = [3, 0, 1, 2, 5, 4]; +const _RAILROAD_ROTATED_RIGHT = [1, 2, 3, 0, 5, 4]; +const SPECIAL_PALETTE_BEHAVIOR = { + directional_block: { + pick_palette_entry(tile) { + if (tile.arrows.size === 2) { + let [a, b] = tile.arrows.keys(); + if (a === DIRECTIONS[b].opposite) { + return 'directional_block/2o'; + } + else { + return 'directional_block/2a'; + } + } + else { + return `directional_block/${tile.arrows.size}`; + } + }, + rotate_left(tile) { + tile.arrows = new Set(Array.from(tile.arrows, arrow => DIRECTIONS[arrow].left)); + }, + rotate_right(tile) { + tile.arrows = new Set(Array.from(tile.arrows, arrow => DIRECTIONS[arrow].right)); + }, + }, + logic_gate: { + pick_palette_entry(tile) { + return `logic_gate/${tile.gate_type}`; + }, + 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; + } + }, + }, + railroad: { + pick_palette_entry(tile) { + // This is a little fuzzy, since railroads are compound, but we just go with the first + // one that matches and fall back to the switch if it's empty + if (tile.tracks & 0x30) { + return 'railroad/straight'; + } + if (tile.tracks) { + return 'railroad/curve'; + } + return 'railroad/switch'; + }, + rotate_left(tile) { + let new_tracks = 0; + for (let i = 0; i < 6; i++) { + if (tile.tracks & (1 << i)) { + new_tracks |= 1 << _RAILROAD_ROTATED_LEFT[i]; + } + } + tile.tracks = new_tracks; + + if (tile.track_switch !== null) { + tile.track_switch = _RAILROAD_ROTATED_LEFT[tile.track_switch]; + } + + 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]; + } + + if (tile.entered_direction) { + tile.entered_direction = DIRECTIONS[tile.entered_direction].right; + } + }, + }, +}; +// 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'], +]) { + 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(tile) { + return name; + }, + rotate_left(tile) { + tile.type = TILE_TYPES[left]; + }, + rotate_right(tile) { + tile.type = TILE_TYPES[right]; + }, + }; + } +} + export class Editor extends PrimaryView { constructor(conductor) { @@ -1047,6 +1190,28 @@ export class Editor extends PrimaryView { } setup() { + // Keyboard shortcuts + window.addEventListener('keydown', ev => { + if (! this.active) + return; + + if (ev.key === ',') { + if (ev.shiftKey) { + this.rotate_palette_left(); + } + else if (this.palette_selection) { + this.rotate_tile_left(this.palette_selection); + } + } + else if (ev.key === '.') { + if (ev.shiftKey) { + this.rotate_palette_right(); + } + else if (this.palette_selection) { + this.rotate_tile_right(this.palette_selection); + } + } + }); // Level canvas and mouse handling this.mouse_op = null; this.viewport_el.addEventListener('mousedown', ev => { @@ -1234,11 +1399,18 @@ export class Editor extends PrimaryView { for (let sectiondef of EDITOR_PALETTE) { let section_el = mk('section'); palette_el.append(mk('h2', sectiondef.title), section_el); - for (let name of sectiondef.tiles) { - let entry = this.renderer.create_tile_type_canvas(name); - entry.setAttribute('data-tile-name', name); + for (let key of sectiondef.tiles) { + let entry; + if (SPECIAL_PALETTE_ENTRIES[key]) { + let tile = SPECIAL_PALETTE_ENTRIES[key]; + entry = this.renderer.create_tile_type_canvas(tile.name, tile); + } + else { + entry = this.renderer.create_tile_type_canvas(key); + } + entry.setAttribute('data-palette-key', key); entry.classList = 'palette-entry'; - this.palette[name] = entry; + this.palette[key] = entry; section_el.append(entry); } } @@ -1247,7 +1419,18 @@ export class Editor extends PrimaryView { if (! entry) return; - this.select_palette(entry.getAttribute('data-tile-name')); + let key = entry.getAttribute('data-palette-key'); + if (SPECIAL_PALETTE_ENTRIES[key]) { + // Tile with preconfigured stuff on it + let tile = Object.assign({}, SPECIAL_PALETTE_ENTRIES[key]); + tile.type = TILE_TYPES[tile.name]; + delete tile.name; + this.select_palette(tile); + } + else { + // Regular tile name + this.select_palette(key); + } }); this.palette_selection = null; this.select_palette('floor'); @@ -1479,10 +1662,8 @@ export class Editor extends PrimaryView { let name, tile; if (typeof name_or_tile === 'string') { name = name_or_tile; - if (this.palette_selection && name === this.palette_selection.type.name) - return; - tile = { type: TILE_TYPES[name] }; + if (tile.type.is_actor) { tile.direction = 'south'; } @@ -1495,26 +1676,63 @@ export class Editor extends PrimaryView { name = tile.type.name; } - if (this.palette_selection) { - let entry = this.palette[this.palette_selection.type.name]; - if (entry) { - entry.classList.remove('--selected'); - } + // Deselect any previous selection + if (this.palette_selection_el) { + this.palette_selection_el.classList.remove('--selected'); } + + // Store the tile this.palette_selection = tile; - if (this.palette[name]) { - this.palette[name].classList.add('--selected'); + + // Select it in the palette, if possible + let key = name; + if (SPECIAL_PALETTE_BEHAVIOR[name]) { + key = SPECIAL_PALETTE_BEHAVIOR[name].pick_palette_entry(tile); + } + this.palette_selection_el = this.palette[key] ?? null; + if (this.palette_selection_el) { + this.palette_selection_el.classList.add('--selected'); } this.mark_tile_dirty(tile); // Some tools obviously don't work with a palette selection, in which case changing tiles // should default you back to the pencil - if (this.current_tool === 'adjust') { + if (! EDITOR_TOOLS[this.current_tool].uses_palette) { this.select_tool('pencil'); } } + rotate_tile_left(tile) { + if (SPECIAL_PALETTE_BEHAVIOR[tile.type.name]) { + SPECIAL_PALETTE_BEHAVIOR[tile.type.name].rotate_left(tile); + } + else if (TILE_TYPES[tile.type.name].is_actor) { + tile.direction = DIRECTIONS[tile.direction ?? 'south'].left; + } + else { + return false; + } + + this.mark_tile_dirty(tile); + return true; + } + + rotate_tile_right(tile) { + if (SPECIAL_PALETTE_BEHAVIOR[tile.type.name]) { + SPECIAL_PALETTE_BEHAVIOR[tile.type.name].rotate_right(tile); + } + else if (TILE_TYPES[tile.type.name].is_actor) { + tile.direction = DIRECTIONS[tile.direction ?? 'south'].right; + } + else { + return false; + } + + this.mark_tile_dirty(tile); + return true; + } + rotate_palette_left() { this.palette_rotation_index += 1; this.palette_rotation_index %= 4; diff --git a/js/tiletypes.js b/js/tiletypes.js index 539f33a..5a3114d 100644 --- a/js/tiletypes.js +++ b/js/tiletypes.js @@ -391,6 +391,7 @@ const TILE_TYPES = { ], populate_defaults(me) { me.tracks = 0; // bitmask of bits 0-5, corresponding to track order above + // FIXME it's possible to have a switch but no tracks... me.track_switch = null; // null, or 0-5 indicating the active switched track // If there's already an actor on us, it's treated as though it entered the tile moving // in this direction, which is given in the save file and defaults to zero i.e. north diff --git a/style.css b/style.css index f4ab3d9..6620e3f 100644 --- a/style.css +++ b/style.css @@ -1196,6 +1196,57 @@ main.--has-demo .demo-controls { box-shadow: 0 0 0 1px black, 0 0 0 3px white; } +.editor-level-browser { + display: grid; + grid: auto-flow auto / repeat(auto-fill, minmax(13em, 1fr)); /* 12em preview wdith + padding */ + gap: 0.5em; + width: 70vw; + /* seems to go into the parent's right padding fsr, i guess because the scrollbar is there */ + margin-right: 1em; + list-style: none; +} +.editor-level-browser li { + display: grid; + grid: + "preview preview" + "number title" + / min-content 1fr + ; + gap: 0.25em; + padding: 0.5em; +} +.editor-level-browser li:hover { + background: hsl(225, 60%, 85%); +} +.editor-level-browser li > .-preview { + grid-area: preview; + display: flex; + align-items: center; + justify-content: center; + width: 12em; + height: 12em; + margin: auto; +} +.editor-level-browser li > .-preview:empty::before { + content: 'ยทยทยท'; + display: block; + font-size: 5em; + color: #c0c0c0; +} +.editor-level-browser li > .-preview canvas { + display: block; + max-width: 100%; + max-height: 100%; +} +.editor-level-browser li > .-number { + grid-area: number; + font-size: 2em; +} +.editor-level-browser li > .-title { + grid-area: title; + align-self: center; +} + /* Mini editors for specific tiles with complex properties */ /* FIXME should this stuff be on an overlay container class? */ form.editor-popup-tile-editor { @@ -1271,25 +1322,32 @@ textarea.editor-hint-tile-text { border: none; font-family: serif; } -/* Railroad tracks are... complicated */ -ul.editor-railroad-tile-tracks { - display: grid; - grid: auto-flow 3em / repeat(3, 3em); - gap: 0.25em; -} -ul.editor-railroad-tile-tracks input { +/* Class for a list that uses hidden inputs with svg icons, shared by directional blocks and + * railroad tracks */ +.editor-tile-editor-svg-parts input { display: none; } -ul.editor-railroad-tile-tracks svg { +.editor-tile-editor-svg-parts svg { display: block; width: 3em; fill: none; stroke: #c0c0c0; stroke-width: 2; } -ul.editor-railroad-tile-tracks input:checked + svg { +.editor-tile-editor-svg-parts input:checked + svg { stroke: hsl(225, 90%, 50%); } +/* Directional blocks have arrows */ +ol.editor-directional-block-tile-arrows { + display: grid; + grid: auto-flow 3em / repeat(3, 3em); +} +/* Railroad tracks are... complicated */ +ul.editor-railroad-tile-tracks { + display: grid; + grid: auto-flow 3em / repeat(3, 3em); + gap: 0.25em; +} ul.editor-railroad-tile-tracks.--switch input:checked + svg { stroke: hsl(15, 90%, 50%); }