Editor: Stub out support for actually saving levels

This commit is contained in:
Eevee (Evelyn Woods) 2020-12-03 15:40:44 -07:00
parent 89ae9aa4a3
commit 411005eaa6
9 changed files with 371 additions and 69 deletions

View File

@ -82,7 +82,8 @@
<section id="splash-your-levels">
<h2>Make your own (WIP lol)</h2>
<p>Please note that the level editor is <strong>extremely</strong> unfinished, and can't even save yet.</p>
<p><button type="button" id="splash-create-level" class="button-big">Create a level</button></p>
<p><button type="button" id="splash-create-pack" class="button-big">New pack</button></p>
<p><button type="button" id="splash-create-level" class="button-big">New scratch level</button></p>
</section>
</main>
<main id="player" hidden>
@ -218,12 +219,6 @@
<nav class="controls">
<div id="editor-tile">
</div>
<div class="-buttons">
<button id="editor-share-url" type="button">Share?</button>
<!--
<button id="editor-toggle-green" type="button">Toggle green objects</button>
-->
</div>
<!--
<p style>
Tip: Right click to color drop.<br>

View File

@ -48,12 +48,13 @@ export class StoredLevel {
}
}
export class StoredGame {
export class StoredPack {
constructor(identifier, level_loader) {
this.identifier = identifier;
this.title = "";
this._level_loader = level_loader;
// Simple objects containing keys:
// Simple objects containing keys that are usually:
// title: level title
// index: level index, used internally only
// number: level number (may not match index due to C2G shenanigans)
@ -76,7 +77,9 @@ export class StoredGame {
}
else {
// Otherwise, attempt to load the level
return this._level_loader(meta.bytes, meta.number);
return this._level_loader(meta);
}
}
}
export const StoredGame = StoredPack;

View File

@ -1060,6 +1060,11 @@ export function parse_level(buf, number = 1) {
return level;
}
// This thin wrapper is passed to StoredGame as the parser function
function _parse_level_from_stored_meta(meta) {
return parse_level(meta.bytes, meta.number);
}
// Write 1, 2, or 4 bytes to a DataView
function write_n_bytes(view, start, n, value) {
@ -1212,6 +1217,10 @@ export function synthesize_level(stored_level) {
let c2m = new C2M;
c2m.add_section('CC2M', '133');
if (stored_level.title) {
c2m.add_section('TITL', stored_level.title);
}
// Store camera regions
// TODO LL feature, should be distinguished somehow
if (stored_level.camera_regions.length > 0) {
@ -1677,7 +1686,7 @@ const MAX_SIMULTANEOUS_REQUESTS = 5;
let resolve;
let promise = new Promise((res, rej) => { resolve = res });
let game = new format_base.StoredGame(undefined, parse_level);
let game = new format_base.StoredGame(undefined, _parse_level_from_stored_meta);
let parser;
let active_map_fetches = new Set;
let pending_map_fetches = [];
@ -1754,7 +1763,7 @@ const MAX_SIMULTANEOUS_REQUESTS = 5;
// Individual levels don't make sense on their own, but we can wrap them in a dummy one-level game
export function wrap_individual_level(buf) {
let game = new format_base.StoredGame(undefined, parse_level);
let game = new format_base.StoredGame(undefined, _parse_level_from_stored_meta);
let meta = {
index: 0,
number: 1,

View File

@ -329,8 +329,13 @@ function parse_level(bytes, number) {
return level;
}
// This thin wrapper is passed to StoredGame as the parser function
function _parse_level_from_stored_meta(meta) {
return parse_level(meta.bytes, meta.number);
}
export function parse_game(buf) {
let game = new format_base.StoredGame(null, parse_level);
let game = new format_base.StoredGame(null, _parse_level_from_stored_meta);
let full_view = new DataView(buf);
let magic = full_view.getUint32(0, true);

View File

@ -76,7 +76,7 @@ export class TransientOverlay extends Overlay {
// Overlay styled like a dialog box
export class DialogOverlay extends Overlay {
constructor(conductor) {
super(conductor, mk('div.dialog'));
super(conductor, mk('form.dialog'));
this.root.append(
this.header = mk('header'),
@ -115,3 +115,11 @@ export class ConfirmOverlay extends DialogOverlay {
this.footer.append(yes, no);
}
}
export function load_json_from_storage(key) {
return JSON.parse(window.localStorage.getItem(key));
}
export function save_json_to_storage(key, value) {
window.localStorage.setItem(key, JSON.stringify(value));
}

View File

@ -1,10 +1,83 @@
import { DIRECTIONS, TICS_PER_SECOND } from './defs.js';
import { TILES_WITH_PROPS } from './editor-tile-overlays.js';
import * as format_base from './format-base.js';
import * as c2g from './format-c2g.js';
import { PrimaryView, TransientOverlay, DialogOverlay } from './main-base.js';
import { PrimaryView, TransientOverlay, DialogOverlay, load_json_from_storage, save_json_to_storage } from './main-base.js';
import CanvasRenderer from './renderer-canvas.js';
import TILE_TYPES from './tiletypes.js';
import { SVG_NS, mk, mk_svg, walk_grid } from './util.js';
import { SVG_NS, mk, mk_svg, string_from_buffer_ascii, bytestring_to_buffer, walk_grid } from './util.js';
class EditorLevelMetaOverlay extends DialogOverlay {
constructor(conductor, stored_level) {
super(conductor);
this.set_title("edit level metadata");
let dl = mk('dl.formgrid');
this.main.append(dl);
let time_limit_input = mk('input', {name: 'time_limit', type: 'range', min: 0, max: 999, value: stored_level.time_limit});
let time_limit_output = mk('output');
let update_time_limit = () => {
let time_limit = parseInt(time_limit_input.value, 10);
let text;
if (time_limit === 0) {
text = "No time limit";
}
else {
text = `${time_limit} (${Math.floor(time_limit / 60)}:${('0' + String(time_limit % 60)).slice(-2)})`;
}
time_limit_output.textContent = text;
};
update_time_limit();
time_limit_input.addEventListener('input', update_time_limit);
dl.append(
mk('dt', "Title"),
mk('dd', mk('input', {name: 'title', type: 'text', value: stored_level.title})),
mk('dt', "Time limit"),
mk('dd', time_limit_input, " ", time_limit_output),
mk('dt', "Size"),
mk('dd',
"Width: ",
mk('input', {name: 'size_x', type: 'number', min: 10, max: 100, value: stored_level.size_x}),
mk('br'),
"Height: ",
mk('input', {name: 'size_y', type: 'number', min: 10, max: 100, value: stored_level.size_y}),
),
);
// TODO:
// - author
// - chips?
// - password???
// - comment
// - viewport size mode
// - rng/blob behavior
// - use CC1 tools
// - hide logic
// - "unviewable", "read only"
let ok = mk('button', {type: 'button'}, "make it so");
ok.addEventListener('click', ev => {
let els = this.root.elements;
let title = els.title.value;
if (title !== stored_level.title) {
stored_level.title = title;
this.conductor.update_level_title();
}
stored_level.time_limit = parseInt(els.time_limit.value, 10);
let size_x = parseInt(els.size_x.value, 10);
let size_y = parseInt(els.size_y.value, 10);
if (size_x !== stored_level.size_x || size_y !== stored_level.size_y) {
this.conductor.editor.resize_level(size_x, size_y);
}
this.close();
});
this.footer.append(ok);
}
}
class EditorShareOverlay extends DialogOverlay {
constructor(conductor, url) {
@ -217,7 +290,7 @@ class ForceFloorOperation extends DrawOperation {
// 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.stored_level.cells[prevy][prevx];
let prevcell = this.editor.cell(prevx, prevy);
if (prevcell[0].type.name.startsWith('force_floor_')) {
prevcell[0].type = TILE_TYPES[name];
}
@ -225,7 +298,7 @@ class ForceFloorOperation extends DrawOperation {
// Drawing a loop with force floors creates ice (but not in the previous
// cell, obviously)
let cell = this.editor.stored_level.cells[y][x];
let cell = this.editor.cell(x, y);
if (cell[0].type.name.startsWith('force_floor_') &&
cell[0].type.name !== name)
{
@ -736,6 +809,21 @@ export class Editor extends PrimaryView {
this.viewport_el = this.root.querySelector('.editor-canvas .-container');
// Load editor state; we may need this before setup() since we create new levels before
// actually loading the editor proper
this.stash = load_json_from_storage("Lexy's Labyrinth editor");
if (! this.stash) {
this.stash = {
packs: {}, // key: { title, level_count, last_modified }
// More pack data is stored separately under the key, as {
// levels: [{key, title}],
// }
// Levels are also stored under separate keys, encoded as C2M.
};
}
this.pack_stash = null;
this.level_stash = null;
// FIXME don't hardcode size here, convey this to renderer some other way
this.renderer = new CanvasRenderer(this.conductor.tileset, 32);
@ -762,6 +850,7 @@ export class Editor extends PrimaryView {
ev.preventDefault();
ev.stopPropagation();
// FIXME eventually this should be automatic
this.renderer.draw();
}
else if (ev.button === 1) {
@ -797,6 +886,7 @@ export class Editor extends PrimaryView {
this.mouse_op.do_mousemove(ev);
// FIXME !!!
this.renderer.draw();
});
// TODO should this happen for a mouseup anywhere?
@ -816,20 +906,6 @@ export class Editor extends PrimaryView {
this.cancel_mouse_operation();
});
// Toolbar buttons
this.root.querySelector('#editor-share-url').addEventListener('click', ev => {
let buf = c2g.synthesize_level(this.stored_level);
// FIXME Not ideal, but btoa() wants a string rather than any of the myriad binary types
let stringy_buf = Array.from(new Uint8Array(buf)).map(n => String.fromCharCode(n)).join('');
// Make URL-safe and strip trailing padding
let data = btoa(stringy_buf).replace(/[+]/g, '-').replace(/[/]/g, '_').replace(/=+$/, '');
let url = new URL(location);
url.searchParams.delete('level');
url.searchParams.delete('setpath');
url.searchParams.append('level', data);
new EditorShareOverlay(this.conductor, url.toString()).open();
});
// Toolbox
// Selected tile
this.selected_tile_el = this.root.querySelector('.controls #editor-tile');
@ -869,6 +945,45 @@ export class Editor extends PrimaryView {
this.select_tool(button.getAttribute('data-tool'));
});
// Toolbar buttons for saving, exporting, etc.
let button_container = mk('div.-buttons');
this.root.querySelector('.controls').append(button_container);
let _make_button = (label, onclick) => {
let button = mk('button', {type: 'button'}, label);
button.addEventListener('click', onclick);
button_container.append(button);
return button;
};
_make_button("Properties...", ev => {
new EditorLevelMetaOverlay(this.conductor, this.stored_level).open();
});
this.save_button = _make_button("Save", ev => {
// TODO need feedback. or maybe not bc this should be replaced with autosave later
// TODO also need to update the pack data's last modified time
if (! this.conductor.stored_game.editor_metadata)
return;
let buf = c2g.synthesize_level(this.stored_level);
let stringy_buf = string_from_buffer_ascii(buf);
window.localStorage.setItem(this.stored_level.editor_metadata.key, stringy_buf);
});
if (this.stored_level) {
this.save_button.disabled = ! this.conductor.stored_game.editor_metadata;
}
_make_button("Share", ev => {
let buf = c2g.synthesize_level(this.stored_level);
// FIXME Not ideal, but btoa() wants a string rather than any of the myriad binary types
let stringy_buf = Array.from(new Uint8Array(buf)).map(n => String.fromCharCode(n)).join('');
// Make URL-safe and strip trailing padding
let data = btoa(stringy_buf).replace(/[+]/g, '-').replace(/[/]/g, '_').replace(/=+$/, '');
let url = new URL(location);
url.searchParams.delete('level');
url.searchParams.delete('setpath');
url.searchParams.append('level', data);
new EditorShareOverlay(this.conductor, url.toString()).open();
});
//_make_button("Toggle green objects");
// Tile palette
let palette_el = this.root.querySelector('.palette');
this.palette = {}; // name => element
@ -899,15 +1014,113 @@ export class Editor extends PrimaryView {
this.renderer.draw();
}
_make_cell() {
let cell = new format_base.StoredCell;
cell.push({type: TILE_TYPES['floor']});
return cell;
}
_make_empty_level(number, size_x, size_y) {
let stored_level = new format_base.StoredLevel(number);
stored_level.size_x = size_x;
stored_level.size_y = size_y;
for (let i = 0; i < size_x * size_y; i++) {
stored_level.linear_cells.push(this._make_cell());
}
stored_level.linear_cells[0].push({type: TILE_TYPES['player'], direction: 'south'});
return stored_level;
}
create_pack() {
// TODO get a dialog for asking about level meta first? or is jumping directly into the editor better?
let stored_level = this._make_empty_level(1, 32, 32);
let pack_key = `LLP-${Date.now()}`;
let level_key = `LLL-${Date.now()}`;
let stored_pack = new format_base.StoredPack(pack_key);
stored_pack.editor_metadata = {
key: pack_key,
};
stored_level.editor_metadata = {
key: level_key,
};
// FIXME should convert this to the storage-backed version when switching levels, rather
// than keeping it around?
stored_pack.level_metadata.push({
stored_level: stored_level,
key: level_key,
title: stored_level.title,
index: 0,
number: 1,
});
this.conductor.load_game(stored_pack);
this.stash.packs[pack_key] = {
title: "Untitled pack",
level_count: 1,
last_modified: Date.now(),
};
save_json_to_storage("Lexy's Labyrinth editor", this.stash);
save_json_to_storage(pack_key, {
levels: [{
key: level_key,
title: stored_level.title,
last_modified: Date.now(),
}],
});
let buf = c2g.synthesize_level(stored_level);
let stringy_buf = string_from_buffer_ascii(buf);
window.localStorage.setItem(level_key, stringy_buf);
this.conductor.switch_to_editor();
}
create_scratch_level() {
let stored_level = this._make_empty_level(1, 32, 32);
let stored_pack = new format_base.StoredPack(null);
stored_pack.level_metadata.push({
stored_level: stored_level,
});
this.conductor.load_game(stored_pack);
this.conductor.switch_to_editor();
}
load_editor_pack(pack_key) {
let pack_stash = load_json_from_storage(pack_key);
let stored_pack = new format_base.StoredPack(pack_key, meta => {
let buf = bytestring_to_buffer(localStorage.getItem(meta.key));
let stored_level = c2g.parse_level(buf, meta.number);
stored_level.editor_metadata = {
key: meta.key,
};
return stored_level;
});
stored_pack.editor_metadata = {
key: pack_key,
};
for (let [i, leveldata] of pack_stash.levels.entries()) {
stored_pack.level_metadata.push({
key: leveldata.key,
title: leveldata.title,
index: i,
number: i + 1,
});
}
this.conductor.load_game(stored_pack);
this.conductor.switch_to_editor();
}
load_game(stored_game) {
}
load_level(stored_level) {
// TODO support a game too i guess
this.stored_level = stored_level;
this.update_viewport_size();
// XXX need this for renderer compat. but i guess it's nice in general idk
_xxx_update_stored_level_cells() {
// XXX need this for renderer compat, not used otherwise
this.stored_level.cells = [];
let row;
for (let [i, cell] of this.stored_level.linear_cells.entries()) {
@ -917,6 +1130,14 @@ export class Editor extends PrimaryView {
}
row.push(cell);
}
}
load_level(stored_level) {
// TODO support a game too i guess
this.stored_level = stored_level;
this.update_viewport_size();
this._xxx_update_stored_level_cells();
// Load connections
this.connections_g.textContent = '';
@ -938,6 +1159,10 @@ export class Editor extends PrimaryView {
if (this.active) {
this.renderer.draw();
}
if (this.save_button) {
this.save_button.disabled = ! this.conductor.stored_game.editor_metadata;
}
}
update_viewport_size() {
@ -1014,7 +1239,7 @@ export class Editor extends PrimaryView {
cell(x, y) {
if (this.is_in_bounds(x, y)) {
return this.stored_level.cells[y][x];
return this.stored_level.linear_cells[this.stored_level.coords_to_scalar(x, y)];
}
else {
return null;
@ -1026,7 +1251,7 @@ export class Editor extends PrimaryView {
if (! tile)
return;
let cell = this.stored_level.cells[y][x];
let cell = this.cell(x, y);
// Replace whatever's on the same layer
// TODO probably not the best heuristic yet, since i imagine you can
// combine e.g. the tent with thin walls
@ -1081,6 +1306,22 @@ export class Editor extends PrimaryView {
this.mouse_op = null;
}
}
resize_level(size_x, size_y, x0 = 0, y0 = 0) {
let new_cells = [];
for (let y = y0; y < y0 + size_y; y++) {
for (let x = x0; x < x0 + size_x; x++) {
new_cells.push(this.cell(x, y) ?? this._make_cell());
}
}
this.stored_level.linear_cells = new_cells;
this.stored_level.size_x = size_x;
this.stored_level.size_y = size_y;
this._xxx_update_stored_level_cells();
this.update_viewport_size();
this.renderer.draw();
}
}

View File

@ -456,6 +456,9 @@ class Player extends PrimaryView {
this.current_keys_new = new Set; // keys that were pressed since input was last read
// TODO this could all probably be more rigorous but it's fine for now
key_target.addEventListener('keydown', ev => {
if (! this.active)
return;
if (ev.key === 'p' || ev.key === 'Pause') {
this.toggle_pause();
return;
@ -505,6 +508,9 @@ class Player extends PrimaryView {
}
});
key_target.addEventListener('keyup', ev => {
if (! this.active)
return;
if (ev.key === 'z') {
if (this.state === 'rewinding') {
this.set_state('playing');
@ -1455,28 +1461,33 @@ class Splash extends PrimaryView {
await this.search_multi_source(new util.EntryFileSource(entries));
},
});
// Bind to "create level" button
this.root.querySelector('#splash-create-level').addEventListener('click', ev => {
let stored_level = new format_base.StoredLevel(1);
stored_level.size_x = 32;
stored_level.size_y = 32;
for (let i = 0; i < 1024; i++) {
let cell = new format_base.StoredCell;
cell.push({type: TILE_TYPES['floor']});
stored_level.linear_cells.push(cell);
}
stored_level.linear_cells[0].push({type: TILE_TYPES['player'], direction: 'south'});
// FIXME definitely gonna need a name here chief
let stored_game = new format_base.StoredGame(null);
stored_game.level_metadata.push({
stored_level: stored_level,
setup() {
// Editor interface
// (this has to be handled here because we need to examine the editor,
// which hasn't yet been created in our constructor)
// Bind to "create" buttons
this.root.querySelector('#splash-create-pack').addEventListener('click', ev => {
this.conductor.editor.create_pack();
});
this.conductor.load_game(stored_game);
this.conductor.switch_to_editor();
this.root.querySelector('#splash-create-level').addEventListener('click', ev => {
this.conductor.editor.create_scratch_level();
});
// Add buttons for any existing packs
let packs = this.conductor.editor.stash.packs;
let pack_keys = Object.keys(packs);
pack_keys.sort((a, b) => packs[a].last_modified - packs[b].last_modified);
let editor_list = this.root.querySelector('#splash-your-levels');
for (let key of pack_keys) {
let pack = packs[key];
let button = mk('button', {type: 'button'}, pack.title);
// TODO make a container so this can be 1 event
button.addEventListener('click', ev => {
this.conductor.editor.load_editor_pack(key);
});
editor_list.append(button);
}
}
// Look for something we can load, and load it
@ -1808,8 +1819,16 @@ class LevelBrowserOverlay extends DialogOverlay {
}
// Central dispatcher of what we're doing and what we've got loaded
// We store several kinds of things in localStorage:
// Main storage:
// packs
// options
const STORAGE_KEY = "Lexy's Labyrinth";
// Records for playing a pack
const STORAGE_PACK_PREFIX = "Lexy's Labyrinth: ";
// Metadata for an edited pack
// - list of the levels they own and basic metadata like name
// Stored individual levels: given dummy names, all indexed on their own
class Conductor {
constructor(tileset) {
this.stored_game = null;
@ -1943,6 +1962,8 @@ class Conductor {
};
}
this.level_pack_name_el.textContent = stored_game.title;
this.player.load_game(stored_game);
this.editor.load_game(stored_game);
@ -1955,16 +1976,14 @@ class Conductor {
this.stored_level = this.stored_game.load_level(level_index);
}
catch (e) {
console.error(e);
new LevelErrorOverlay(this, e).open();
return false;
}
this.level_index = level_index;
// FIXME do better
this.level_name_el.textContent = `Level ${level_index + 1}${this.stored_level.title}`;
document.title = `${PAGE_TITLE} - ${this.stored_level.title}`;
this.update_level_title();
this.update_nav_buttons();
this.player.load_level(this.stored_level);
@ -1972,6 +1991,12 @@ class Conductor {
return true;
}
update_level_title() {
this.level_name_el.textContent = `Level ${this.stored_level.number}${this.stored_level.title}`;
document.title = `${this.stored_level.title} [#${this.stored_level.number}] — ${this.stored_game.title}${PAGE_TITLE}`;
}
update_nav_buttons() {
this.nav_choose_level_button.disabled = !this.stored_game;
this.nav_prev_button.disabled = !this.stored_game || this.level_index <= 0;
@ -2019,7 +2044,7 @@ class Conductor {
// TODO handle errors
// TODO cancel a download if we start another one?
let buf = await util.fetch(path);
await this.parse_and_load_game(buf, new util.HTTPFileSource(new URL(location)), path);
await this.parse_and_load_game(buf, new util.HTTPFileSource(new URL(location)), path, title);
}
async parse_and_load_game(buf, source, path, identifier, title) {
@ -2027,9 +2052,6 @@ class Conductor {
identifier = this.extract_identifier_from_path(path);
}
// TODO get title out of C2G when it's supported
this.level_pack_name_el.textContent = title ?? identifier ?? '(untitled)';
// TODO also support tile world's DAC when reading from local??
// TODO ah, there's more metadata in CCX, crapola
let magic = String.fromCharCode.apply(null, new Uint8Array(buf.slice(0, 4)));
@ -2045,7 +2067,6 @@ class Conductor {
else if (magic.toLowerCase() === 'game') {
// TODO this isn't really a magic number and isn't required to be first, so, maybe
// this one should just go by filename
console.log(path);
let dir;
if (! path.match(/[/]/)) {
dir = '';
@ -2058,6 +2079,12 @@ class Conductor {
else {
throw new Error("Unrecognized file format");
}
// TODO load title for a C2G
if (! stored_game.title) {
stored_game.title = title ?? identifier ?? "Untitled pack";
}
if (this.load_game(stored_game, identifier)) {
this.switch_to_player();
}

View File

@ -157,6 +157,11 @@ export function string_from_buffer_ascii(buf, start = 0, len) {
return String.fromCharCode.apply(null, new Uint8Array(buf, start, len));
}
// Converts a string to a buffer, using NO ENCODING, assuming single-byte characters
export function bytestring_to_buffer(bytestring) {
return Uint8Array.from(bytestring, c => c.charCodeAt(0)).buffer;
}
// Cast a line through a grid and yield every cell it touches
export function* walk_grid(x0, y0, x1, y1, min_a, min_b, max_a, max_b) {
// TODO if the ray starts outside the grid (extremely unlikely), we should

View File

@ -193,6 +193,15 @@ svg.svg-icon {
background: #f0d0d0;
padding: 0.5em 1em;
}
dl.formgrid {
display: grid;
grid: auto-flow min-content / 1fr 4fr;
gap: 1em;
margin: 0;
}
dl.formgrid > dd {
margin: 0;
}
/* Individual overlays */
table.level-browser {