Editor: Stub out support for actually saving levels
This commit is contained in:
parent
89ae9aa4a3
commit
411005eaa6
@ -82,7 +82,8 @@
|
|||||||
<section id="splash-your-levels">
|
<section id="splash-your-levels">
|
||||||
<h2>Make your own (WIP lol)</h2>
|
<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>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>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<main id="player" hidden>
|
<main id="player" hidden>
|
||||||
@ -218,12 +219,6 @@
|
|||||||
<nav class="controls">
|
<nav class="controls">
|
||||||
<div id="editor-tile">
|
<div id="editor-tile">
|
||||||
</div>
|
</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>
|
<p style>
|
||||||
Tip: Right click to color drop.<br>
|
Tip: Right click to color drop.<br>
|
||||||
|
|||||||
@ -48,12 +48,13 @@ export class StoredLevel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StoredGame {
|
export class StoredPack {
|
||||||
constructor(identifier, level_loader) {
|
constructor(identifier, level_loader) {
|
||||||
this.identifier = identifier;
|
this.identifier = identifier;
|
||||||
|
this.title = "";
|
||||||
this._level_loader = level_loader;
|
this._level_loader = level_loader;
|
||||||
|
|
||||||
// Simple objects containing keys:
|
// Simple objects containing keys that are usually:
|
||||||
// title: level title
|
// title: level title
|
||||||
// index: level index, used internally only
|
// index: level index, used internally only
|
||||||
// number: level number (may not match index due to C2G shenanigans)
|
// number: level number (may not match index due to C2G shenanigans)
|
||||||
@ -76,7 +77,9 @@ export class StoredGame {
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Otherwise, attempt to load the level
|
// Otherwise, attempt to load the level
|
||||||
return this._level_loader(meta.bytes, meta.number);
|
return this._level_loader(meta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const StoredGame = StoredPack;
|
||||||
|
|||||||
@ -1060,6 +1060,11 @@ export function parse_level(buf, number = 1) {
|
|||||||
return level;
|
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
|
// Write 1, 2, or 4 bytes to a DataView
|
||||||
function write_n_bytes(view, start, n, value) {
|
function write_n_bytes(view, start, n, value) {
|
||||||
@ -1212,6 +1217,10 @@ export function synthesize_level(stored_level) {
|
|||||||
let c2m = new C2M;
|
let c2m = new C2M;
|
||||||
c2m.add_section('CC2M', '133');
|
c2m.add_section('CC2M', '133');
|
||||||
|
|
||||||
|
if (stored_level.title) {
|
||||||
|
c2m.add_section('TITL', stored_level.title);
|
||||||
|
}
|
||||||
|
|
||||||
// Store camera regions
|
// Store camera regions
|
||||||
// TODO LL feature, should be distinguished somehow
|
// TODO LL feature, should be distinguished somehow
|
||||||
if (stored_level.camera_regions.length > 0) {
|
if (stored_level.camera_regions.length > 0) {
|
||||||
@ -1677,7 +1686,7 @@ const MAX_SIMULTANEOUS_REQUESTS = 5;
|
|||||||
let resolve;
|
let resolve;
|
||||||
let promise = new Promise((res, rej) => { resolve = res });
|
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 parser;
|
||||||
let active_map_fetches = new Set;
|
let active_map_fetches = new Set;
|
||||||
let pending_map_fetches = [];
|
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
|
// 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) {
|
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 = {
|
let meta = {
|
||||||
index: 0,
|
index: 0,
|
||||||
number: 1,
|
number: 1,
|
||||||
|
|||||||
@ -329,8 +329,13 @@ function parse_level(bytes, number) {
|
|||||||
return level;
|
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) {
|
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 full_view = new DataView(buf);
|
||||||
let magic = full_view.getUint32(0, true);
|
let magic = full_view.getUint32(0, true);
|
||||||
|
|||||||
@ -76,7 +76,7 @@ export class TransientOverlay extends Overlay {
|
|||||||
// Overlay styled like a dialog box
|
// Overlay styled like a dialog box
|
||||||
export class DialogOverlay extends Overlay {
|
export class DialogOverlay extends Overlay {
|
||||||
constructor(conductor) {
|
constructor(conductor) {
|
||||||
super(conductor, mk('div.dialog'));
|
super(conductor, mk('form.dialog'));
|
||||||
|
|
||||||
this.root.append(
|
this.root.append(
|
||||||
this.header = mk('header'),
|
this.header = mk('header'),
|
||||||
@ -115,3 +115,11 @@ export class ConfirmOverlay extends DialogOverlay {
|
|||||||
this.footer.append(yes, no);
|
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));
|
||||||
|
}
|
||||||
|
|||||||
@ -1,10 +1,83 @@
|
|||||||
import { DIRECTIONS, TICS_PER_SECOND } from './defs.js';
|
import { DIRECTIONS, TICS_PER_SECOND } from './defs.js';
|
||||||
import { TILES_WITH_PROPS } from './editor-tile-overlays.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 * 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 CanvasRenderer from './renderer-canvas.js';
|
||||||
import TILE_TYPES from './tiletypes.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 {
|
class EditorShareOverlay extends DialogOverlay {
|
||||||
constructor(conductor, url) {
|
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
|
// The second cell tells us the direction to use for the first, assuming it
|
||||||
// had some kind of force floor
|
// had some kind of force floor
|
||||||
if (i === 2) {
|
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_')) {
|
if (prevcell[0].type.name.startsWith('force_floor_')) {
|
||||||
prevcell[0].type = TILE_TYPES[name];
|
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
|
// Drawing a loop with force floors creates ice (but not in the previous
|
||||||
// cell, obviously)
|
// 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_') &&
|
if (cell[0].type.name.startsWith('force_floor_') &&
|
||||||
cell[0].type.name !== name)
|
cell[0].type.name !== name)
|
||||||
{
|
{
|
||||||
@ -736,6 +809,21 @@ export class Editor extends PrimaryView {
|
|||||||
|
|
||||||
this.viewport_el = this.root.querySelector('.editor-canvas .-container');
|
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
|
// FIXME don't hardcode size here, convey this to renderer some other way
|
||||||
this.renderer = new CanvasRenderer(this.conductor.tileset, 32);
|
this.renderer = new CanvasRenderer(this.conductor.tileset, 32);
|
||||||
|
|
||||||
@ -762,6 +850,7 @@ export class Editor extends PrimaryView {
|
|||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
// FIXME eventually this should be automatic
|
||||||
this.renderer.draw();
|
this.renderer.draw();
|
||||||
}
|
}
|
||||||
else if (ev.button === 1) {
|
else if (ev.button === 1) {
|
||||||
@ -797,6 +886,7 @@ export class Editor extends PrimaryView {
|
|||||||
|
|
||||||
this.mouse_op.do_mousemove(ev);
|
this.mouse_op.do_mousemove(ev);
|
||||||
|
|
||||||
|
// FIXME !!!
|
||||||
this.renderer.draw();
|
this.renderer.draw();
|
||||||
});
|
});
|
||||||
// TODO should this happen for a mouseup anywhere?
|
// TODO should this happen for a mouseup anywhere?
|
||||||
@ -816,20 +906,6 @@ export class Editor extends PrimaryView {
|
|||||||
this.cancel_mouse_operation();
|
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
|
// Toolbox
|
||||||
// Selected tile
|
// Selected tile
|
||||||
this.selected_tile_el = this.root.querySelector('.controls #editor-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'));
|
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
|
// Tile palette
|
||||||
let palette_el = this.root.querySelector('.palette');
|
let palette_el = this.root.querySelector('.palette');
|
||||||
this.palette = {}; // name => element
|
this.palette = {}; // name => element
|
||||||
@ -899,15 +1014,113 @@ export class Editor extends PrimaryView {
|
|||||||
this.renderer.draw();
|
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_game(stored_game) {
|
||||||
}
|
}
|
||||||
|
|
||||||
load_level(stored_level) {
|
_xxx_update_stored_level_cells() {
|
||||||
// TODO support a game too i guess
|
// XXX need this for renderer compat, not used otherwise
|
||||||
this.stored_level = stored_level;
|
|
||||||
this.update_viewport_size();
|
|
||||||
|
|
||||||
// XXX need this for renderer compat. but i guess it's nice in general idk
|
|
||||||
this.stored_level.cells = [];
|
this.stored_level.cells = [];
|
||||||
let row;
|
let row;
|
||||||
for (let [i, cell] of this.stored_level.linear_cells.entries()) {
|
for (let [i, cell] of this.stored_level.linear_cells.entries()) {
|
||||||
@ -917,6 +1130,14 @@ export class Editor extends PrimaryView {
|
|||||||
}
|
}
|
||||||
row.push(cell);
|
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
|
// Load connections
|
||||||
this.connections_g.textContent = '';
|
this.connections_g.textContent = '';
|
||||||
@ -938,6 +1159,10 @@ export class Editor extends PrimaryView {
|
|||||||
if (this.active) {
|
if (this.active) {
|
||||||
this.renderer.draw();
|
this.renderer.draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.save_button) {
|
||||||
|
this.save_button.disabled = ! this.conductor.stored_game.editor_metadata;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
update_viewport_size() {
|
update_viewport_size() {
|
||||||
@ -1014,7 +1239,7 @@ export class Editor extends PrimaryView {
|
|||||||
|
|
||||||
cell(x, y) {
|
cell(x, y) {
|
||||||
if (this.is_in_bounds(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 {
|
else {
|
||||||
return null;
|
return null;
|
||||||
@ -1026,7 +1251,7 @@ export class Editor extends PrimaryView {
|
|||||||
if (! tile)
|
if (! tile)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
let cell = this.stored_level.cells[y][x];
|
let cell = this.cell(x, y);
|
||||||
// Replace whatever's on the same layer
|
// Replace whatever's on the same layer
|
||||||
// TODO probably not the best heuristic yet, since i imagine you can
|
// TODO probably not the best heuristic yet, since i imagine you can
|
||||||
// combine e.g. the tent with thin walls
|
// combine e.g. the tent with thin walls
|
||||||
@ -1081,6 +1306,22 @@ export class Editor extends PrimaryView {
|
|||||||
this.mouse_op = null;
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
85
js/main.js
85
js/main.js
@ -456,6 +456,9 @@ class Player extends PrimaryView {
|
|||||||
this.current_keys_new = new Set; // keys that were pressed since input was last read
|
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
|
// TODO this could all probably be more rigorous but it's fine for now
|
||||||
key_target.addEventListener('keydown', ev => {
|
key_target.addEventListener('keydown', ev => {
|
||||||
|
if (! this.active)
|
||||||
|
return;
|
||||||
|
|
||||||
if (ev.key === 'p' || ev.key === 'Pause') {
|
if (ev.key === 'p' || ev.key === 'Pause') {
|
||||||
this.toggle_pause();
|
this.toggle_pause();
|
||||||
return;
|
return;
|
||||||
@ -505,6 +508,9 @@ class Player extends PrimaryView {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
key_target.addEventListener('keyup', ev => {
|
key_target.addEventListener('keyup', ev => {
|
||||||
|
if (! this.active)
|
||||||
|
return;
|
||||||
|
|
||||||
if (ev.key === 'z') {
|
if (ev.key === 'z') {
|
||||||
if (this.state === 'rewinding') {
|
if (this.state === 'rewinding') {
|
||||||
this.set_state('playing');
|
this.set_state('playing');
|
||||||
@ -1455,28 +1461,33 @@ class Splash extends PrimaryView {
|
|||||||
await this.search_multi_source(new util.EntryFileSource(entries));
|
await this.search_multi_source(new util.EntryFileSource(entries));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Bind to "create level" button
|
setup() {
|
||||||
this.root.querySelector('#splash-create-level').addEventListener('click', ev => {
|
// Editor interface
|
||||||
let stored_level = new format_base.StoredLevel(1);
|
// (this has to be handled here because we need to examine the editor,
|
||||||
stored_level.size_x = 32;
|
// which hasn't yet been created in our constructor)
|
||||||
stored_level.size_y = 32;
|
// Bind to "create" buttons
|
||||||
for (let i = 0; i < 1024; i++) {
|
this.root.querySelector('#splash-create-pack').addEventListener('click', ev => {
|
||||||
let cell = new format_base.StoredCell;
|
this.conductor.editor.create_pack();
|
||||||
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,
|
|
||||||
});
|
|
||||||
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
|
// 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
|
// 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";
|
const STORAGE_KEY = "Lexy's Labyrinth";
|
||||||
|
// Records for playing a pack
|
||||||
const STORAGE_PACK_PREFIX = "Lexy's Labyrinth: ";
|
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 {
|
class Conductor {
|
||||||
constructor(tileset) {
|
constructor(tileset) {
|
||||||
this.stored_game = null;
|
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.player.load_game(stored_game);
|
||||||
this.editor.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);
|
this.stored_level = this.stored_game.load_level(level_index);
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
|
console.error(e);
|
||||||
new LevelErrorOverlay(this, e).open();
|
new LevelErrorOverlay(this, e).open();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.level_index = level_index;
|
this.level_index = level_index;
|
||||||
|
|
||||||
// FIXME do better
|
this.update_level_title();
|
||||||
this.level_name_el.textContent = `Level ${level_index + 1} — ${this.stored_level.title}`;
|
|
||||||
|
|
||||||
document.title = `${PAGE_TITLE} - ${this.stored_level.title}`;
|
|
||||||
this.update_nav_buttons();
|
this.update_nav_buttons();
|
||||||
|
|
||||||
this.player.load_level(this.stored_level);
|
this.player.load_level(this.stored_level);
|
||||||
@ -1972,6 +1991,12 @@ class Conductor {
|
|||||||
return true;
|
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() {
|
update_nav_buttons() {
|
||||||
this.nav_choose_level_button.disabled = !this.stored_game;
|
this.nav_choose_level_button.disabled = !this.stored_game;
|
||||||
this.nav_prev_button.disabled = !this.stored_game || this.level_index <= 0;
|
this.nav_prev_button.disabled = !this.stored_game || this.level_index <= 0;
|
||||||
@ -2019,7 +2044,7 @@ class Conductor {
|
|||||||
// TODO handle errors
|
// TODO handle errors
|
||||||
// TODO cancel a download if we start another one?
|
// TODO cancel a download if we start another one?
|
||||||
let buf = await util.fetch(path);
|
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) {
|
async parse_and_load_game(buf, source, path, identifier, title) {
|
||||||
@ -2027,9 +2052,6 @@ class Conductor {
|
|||||||
identifier = this.extract_identifier_from_path(path);
|
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 also support tile world's DAC when reading from local??
|
||||||
// TODO ah, there's more metadata in CCX, crapola
|
// TODO ah, there's more metadata in CCX, crapola
|
||||||
let magic = String.fromCharCode.apply(null, new Uint8Array(buf.slice(0, 4)));
|
let magic = String.fromCharCode.apply(null, new Uint8Array(buf.slice(0, 4)));
|
||||||
@ -2045,7 +2067,6 @@ class Conductor {
|
|||||||
else if (magic.toLowerCase() === 'game') {
|
else if (magic.toLowerCase() === 'game') {
|
||||||
// TODO this isn't really a magic number and isn't required to be first, so, maybe
|
// TODO this isn't really a magic number and isn't required to be first, so, maybe
|
||||||
// this one should just go by filename
|
// this one should just go by filename
|
||||||
console.log(path);
|
|
||||||
let dir;
|
let dir;
|
||||||
if (! path.match(/[/]/)) {
|
if (! path.match(/[/]/)) {
|
||||||
dir = '';
|
dir = '';
|
||||||
@ -2058,6 +2079,12 @@ class Conductor {
|
|||||||
else {
|
else {
|
||||||
throw new Error("Unrecognized file format");
|
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)) {
|
if (this.load_game(stored_game, identifier)) {
|
||||||
this.switch_to_player();
|
this.switch_to_player();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -157,6 +157,11 @@ export function string_from_buffer_ascii(buf, start = 0, len) {
|
|||||||
return String.fromCharCode.apply(null, new Uint8Array(buf, start, 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
|
// 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) {
|
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
|
// TODO if the ray starts outside the grid (extremely unlikely), we should
|
||||||
|
|||||||
@ -193,6 +193,15 @@ svg.svg-icon {
|
|||||||
background: #f0d0d0;
|
background: #f0d0d0;
|
||||||
padding: 0.5em 1em;
|
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 */
|
/* Individual overlays */
|
||||||
table.level-browser {
|
table.level-browser {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user