Basically finish the camera region editing tool; add save/load support for it

This commit is contained in:
Eevee (Evelyn Woods) 2020-09-28 04:00:55 -06:00
parent 432bb881e6
commit 76051870b7
5 changed files with 263 additions and 62 deletions

BIN
icons/tool-camera.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 B

View File

@ -902,7 +902,23 @@ export function parse_level(buf, number = 1) {
} }
else if (section_type === 'RDNY') { else if (section_type === 'RDNY') {
} }
else if (section_type === 'END ') { // TODO LL custom chunks, should distinguish somehow
else if (section_type === 'LXCM') {
// Camera regions
if (section_length % 4 !== 0)
throw new Error(`Expected LXCM chunk to be a multiple of 4 bytes; got ${section_length}`);
let bytes = new Uint8Array(section_buf);
let p = 0;
while (p < section_length) {
let x = bytes[p + 0];
let y = bytes[p + 1];
let w = bytes[p + 2];
let h = bytes[p + 3];
// TODO validate? must be smaller than map?
level.camera_regions.push(new DOMRect(x, y, w, h));
p += 4;
}
} }
else { else {
console.warn(`Unrecognized section type '${section_type}' at offset ${section_start}`); console.warn(`Unrecognized section type '${section_type}' at offset ${section_start}`);
@ -1071,6 +1087,21 @@ export function synthesize_level(stored_level) {
let c2m = new C2M; let c2m = new C2M;
c2m.add_section('CC2M', '133'); c2m.add_section('CC2M', '133');
// Store camera regions
// TODO LL feature, should be distinguished somehow
if (stored_level.camera_regions.length > 0) {
let bytes = new Uint8Array(4 * stored_level.camera_regions.length);
let p = 0;
for (let region of stored_level.camera_regions) {
bytes[p + 0] = region.x;
bytes[p + 1] = region.y;
bytes[p + 2] = region.width;
bytes[p + 3] = region.height;
p += 4;
}
c2m.add_section('LXCM', bytes.buffer);
}
// FIXME well this will not do // FIXME well this will not do
let map_bytes = new Uint8Array(4096); let map_bytes = new Uint8Array(4096);
let map_view = new DataView(map_bytes.buffer); let map_view = new DataView(map_bytes.buffer);

View File

@ -1,3 +1,5 @@
import { mk, mk_svg, walk_grid } from './util.js';
// Superclass for the main display modes: the player, the editor, and the splash screen // Superclass for the main display modes: the player, the editor, and the splash screen
export class PrimaryView { export class PrimaryView {
constructor(conductor, root) { constructor(conductor, root) {

View File

@ -1,4 +1,5 @@
import { DIRECTIONS, TICS_PER_SECOND } from './defs.js'; import { DIRECTIONS, TICS_PER_SECOND } from './defs.js';
import * as c2m from './format-c2m.js';
import { PrimaryView, DialogOverlay } from './main-base.js'; import { PrimaryView, DialogOverlay } from './main-base.js';
import CanvasRenderer from './renderer-canvas.js'; import CanvasRenderer from './renderer-canvas.js';
import TILE_TYPES from './tiletypes.js'; import TILE_TYPES from './tiletypes.js';
@ -37,16 +38,21 @@ class MouseOperation {
// Client coordinates of the initial click // Client coordinates of the initial click
this.mx0 = ev.clientX; this.mx0 = ev.clientX;
this.my0 = ev.clientY; this.my0 = ev.clientY;
// Real cell coordinates (i.e. including fractional position within a cell) of the click
[this.gx0f, this.gy0f] = this.editor.renderer.real_cell_coords_from_event(ev);
// Cell coordinates
this.gx0 = Math.floor(this.gx0f);
this.gy0 = Math.floor(this.gy0f);
// Client coordinates of the previous mouse position // Same as above but for the previous mouse position
this.mx1 = ev.clientX; this.mx1 = this.mx0;
this.my1 = ev.clientY; this.my1 = this.mx1;
// Cell coordinates of the previous mouse position this.gx1f = this.gx0f;
[this.gx1, this.gy1] = this.editor.renderer.cell_coords_from_event(ev); this.gy1f = this.gy0f;
// Real cell coordinates (i.e. including fractional position within a cell) of etc this.gx1 = this.gx0;
[this.gx1f, this.gy1f] = this.editor.renderer.real_cell_coords_from_event(ev); this.gy1 = this.gy0;
this.start(); this.start(ev);
} }
cell(gx, gy) { cell(gx, gy) {
@ -54,26 +60,28 @@ class MouseOperation {
} }
do_mousemove(ev) { do_mousemove(ev) {
let [gx1f, gy1f] = this.editor.renderer.real_cell_coords_from_event(ev); let [gxf, gyf] = this.editor.renderer.real_cell_coords_from_event(ev);
let gx = Math.floor(gxf);
let gy = Math.floor(gyf);
this.step(ev.clientX, ev.clientY, gx1f, gy1f); this.step(ev.clientX, ev.clientY, gxf, gyf, gx, gy);
// Client coordinates of the previous mouse position
this.mx1 = ev.clientX; this.mx1 = ev.clientX;
this.my1 = ev.clientY; this.my1 = ev.clientY;
// Cell coordinates of the previous mouse position this.gx1f = gxf;
[this.gx1, this.gy1] = this.editor.renderer.cell_coords_from_event(ev); this.gy1f = gyf;
// Real cell coordinates (i.e. including fractional position within a cell) of etc this.gx1 = gx;
this.gx1f = gx1f; this.gy1 = gy;
this.gy1f = gy1f;
} }
do_commit() { do_commit() {
this.commit(); this.commit();
this.cleanup();
} }
do_abort() { do_abort() {
this.abort(); this.abort();
this.cleanup();
} }
// Implement these // Implement these
@ -81,6 +89,7 @@ class MouseOperation {
step(x, y) {} step(x, y) {}
commit() {} commit() {}
abort() {} abort() {}
cleanup() {}
} }
class PanOperation extends MouseOperation { class PanOperation extends MouseOperation {
@ -97,8 +106,8 @@ class PencilOperation extends DrawOperation {
start() { start() {
this.editor.place_in_cell(this.gx1, this.gy1, this.editor.palette_selection); this.editor.place_in_cell(this.gx1, this.gy1, this.editor.palette_selection);
} }
step(mx, my, gx, gy) { step(mx, my, gxf, gyf) {
for (let [x, y] of walk_grid(this.gx1f, this.gy1f, gx, gy)) { for (let [x, y] of walk_grid(this.gx1f, this.gy1f, gxf, gyf)) {
this.editor.place_in_cell(x, y, this.editor.palette_selection); this.editor.place_in_cell(x, y, this.editor.palette_selection);
} }
} }
@ -109,14 +118,14 @@ class ForceFloorOperation extends DrawOperation {
// Begin by placing an all-way force floor under the mouse // Begin by placing an all-way force floor under the mouse
this.editor.place_in_cell(x, y, 'force_floor_all'); this.editor.place_in_cell(x, y, 'force_floor_all');
} }
step(mx, my, gx, gy) { step(mx, my, gxf, gyf) {
// Walk the mouse movement and change each we touch to match the direction we // Walk the mouse movement and change each we touch to match the direction we
// crossed the border // crossed the border
// FIXME occasionally i draw a tetris S kinda shape and both middle parts point // FIXME occasionally i draw a tetris S kinda shape and both middle parts point
// the same direction, but shouldn't // the same direction, but shouldn't
let i = 0; let i = 0;
let prevx, prevy; let prevx, prevy;
for (let [x, y] of walk_grid(this.gx1f, this.gy1f, gx, gy)) { for (let [x, y] of walk_grid(this.gx1f, this.gy1f, gxf, gyf)) {
i++; i++;
// The very first cell is the one the mouse was already in, and we don't // 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 // have a movement direction yet, so leave that alone
@ -211,51 +220,189 @@ class AdjustOperation extends MouseOperation {
// TODO should it? // TODO should it?
} }
// FIXME currently allows creating outside the map bounds and moving beyond the right/bottom, sigh
class CameraOperation extends MouseOperation { class CameraOperation extends MouseOperation {
start() { start(ev) {
this.region = this.editor.stored_level.camera_regions[0]; this.offset_x = 0;
this.offset_y = 0;
this.resize_x = 0;
this.resize_y = 0;
// TODO allow resizing it too let cursor;
let rect = this.target.getBoundingClientRect();
if (this.mx0 < rect.left + 16 || this.mx0 > rect.right - 16) { this.target = ev.target.closest('.overlay-camera');
this.mode = 'resize'; if (! this.target) {
// Clicking in empty space creates a new camera region
this.mode = 'create';
cursor = 'move';
this.region = new DOMRect(this.gx0, this.gy0, 1, 1);
this.target = mk_svg('rect.overlay-camera', {
x: this.gx0, y: this.gy1, width: 1, height: 1,
'data-region-index': this.editor.stored_level.camera_regions.length,
});
this.editor.connections_g.append(this.target);
} }
else if (this.my0 < rect.top + 16 || this.my0 > rect.bottom - 16) { 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.mx0 < rect.left + 16);
let grab_right = (this.mx0 > rect.right - 16);
let grab_top = (this.my0 < rect.top + 16);
let grab_bottom = (this.my0 > rect.bottom - 16);
if (grab_left || grab_right || grab_top || grab_bottom) {
this.mode = 'resize'; 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 { else {
this.mode = 'move'; this.mode = 'move';
cursor = 'move';
}
} }
this.editor.viewport_el.style.cursor = cursor;
// Create a text element to show the size while editing
this.size_text = mk_svg('text.overlay-edit-tip', {
// Center it within the rectangle probably (x and y are set in _update_size_text)
'text-anchor': 'middle', 'dominant-baseline': 'middle',
});
this._update_size_text();
this.editor.svg_overlay.append(this.size_text);
}
_update_size_text() {
this.size_text.setAttribute('x', this.region.x + this.offset_x + (this.region.width + this.resize_x) / 2);
this.size_text.setAttribute('y', this.region.y + this.offset_y + (this.region.height + this.resize_y) / 2);
this.size_text.textContent = `${this.region.width + this.resize_x} × ${this.region.height + this.resize_y}`;
}
step(mx, my, gxf, gyf, gx, gy) {
// FIXME not right if we zoom, should use gxf
let dx = Math.floor((mx - this.mx0) / this.editor.conductor.tileset.size_x + 0.5);
let dy = Math.floor((my - this.my0) / this.editor.conductor.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(gx, this.gx0);
this.region.y = Math.min(gy, this.gy0);
this.region.width = Math.max(gx, this.gx0) + 1 - this.region.x;
this.region.height = Math.max(gy, this.gy0) + 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; 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.offset_y = 0;
} }
step(mx, my) { }
let dx = (mx - this.mx0) / this.editor.conductor.tileset.size_x;
let dy = (my - this.my0) / this.editor.conductor.tileset.size_y;
this.offset_x = Math.floor(dx + 0.5);
this.offset_y = Math.floor(dy + 0.5);
// Keep it within the map!
let stored_level = this.editor.stored_level;
this.offset_x = Math.max(- this.region.x, Math.min(stored_level.size_x - this.region.width, this.offset_x));
this.offset_y = Math.max(- this.region.y, Math.min(stored_level.size_y - this.region.height, this.offset_y));
this.target.setAttribute('x', this.region.x + this.offset_x); this.target.setAttribute('x', this.region.x + this.offset_x);
this.target.setAttribute('y', this.region.y + this.offset_y); 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() { commit() {
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 // Actually edit the underlying region
this.region.x += this.offset_x; this.region.x += this.offset_x;
this.region.y += this.offset_y; this.region.y += this.offset_y;
this.region.width += this.resize_x;
this.region.height += this.resize_y;
}
} }
abort() { abort() {
if (this.mode === 'create') {
// The element was fake, so delete it
this.target.remove();
}
else {
// Move the element back to its original location // Move the element back to its original location
this.target.setAttribute('x', this.region.x); this.target.setAttribute('x', this.region.x);
this.target.setAttribute('y', this.region.y); this.target.setAttribute('y', this.region.y);
this.target.setAttribute('width', this.region.width);
this.target.setAttribute('height', this.region.height);
}
}
cleanup() {
this.editor.viewport_el.style.cursor = '';
this.size_text.remove();
}
}
class CameraEraseOperation extends MouseOperation {
start(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);
}
} }
} }
CameraOperation.TARGET_SELECTOR = '.overlay-camera';
const EDITOR_TOOLS = { const EDITOR_TOOLS = {
pencil: { pencil: {
@ -310,9 +457,10 @@ const EDITOR_TOOLS = {
camera: { camera: {
icon: 'icons/tool-camera.png', icon: 'icons/tool-camera.png',
name: "Camera", name: "Camera",
desc: "Draw and edit custom camera bounds", desc: "Draw and edit custom camera regions",
help: "Draw and edit camera bounds. When the player is within a camera region, the camera will avoid showing anything outside that region. LL only.", help: "Draw and edit camera regions. Right-click to erase a region. When the player is within a camera region, the camera will avoid showing anything outside that region. LL only.",
op1: CameraOperation, op1: CameraOperation,
op2: CameraEraseOperation,
}, },
// TODO text tool; thin walls tool; ice tool; map generator?; subtools for select tool (copy, paste, crop) // TODO text tool; thin walls tool; ice tool; map generator?; subtools for select tool (copy, paste, crop)
// TODO interesting option: rotate an actor as you draw it by dragging? or hold a key like in // TODO interesting option: rotate an actor as you draw it by dragging? or hold a key like in
@ -406,13 +554,7 @@ export class Editor extends PrimaryView {
if (! op_type) if (! op_type)
return; return;
let target; this.mouse_op = new op_type(this, ev);
if (op_type.TARGET_SELECTOR) {
target = ev.target.closest(op_type.TARGET_SELECTOR);
if (! target)
return;
}
this.mouse_op = new op_type(this, ev, target);
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
@ -425,6 +567,18 @@ export class Editor extends PrimaryView {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
} }
else if (ev.button === 2) {
// Right button: activate tool's alt mode
let op_type = EDITOR_TOOLS[this.current_tool].op2;
if (! op_type)
return;
this.mouse_op = new op_type(this, ev);
ev.preventDefault();
ev.stopPropagation();
this.renderer.draw();
}
}); });
this.viewport_el.addEventListener('mousemove', ev => { this.viewport_el.addEventListener('mousemove', ev => {
if (! this.mouse_op) if (! this.mouse_op)
@ -443,8 +597,14 @@ export class Editor extends PrimaryView {
if (this.mouse_op) { if (this.mouse_op) {
this.mouse_op.do_commit(); this.mouse_op.do_commit();
this.mouse_op = null; this.mouse_op = null;
ev.stopPropagation();
ev.preventDefault();
} }
}); });
// Disable context menu, which interferes with right-click tools
this.viewport_el.addEventListener('contextmenu', ev => {
ev.preventDefault();
});
window.addEventListener('blur', ev => { window.addEventListener('blur', ev => {
this.cancel_mouse_operation(); this.cancel_mouse_operation();
}); });
@ -551,7 +711,7 @@ export class Editor extends PrimaryView {
mk_svg('line.overlay-cxn', {x1: sx + 0.5, y1: sy + 0.5, x2: dx + 0.5, y2: dy + 0.5}), mk_svg('line.overlay-cxn', {x1: sx + 0.5, y1: sy + 0.5, x2: dx + 0.5, y2: dy + 0.5}),
); );
} }
this.stored_level.camera_regions.push(new DOMRect(0, 0, 10, 10)); // TODO why are these in connections_g lol
for (let [i, region] of this.stored_level.camera_regions.entries()) { for (let [i, region] of this.stored_level.camera_regions.entries()) {
let el = mk_svg('rect.overlay-camera', {x: region.x, y: region.y, width: region.width, height: region.height}); let el = mk_svg('rect.overlay-camera', {x: region.x, y: region.y, width: region.width, height: region.height});
this.connections_g.append(el); this.connections_g.append(el);

View File

@ -838,6 +838,14 @@ main.--has-demo .demo-controls {
fill: #80808040; fill: #80808040;
pointer-events: auto; pointer-events: auto;
} }
#editor .level-editor-overlay text {
/* Each cell is one "pixel", so text needs to be real small */
font-size: 1px;
}
#editor .level-editor-overlay text.overlay-edit-tip {
stroke: none;
fill: black;
}
#editor .controls { #editor .controls {
grid-area: controls; grid-area: controls;