Finally populate the options dialog, with volume controls and tileset selection

This commit is contained in:
Eevee (Evelyn Woods) 2021-01-06 19:04:28 -07:00
parent 04940ff42c
commit f35da9cc2b
11 changed files with 551 additions and 236 deletions

View File

@ -48,7 +48,7 @@
<h1><a href="https://github.com/eevee/lexys-labyrinth">Lexy's Labyrinth</a></h1>
<p>— an <a href="https://github.com/eevee/lexys-labyrinth">open source</a> game by <a href="https://eev.ee/">eevee</a></p>
<nav>
<button id="main-options" type="button" disabled>options</button>
<button id="main-options" type="button">options</button>
<button id="main-compat" type="button">compat mode: <output>lexy</output></button>
</nav>
</header>
@ -190,13 +190,7 @@
<div class="inventory"></div>
</section>
<div id="player-music">
<div id="player-music-left">
🎵 <a id="player-music-title">title</a> by <a id="player-music-author">author</a>
</div>
<div id="player-music-right">
<input id="player-music-volume" type="range" min="0" max="1" step="0.05" value="1">
<input id="player-music-unmute" type="checkbox" checked>
</div>
<audio loop preload="auto">
</div>
</div>

View File

@ -94,7 +94,10 @@ export class StoredLevel extends LevelInterface {
this.viewport_size = 9;
this.extra_chunks = [];
this.use_cc1_boots = false;
this.use_ccl_compat = false;
// What we were parsed from: 'ccl', 'c2m', or null
this.format = null;
// Whether we use LL features that don't exist in CC2; null means we don't know
this.uses_ll_extensions = null;
// 0 - deterministic (PRNG + simple convolution)
// 1 - 4 patterns (default; PRNG + rotating through 0-3)
// 2 - extra random (like deterministic, but initial seed is "actually" random)

View File

@ -790,12 +790,14 @@ const TILE_ENCODING = {
0xe0: {
name: 'gift_bow',
has_next: true,
is_extension: true,
},
0xe1: {
name: 'circuit_block',
has_next: true,
modifier: modifier_wire,
extra_args: [arg_direction],
is_extension: true,
},
};
const REVERSE_TILE_ENCODING = {};
@ -914,6 +916,8 @@ export function parse_level(buf, number = 1) {
}
let level = new format_base.StoredLevel(number);
level.format = 'c2m';
level.uses_ll_extensions = false; // we'll update this if it changes
let extra_hints = [];
let hint_tiles = [];
for (let [type, bytes] of read_c2m_sections(buf)) {
@ -1063,6 +1067,10 @@ export function parse_level(buf, number = 1) {
}
}
if (spec.is_extension) {
level.uses_ll_extensions = true;
}
let name = spec.name;
// Make a tile template, possibly dealing with some special cases

View File

@ -175,7 +175,8 @@ export function parse_level_metadata(bytes) {
function parse_level(bytes, number) {
let level = new format_base.StoredLevel(number);
level.has_custom_connections = true;
level.use_ccl_compat = true;
level.format = 'ccl';
level.uses_ll_extensions = false;
// Map size is always fixed as 32x32 in CC1
level.size_x = 32;
level.size_y = 32;

View File

@ -1,4 +1,4 @@
import { mk, mk_svg, walk_grid } from './util.js';
import { mk } from './util.js';
// Superclass for the main display modes: the player, the editor, and the splash screen
export class PrimaryView {
@ -24,6 +24,8 @@ export class PrimaryView {
this.root.setAttribute('hidden', '');
this.active = false;
}
reload_options(options) {}
}
// Stackable modal overlay of some kind, usually a dialog

View File

@ -199,7 +199,8 @@ class EditorLevelBrowserOverlay extends DialogOverlay {
this.set_title("choose a level");
// Set up some infrastructure to lazily display level renders
this.renderer = new CanvasRenderer(this.conductor.tileset, 32);
// FIXME should this use the tileset appropriate for the particular level?
this.renderer = new CanvasRenderer(this.conductor.tilesets['ll'], 32);
this.awaiting_renders = [];
this.observer = new IntersectionObserver((entries, observer) => {
let any_new = false;
@ -284,8 +285,8 @@ class EditorLevelBrowserOverlay extends DialogOverlay {
this.renderer.set_viewport_size(stored_level.size_x, stored_level.size_y);
this.renderer.draw_static_region(0, 0, stored_level.size_x, stored_level.size_y);
let canvas = mk('canvas', {
width: stored_level.size_x * this.conductor.tileset.size_x / 4,
height: stored_level.size_y * this.conductor.tileset.size_y / 4,
width: stored_level.size_x * this.renderer.tileset.size_x / 4,
height: stored_level.size_y * this.renderer.tileset.size_y / 4,
});
canvas.getContext('2d').drawImage(this.renderer.canvas, 0, 0, canvas.width, canvas.height);
element.querySelector('.-preview').append(canvas);
@ -1051,8 +1052,8 @@ class CameraOperation extends MouseOperation {
}
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 dx = Math.floor((mx - this.mx0) / this.editor.renderer.tileset.size_x + 0.5);
let dy = Math.floor((my - this.my0) / this.editor.renderer.tileset.size_y + 0.5);
let stored_level = this.editor.stored_level;
if (this.mode === 'create') {
@ -2323,7 +2324,7 @@ class Selection {
console.error("Trying to float a selection that's already floating");
this.floated_cells = [];
let tileset = this.editor.conductor.tileset;
let tileset = this.editor.renderer.tileset;
let stored_level = this.editor.stored_level;
let bbox = this.rect;
let canvas = mk('canvas', {width: bbox.width * tileset.size_x, height: bbox.height * tileset.size_y});
@ -2396,7 +2397,7 @@ export class Editor extends PrimaryView {
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);
this.renderer = new CanvasRenderer(this.conductor.tilesets['ll'], 32);
this.renderer.perception = 'editor';
// FIXME need this in load_level which is called even if we haven't been setup yet

View File

@ -7,11 +7,11 @@ import * as c2g from './format-c2g.js';
import * as dat from './format-dat.js';
import * as format_base from './format-base.js';
import { Level } from './game.js';
import { PrimaryView, Overlay, DialogOverlay, ConfirmOverlay, flash_button } from './main-base.js';
import { PrimaryView, Overlay, DialogOverlay, ConfirmOverlay, flash_button, load_json_from_storage, save_json_to_storage } from './main-base.js';
import { Editor } from './main-editor.js';
import CanvasRenderer from './renderer-canvas.js';
import SOUNDTRACK from './soundtrack.js';
import { Tileset, CC2_TILESET_LAYOUT, LL_TILESET_LAYOUT, TILE_WORLD_TILESET_LAYOUT } from './tileset.js';
import { Tileset, CC2_TILESET_LAYOUT, LL_TILESET_LAYOUT, TILE_WORLD_TILESET_LAYOUT, TILESET_LAYOUTS } from './tileset.js';
import TILE_TYPES from './tiletypes.js';
import { random_choice, mk, mk_svg, promise_event } from './util.js';
import * as util from './util.js';
@ -121,6 +121,9 @@ const OBITUARIES = {
class SFXPlayer {
constructor() {
this.ctx = new (window.AudioContext || window.webkitAudioContext); // come the fuck on, safari
this.volume = 1.0;
this.enabled = true;
// This automatically reduces volume when a lot of sound effects are playing at once
this.compressor_node = this.ctx.createDynamicsCompressor();
this.compressor_node.threshold.value = -40;
@ -217,6 +220,9 @@ class SFXPlayer {
}
play_once(name, cell = null) {
if (! this.enabled)
return;
let data = this.sounds[name];
if (! data) {
// Hasn't loaded yet, not much we can do
@ -240,25 +246,23 @@ class SFXPlayer {
let node = this.ctx.createBufferSource();
node.buffer = data.audiobuf;
let volume = this.volume;
if (cell && this.player_x !== null) {
// Reduce the volume for further-away sounds
let dx = cell.x - this.player_x;
let dy = cell.y - this.player_y;
let dist = Math.sqrt(dx*dx + dy*dy);
let gain = this.ctx.createGain();
// x/(x + a) is a common and delightful way to get an easy asymptote and output between
// 0 and 1. Here, the result is above 2/3 for almost everything on screen; drops down
// to 1/3 for things 20 tiles away (which is, roughly, the periphery when standing in
// the center of a CC1 map), and bottoms out at 1/15 for standing in one corner of a
// CC2 map of max size and hearing something on the far opposite corner.
gain.gain.value = 1 - dist / (dist + 10);
volume *= 1 - dist / (dist + 10);
}
let gain = this.ctx.createGain();
gain.gain.value = volume;
node.connect(gain);
gain.connect(this.compressor_node);
}
else {
// Play at full volume
node.connect(this.compressor_node);
}
node.start(this.ctx.currentTime);
}
@ -292,8 +296,6 @@ class Player extends PrimaryView {
this.scale = 1;
this.play_speed = 1;
this.root.style.setProperty('--tile-width', `${this.conductor.tileset.size_x}px`);
this.root.style.setProperty('--tile-height', `${this.conductor.tileset.size_y}px`);
this.level_el = this.root.querySelector('.level');
this.overlay_message_el = this.root.querySelector('.overlay-message');
this.hint_el = this.root.querySelector('.player-hint');
@ -305,32 +307,6 @@ class Player extends PrimaryView {
this.music_el = this.root.querySelector('#player-music');
this.music_audio_el = this.music_el.querySelector('audio');
this.music_index = null;
let volume_el = this.music_el.querySelector('#player-music-volume');
this.music_audio_el.volume = this.conductor.options.music_volume ?? 1.0;
volume_el.value = this.music_audio_el.volume;
volume_el.addEventListener('input', ev => {
let volume = ev.target.value;
this.conductor.options.music_volume = volume;
this.conductor.save_stash();
this.music_audio_el.volume = ev.target.value;
});
let enabled_el = this.music_el.querySelector('#player-music-unmute');
this.music_enabled = this.conductor.options.music_enabled ?? true;
enabled_el.checked = this.music_enabled;
enabled_el.addEventListener('change', ev => {
this.music_enabled = ev.target.checked;
this.conductor.options.music_enabled = this.music_enabled;
this.conductor.save_stash();
// TODO also hide most of the music stuff
if (this.music_enabled) {
this.update_music_playback_state();
}
else {
this.music_audio_el.pause();
}
});
// 0: normal realtime mode
// 1: turn-based mode, at the start of a tic
@ -421,7 +397,9 @@ class Player extends PrimaryView {
});
this.use_interpolation = true;
this.renderer = new CanvasRenderer(this.conductor.tileset);
// Default to the LL tileset for safety, but change when we load a level
this.renderer = new CanvasRenderer(this.conductor.tilesets['ll']);
this._loaded_tileset = false;
this.level_el.append(this.renderer.canvas);
this.renderer.canvas.addEventListener('auxclick', ev => {
if (ev.button !== 1)
@ -436,14 +414,11 @@ class Player extends PrimaryView {
// actually "happens"
});
// Populate inventory
this._inventory_tiles = {};
let floor_tile = this.render_inventory_tile('floor');
this.inventory_el.style.backgroundImage = `url(${floor_tile})`;
// Populate a skeleton inventory
this.inventory_key_nodes = {};
this.inventory_tool_nodes = [];
for (let key of ['key_red', 'key_blue', 'key_yellow', 'key_green']) {
let img = mk('img', {src: this.render_inventory_tile(key)});
let img = mk('img'); // drawn in update_tileset
let count = mk('span.-count');
let root = mk('span', img, count);
this.inventory_key_nodes[key] = {root, img, count};
@ -678,12 +653,16 @@ class Player extends PrimaryView {
}
setup() {
if (this._start_in_debug_mode) {
this.setup_debug();
}
}
// Link up the debug panel and enable debug features
// (note that this might be called /before/ setup!)
setup_debug() {
document.body.classList.add('--debug');
document.querySelector('#header-icon').src = 'icon-debug.png';
let debug_el = this.root.querySelector('#player-debug');
this.debug = {
enabled: true,
@ -1003,6 +982,40 @@ class Player extends PrimaryView {
}
}
reload_options(options) {
this.music_audio_el.volume = options.music_volume ?? 1.0;
// TODO hide music info when disabled?
this.music_enabled = options.music_enabled ?? true;
this.sfx_player.volume = options.sound_volume ?? 1.0;
this.sfx_player.enabled = options.sound_enabled ?? true;
if (this.level) {
this.update_tileset();
this._redraw();
}
}
update_tileset() {
if (! this.level)
return;
let tileset = this.conductor.choose_tileset_for_level(this.level.stored_level);
if (tileset === this.renderer.tileset && this._loaded_tileset)
return;
this._loaded_tileset = true;
this.renderer.set_tileset(tileset);
this.root.style.setProperty('--tile-width', `${tileset.size_x}px`);
this.root.style.setProperty('--tile-height', `${tileset.size_y}px`);
this._inventory_tiles = {}; // flush the render_inventory_tile cache
let floor_tile = this.render_inventory_tile('floor');
this.inventory_el.style.backgroundImage = `url(${floor_tile})`;
for (let [key, nodes] of Object.entries(this.inventory_key_nodes)) {
nodes.img.src = this.render_inventory_tile(key);
}
}
load_game(stored_game) {
}
@ -1018,6 +1031,7 @@ class Player extends PrimaryView {
this.level = new Level(stored_level, this.conductor.compat);
this.level.sfx = this.sfx_player;
this.update_tileset();
this.renderer.set_level(this.level);
this.update_viewport_size();
// TODO base this on a hash of the UA + some identifier for the pack + the level index. StoredLevel doesn't know its own index atm...
@ -1741,12 +1755,12 @@ class Player extends PrimaryView {
// but note that we have 2x4 extra tiles for the inventory depending on layout
let base_x, base_y;
if (is_portrait) {
base_x = this.conductor.tileset.size_x * this.renderer.viewport_size_x;
base_y = this.conductor.tileset.size_y * (this.renderer.viewport_size_y + 2);
base_x = this.renderer.tileset.size_x * this.renderer.viewport_size_x;
base_y = this.renderer.tileset.size_y * (this.renderer.viewport_size_y + 2);
}
else {
base_x = this.conductor.tileset.size_x * (this.renderer.viewport_size_x + 4);
base_y = this.conductor.tileset.size_y * this.renderer.viewport_size_y;
base_x = this.renderer.tileset.size_x * (this.renderer.viewport_size_x + 4);
base_y = this.renderer.tileset.size_y * this.renderer.viewport_size_y;
}
// Unfortunately, finding the available space is a little tricky. The container is a CSS
// flex item, and the flex cell doesn't correspond directly to any element, so there's no
@ -2196,6 +2210,16 @@ class Splash extends PrimaryView {
// -------------------------------------------------------------------------------------------------
// Central controller, thingy
const BUILTIN_TILESETS = {
lexy: {
name: "Lexy's Labyrinth",
src: 'tileset-lexy.png',
layout: 'lexy',
tile_width: 32,
tile_height: 32,
},
};
// Report an error when a level fails to load
class LevelErrorOverlay extends DialogOverlay {
constructor(conductor, error) {
@ -2220,99 +2244,359 @@ class LevelErrorOverlay extends DialogOverlay {
}
}
// About dialog
const ABOUT_HTML = `
<p>Welcome to Lexy's Labyrinth, an exciting old-school tile-based puzzle adventure that is compatible with — but legally distinct from! — <a href="https://store.steampowered.com/app/346850/Chips_Challenge_1/">Chip's Challenge</a> and its long-awaited sequel <a href="https://store.steampowered.com/app/348300/Chips_Challenge_2/">Chip's Challenge 2</a>.</p>
<p>This is a reimplementation from scratch of the game and uses none of its original code or assets. It aims to match the behavior of the Steam releases (sans obvious bugs), since those are now the canonical versions of the game, but compatibility settings aren't off the table.</p>
<p>The default level pack is the community-made <a href="https://wiki.bitbusters.club/Chip%27s_Challenge_Level_Pack_1">Chip's Challenge Level Pack 1</a>, which I had no hand in whatsoever; please follow the link for full attribution.</p>
<p>Source code is on <a href="https://github.com/eevee/lexys-labyrinth">GitHub</a>.</p>
<p>Special thanks to:</p>
<ul class="normal-list">
<li>The lovingly maintained <a href="https://bitbusters.club/">Bit Busters Club</a>, its incredibly detailed <a href="https://wiki.bitbusters.club/Main_Page">wiki</a>, and its <a href="https://discord.gg/Xd4dUY9">Discord</a> full of welcoming and patient folks who've been more than happy to playtest this thing and answer all kinds of arcane questions about Chip's Challenge mechanics.</li>
<li><a href="https://tw2.bitbusters.club/">Tile World</a>, the original Chip's Challenge 1 emulator whose source code was indispensable.</li>
<li>Everyone who contributed to the soundtrack, without whom there would still only be one song.</li>
<li>Chuck Sommerville, for creating the original game!</li>
</ul>
<p>Not affiliated with, endorsed by, aided by, or done with the permission of Chuck Sommerville, Niffler Inc., or Alpha Omega Productions.</p>
`;
class AboutOverlay extends DialogOverlay {
constructor(conductor) {
super(conductor);
this.set_title("about");
this.main.innerHTML = ABOUT_HTML;
this.add_button("cool", ev => {
this.close();
});
}
}
// Options dialog
// functionality?:
// - store local levels and tilesets in localstorage? (will duplicate space but i'll be able to remember them)
// aesthetics:
// - tileset
// - animations on or off
// compat:
// - flicking
// - that cc2 hook wrapping thing
// - that cc2 thing where a brown button sends a 1-frame pulse to a wired trap
// - cc2 something about blue teleporters at 0, 0 forgetting they're looking for unwired only
// - monsters go in fire
// - rff blocks monsters
// - rff truly random
// - all manner of fucking bugs
// TODO distinguish between deliberately gameplay changes and bugs, though that's kind of an arbitrary line
const AESTHETIC_OPTIONS = [{
key: 'anim_half_speed',
label: "Animate at half speed",
default: true,
note: "CC2 plays animations at utterly ludicrous speeds and it looks very bad. This option plays them at half speed (except for explosions and splashes, which have a fixed duration), which is objectively better in every way.",
const TILESET_SLOTS = [{
ident: 'cc1',
name: "CC1",
}, {
key: 'offset_actors',
label: "Offset some actors",
default: true,
note: "Chip's Challenge typically draws everything in a grid, which looks a bit funny for tall skinny objects like... the player. And teeth. This option draws both of them raised up slightly, so they'll break the grid and add a slight 3D effect. May not work for all tilesets.",
}];
const OPTIONS_TABS = [{
name: 'aesthetic',
label: "Aesthetics",
ident: 'cc2',
name: "CC2",
}, {
ident: 'll',
name: "LL/editor",
}];
const CUSTOM_TILESET_BUCKETS = ['Custom 1', 'Custom 2', 'Custom 3'];
const CUSTOM_TILESET_PREFIX = "Lexy's Labyrinth custom tileset: ";
class OptionsOverlay extends DialogOverlay {
constructor(conductor) {
super(conductor);
this.root.classList.add('dialog-options');
this.set_title("options");
this.add_button("well alright then", ev => {
let dl = mk('dl.formgrid');
this.main.append(dl);
// Volume options
dl.append(
mk('dt', "Music volume"),
mk('dd.option-volume',
mk('label', mk('input', {name: 'music-enabled', type: 'checkbox'}), " Enabled"),
mk('input', {name: 'music-volume', type: 'range', min: 0, max: 1, step: 0.05}),
),
mk('dt', "Sound volume"),
mk('dd.option-volume',
mk('label', mk('input', {name: 'sound-enabled', type: 'checkbox'}), " Enabled"),
mk('input', {name: 'sound-volume', type: 'range', min: 0, max: 1, step: 0.05}),
),
);
// Update volume live, if the player is active and was playing when this dialog was opened
// (note that it won't auto-pause until open())
let player = this.conductor.player;
if (this.conductor.current === player && player.state === 'playing') {
this.original_music_volume = player.music_audio_el.volume;
this.original_sound_volume = player.sfx_player.volume;
this.resume_music_on_open = true;
// Adjust music volume in realtime
this.root.elements['music-enabled'].addEventListener('change', ev => {
if (ev.target.checked) {
player.music_audio_el.play();
}
else {
player.music_audio_el.pause();
}
});
this.root.elements['music-volume'].addEventListener('input', ev => {
player.music_audio_el.volume = parseFloat(ev.target.value);
});
// Play a sound effect after altering volume
this.root.elements['sound-enabled'].addEventListener('change', ev => {
if (ev.target.checked) {
this._play_random_sfx();
}
});
this.root.elements['sound-volume'].addEventListener('input', ev => {
player.sfx_player.volume = parseFloat(ev.target.value);
if (this.root.elements['sound-enabled'].checked) {
this._play_random_sfx();
}
});
}
// Tileset options
this.tileset_els = {};
this.renderers = {};
this.available_tilesets = {};
for (let [ident, def] of Object.entries(BUILTIN_TILESETS)) {
let newdef = { ...def, is_builtin: true };
newdef.ident = ident;
newdef.tileset = conductor._loaded_tilesets[ident];
if (! newdef.tileset) {
let img = new Image;
// FIXME again, wait, or what?
img.src = newdef.src;
newdef.tileset = new Tileset(
img, TILESET_LAYOUTS[newdef.layout] ?? 'lexy',
newdef.tile_width, newdef.tile_height);
}
this.available_tilesets[ident] = newdef;
}
for (let bucket of CUSTOM_TILESET_BUCKETS) {
if (conductor._loaded_tilesets[bucket]) {
this.available_tilesets[bucket] = {
ident: bucket,
name: bucket,
is_already_stored: true,
tileset: conductor._loaded_tilesets[bucket],
};
}
}
for (let slot of TILESET_SLOTS) {
let renderer = new CanvasRenderer(conductor.tilesets[slot.ident], 1);
this.renderers[slot.ident] = renderer;
let select = mk('select', {name: `tileset-${slot.ident}`});
for (let [ident, def] of Object.entries(this.available_tilesets)) {
if (def.tileset.layout['#supported-versions'].has(slot.ident)) {
select.append(mk('option', {value: ident}, def.name));
}
}
select.value = conductor.options.tilesets[slot.ident] ?? 'lexy';
select.addEventListener('change', ev => {
this.update_selected_tileset(slot.ident);
});
let el = mk('dd.option-tileset', select);
this.tileset_els[slot.ident] = el;
this.update_selected_tileset(slot.ident);
dl.append(
mk('dt', `${slot.name} tileset`),
el,
);
}
this.custom_tileset_counter = 1;
dl.append(
mk('dd',
"You can also load a custom tileset, which will be saved in browser storage.",
mk('br'),
"Supports the Tile World static layout and the Steam layout.",
mk('br'),
"(Steam tilesets can be found in ", mk('code', "data/bmp"), " within the game's local files).",
mk('br'),
mk('input', {type: 'file', name: 'custom-tileset'}),
mk('div.option-load-tileset',
),
),
);
// Load current values
this.root.elements['music-volume'].value = this.conductor.options.music_volume ?? 1.0;
this.root.elements['music-enabled'].checked = this.conductor.options.music_enabled ?? true;
this.root.elements['sound-volume'].value = this.conductor.options.sound_volume ?? 1.0;
this.root.elements['sound-enabled'].checked = this.conductor.options.sound_enabled ?? true;
this.root.elements['custom-tileset'].addEventListener('change', ev => {
this._load_custom_tileset(ev.target.files[0]);
});
this.add_button("save", ev => {
let options = this.conductor.options;
options.music_volume = parseFloat(this.root.elements['music-volume'].value);
options.music_enabled = this.root.elements['music-enabled'].checked;
options.sound_volume = parseFloat(this.root.elements['sound-volume'].value);
options.sound_enabled = this.root.elements['sound-enabled'].checked;
// Tileset stuff: slightly more complicated. Save custom ones to localStorage as data
// URIs, and /delete/ any custom ones we're not using any more, both of which require
// knowing which slots we're already using first
let buckets_in_use = new Set;
let chosen_tilesets = {};
for (let slot of TILESET_SLOTS) {
let tileset_ident = this.root.elements[`tileset-${slot.ident}`].value;
let tilesetdef = this.available_tilesets[tileset_ident];
if (! tilesetdef) {
tilesetdef = this.available_tilesets['lexy'];
}
chosen_tilesets[slot.ident] = tilesetdef;
if (tilesetdef.is_already_stored) {
buckets_in_use.add(tilesetdef.ident);
}
}
for (let [slot_ident, tilesetdef] of Object.entries(chosen_tilesets)) {
if (tilesetdef.is_builtin || tilesetdef.is_already_stored) {
options.tilesets[slot_ident] = tilesetdef.ident;
}
else {
// This is a newly uploaded one
let data_uri = tilesetdef.data_uri ?? tilesetdef.canvas.toDataURL('image/png');
let storage_bucket = CUSTOM_TILESET_BUCKETS.find(
bucket => ! buckets_in_use.has(bucket));
if (! storage_bucket) {
console.error("Somehow ran out of storage buckets, this should be impossible??");
continue;
}
buckets_in_use.add(storage_bucket);
save_json_to_storage(CUSTOM_TILESET_PREFIX + storage_bucket, {
src: data_uri,
name: storage_bucket,
layout: tilesetdef.layout,
tile_width: tilesetdef.tile_width,
tile_height: tilesetdef.tile_height,
});
options.tilesets[slot_ident] = storage_bucket;
}
// Update the conductor's loaded tilesets
this.conductor.tilesets[slot_ident] = tilesetdef.tileset;
this.conductor._loaded_tilesets[tilesetdef.ident] = tilesetdef.tileset;
}
// Delete old custom set URIs
for (let bucket of CUSTOM_TILESET_BUCKETS) {
if (! buckets_in_use.has(bucket)) {
window.localStorage.removeItem(CUSTOM_TILESET_PREFIX + bucket);
}
}
this.conductor.save_stash();
this.conductor.reload_all_options();
this.close();
});
this.main.append(mk('p', "Sorry! This stuff doesn't actually work yet."));
let tab_strip = mk('nav.tabstrip');
this.main.append(tab_strip);
this.tab_links = {};
this.tab_blocks = {};
this.current_tab = 'aesthetic';
for (let tabdef of OPTIONS_TABS) {
let link = mk('a', {href: 'javascript:', 'data-tab': tabdef.name}, tabdef.label);
link.addEventListener('click', ev => {
ev.preventDefault();
this.switch_tab(ev.target.getAttribute('data-tab'));
this.add_button("forget it", ev => {
// Restore the player's music volume just in case
if (this.original_music_volume !== undefined) {
this.conductor.player.music_audio_el.volume = this.original_music_volume;
this.conductor.player.sfx_player.volume = this.original_sound_volume;
}
this.close();
});
tab_strip.append(link);
this.tab_links[tabdef.name] = link;
let block = mk('section.tabblock');
this.main.append(block);
this.tab_blocks[tabdef.name] = block;
}
if (tabdef.name === this.current_tab) {
link.classList.add('--selected');
block.classList.add('--selected');
open() {
super.open();
// Forcibly start the music player, since opening this dialog auto-pauses the game, and
// anyway it's hard to gauge music volume if it's not playing
if (this.resume_music_on_open && this.conductor.player.music_enabled) {
this.conductor.player.music_audio_el.play();
}
}
// Aesthetic tab
this._add_options(this.tab_blocks['aesthetic'], AESTHETIC_OPTIONS);
_play_random_sfx() {
let sfx = this.conductor.player.sfx_player;
// Temporarily force enable it
let was_enabled = sfx.enabled;
sfx.enabled = true;
sfx.play_once(util.random_choice([
'blocked', 'door', 'get-chip', 'get-key', 'get-tool', 'socket', 'splash',
]));
sfx.enabled = was_enabled;
}
async _load_custom_tileset(file) {
// This is dumb and roundabout, but such is the web
let reader = new FileReader;
let reader_loaded = util.promise_event(reader, 'load', 'error');
reader.readAsDataURL(file);
await reader_loaded;
let img = mk('img');
img.src = reader.result;
await img.decode();
// Now we've got an <img> ready to go, and we can guess its layout based on its aspect
// ratio, hopefully. Note that the LL layout is currently in progress so we can't
// really detect that, but there can't really be alternatives to it either
let result_el = this.root.querySelector('.option-load-tileset');
let layout;
// Note: Animated Tile World has a 1px gap between rows to indicate where animations
// start and also a single extra 1px column on the left, which I super don't support :(
for (let try_layout of [CC2_TILESET_LAYOUT, TILE_WORLD_TILESET_LAYOUT]) {
let [w, h] = try_layout['#dimensions'];
// XXX this assumes square tiles, but i have written mountains of code that doesn't!
if (img.naturalWidth * h === img.naturalHeight * w) {
layout = try_layout;
break;
}
}
if (! layout) {
result_el.textContent = '';
result_el.append("This doesn't look like a tileset layout I understand, sorry!");
return;
}
// Load into a canvas and erase the transparent color
let canvas = mk('canvas', {width: img.naturalWidth, height: img.naturalHeight});
let ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
let trans = layout['#transparent-color'];
let image_data = ctx.getImageData(0, 0, canvas.width, canvas.height);
let px = image_data.data;
for (let i = 0; i < canvas.width * canvas.height * 4; i += 4) {
if (px[i] === trans[0] && px[i + 1] === trans[1] &&
px[i + 2] === trans[2] && px[i + 3] === trans[3])
{
px[i] = 0;
px[i + 1] = 0;
px[i + 2] = 0;
px[i + 3] = 0;
}
}
ctx.putImageData(image_data, 0, 0);
let [w, h] = layout['#dimensions'];
let tw = Math.floor(img.naturalWidth / w);
let th = Math.floor(img.naturalHeight / h);
let tileset = new Tileset(canvas, layout, tw, th);
let renderer = new CanvasRenderer(tileset, 1);
result_el.textContent = '';
result_el.append(
`This looks like a ${layout['#name']} tileset with ${tw}×${th} tiles.`,
mk('br'),
renderer.create_tile_type_canvas('player'),
renderer.create_tile_type_canvas('chip'),
renderer.create_tile_type_canvas('exit'),
mk('br'),
);
let tileset_ident = `custom-${this.custom_tileset_counter}`;
let tileset_name = `New custom ${this.custom_tileset_counter}`;
this.custom_tileset_counter += 1;
for (let slot of TILESET_SLOTS) {
if (! layout['#supported-versions'].has(slot.ident))
continue;
let dd = this.tileset_els[slot.ident];
let select = dd.querySelector('select');
select.append(mk('option', {value: tileset_ident}, tileset_name));
let button = util.mk_button(`Use for ${slot.name}`, ev => {
select.value = tileset_ident;
this.update_selected_tileset(slot.ident);
});
result_el.append(button);
}
this.available_tilesets[tileset_ident] = {
ident: tileset_ident,
name: tileset_name,
canvas: canvas,
tileset: tileset,
layout: layout['#ident'],
tile_width: tw,
tile_height: th,
};
}
update_selected_tileset(slot_ident) {
let dd = this.tileset_els[slot_ident];
let select = dd.querySelector('select');
let tileset_ident = select.value;
let renderer = this.renderers[slot_ident];
renderer.tileset = this.available_tilesets[tileset_ident].tileset;
for (let canvas of dd.querySelectorAll('canvas')) {
canvas.remove();
}
dd.append(
// TODO allow me to draw an arbitrary tile to an arbitrary point on a given canvas!
renderer.create_tile_type_canvas('player'),
renderer.create_tile_type_canvas('chip'),
renderer.create_tile_type_canvas('exit'),
);
}
_add_options(root, options) {
@ -2335,15 +2619,11 @@ class OptionsOverlay extends DialogOverlay {
}
}
switch_tab(tab) {
if (this.current_tab === tab)
return;
close() {
// Ensure the player's music is set back how we left it
this.conductor.player.update_music_playback_state();
this.tab_links[this.current_tab].classList.remove('--selected');
this.tab_blocks[this.current_tab].classList.remove('--selected');
this.current_tab = tab;
this.tab_links[this.current_tab].classList.add('--selected');
this.tab_blocks[this.current_tab].classList.add('--selected');
super.close();
}
}
const COMPAT_RULESETS = [
@ -2589,12 +2869,11 @@ class PackTestDialog extends DialogOverlay {
this.close();
});
this.renderer = new CanvasRenderer(this.conductor.tileset, 16);
this.renderer = new CanvasRenderer(this.conductor.tilesets['ll'], 16);
}
async run(handle) {
let pack = this.conductor.stored_game;
let tileset = this.conductor.tileset;
let dummy_sfx = {
set_player_position() {},
play() {},
@ -2660,6 +2939,8 @@ class PackTestDialog extends DialogOverlay {
}
if (include_canvas && level) {
try {
let tileset = this.conductor.choose_tileset_for_level(level.stored_level);
this.renderer.set_tileset(tileset);
let canvas = mk('canvas', {
width: Math.min(this.renderer.canvas.width, level.size_x * tileset.size_x),
height: Math.min(this.renderer.canvas.height, level.size_y * tileset.size_y),
@ -2924,9 +3205,8 @@ const STORAGE_PACK_PREFIX = "Lexy's Labyrinth: ";
// - 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) {
constructor() {
this.stored_game = null;
this.tileset = tileset;
this.stash = JSON.parse(window.localStorage.getItem(STORAGE_KEY));
// TODO more robust way to ensure this is shaped how i expect?
@ -2936,12 +3216,16 @@ class Conductor {
if (! this.stash.options) {
this.stash.options = {};
}
if (! this.stash.options.tilesets) {
this.stash.options.tilesets = {};
}
if (! this.stash.compat) {
this.stash.compat = 'lexy';
}
if (! this.stash.packs) {
this.stash.packs = {};
}
// Handy aliases
this.options = this.stash.options;
this.compat = {};
@ -2959,9 +3243,40 @@ class Conductor {
}
this.set_compat(this._compat_ruleset, this.compat);
this._loaded_tilesets = {}; // tileset ident => tileset
this.tilesets = {}; // slot (game type) => tileset
for (let slot of TILESET_SLOTS) {
let tileset_ident = this.options.tilesets[slot.ident] ?? 'lexy';
let tilesetdef;
if (BUILTIN_TILESETS[tileset_ident]) {
tilesetdef = BUILTIN_TILESETS[tileset_ident];
}
else {
tilesetdef = load_json_from_storage(CUSTOM_TILESET_PREFIX + tileset_ident);
if (! tilesetdef) {
tileset_ident = 'lexy';
tilesetdef = BUILTIN_TILESETS['lexy'];
}
}
if (this._loaded_tilesets[tileset_ident]) {
this.tilesets[slot.ident] = this._loaded_tilesets[tileset_ident];
continue;
}
let layout = TILESET_LAYOUTS[tilesetdef.layout] ?? 'lexy';
let img = new Image;
img.src = tilesetdef.src;
// FIXME wait on the img to decode (well i can't in a constructor), or what?
let tileset = new Tileset(img, layout, tilesetdef.tile_width, tilesetdef.tile_height);
this.tilesets[slot.ident] = tileset;
this._loaded_tilesets[tileset_ident] = tileset;
}
this.splash = new Splash(this);
this.editor = new Editor(this);
this.player = new Player(this);
this.reload_all_options();
this.loaded_in_editor = false;
this.loaded_in_player = false;
@ -3037,7 +3352,6 @@ class Conductor {
"but disable all saving of scores until you reload the page!",
() => {
this.player.setup_debug();
ev.target.src = 'icon-debug.png';
},
).open();
}
@ -3083,6 +3397,22 @@ class Conductor {
document.body.setAttribute('data-mode', 'player');
}
reload_all_options() {
this.splash.reload_options(this.options);
this.player.reload_options(this.options);
this.editor.reload_options(this.options);
}
choose_tileset_for_level(stored_level) {
if (stored_level.format === 'ccl') {
return this.tilesets['cc1'];
}
if (stored_level.uses_ll_extensions === false) {
return this.tilesets['cc2'];
}
return this.tilesets['ll'];
}
load_game(stored_game, identifier = null) {
this.stored_game = stored_game;
this._pack_test_dialog = null;
@ -3336,44 +3666,12 @@ async function main() {
let local = !! location.host.match(/localhost/);
let query = new URLSearchParams(location.search);
// Pick a tileset
// These alternative ones only work locally for me for testing purposes, since they're part of
// the commercial games!
let tilesheet = new Image();
let tilesize;
let tilelayout;
if (local && query.get('tileset') === 'ms') {
tilesheet.src = 'tileset-ms.png';
tilesize = 32;
tilelayout = CC2_TILESET_LAYOUT;
}
else if (local && query.get('tileset') === 'steam') {
tilesheet.src = 'tileset-steam.png';
tilesize = 32;
tilelayout = CC2_TILESET_LAYOUT;
}
else if (query.get('tileset') === 'tworld') {
tilesheet.src = 'tileset-tworld.png';
tilesize = 48;
tilelayout = TILE_WORLD_TILESET_LAYOUT;
}
else {
// Default to Lexy's Labyrinth tileset
tilesheet.src = 'tileset-lexy.png';
tilesize = 32;
tilelayout = LL_TILESET_LAYOUT;
}
// TODO would be fabulous to not wait on this before creating conductor
await tilesheet.decode();
let tileset = new Tileset(tilesheet, tilelayout, tilesize, tilesize);
let conductor = new Conductor(tileset);
let conductor = new Conductor();
window._conductor = conductor;
// Allow putting us in debug mode automatically if we're in development
if (local && query.has('debug')) {
conductor.player.setup_debug();
document.querySelector('#header-icon').src = 'icon-debug.png';
conductor.player._start_in_debug_mode = true;
}
// Pick a level (set)

View File

@ -47,6 +47,11 @@ export class CanvasRenderer {
this.viewport_dirty = true;
}
set_tileset(tileset) {
this.tileset = tileset;
this.viewport_dirty = true;
}
cell_coords_from_event(ev) {
let rect = this.canvas.getBoundingClientRect();
let scale_x = rect.width / this.canvas.width;

View File

@ -1,6 +1,12 @@
import { DIRECTIONS } from './defs.js';
import TILE_TYPES from './tiletypes.js';
const _omit_custom_lexy_vfx = {
teleport_flash: null,
transmogrify_flash: null,
puff: null,
};
// TODO move the remaining stuff (arrows, overlay i think, probably force floor thing) into specials
// TODO more explicitly define animations, give them a speed! maybe fold directions into it
// TODO relatedly, the push animations are sometimes glitchy depending on when you start?
@ -10,6 +16,11 @@ import TILE_TYPES from './tiletypes.js';
// blur with cc2 blobs/walkers, also makes a lot of signatures cleaner (make sure not slower)
// TODO monsters should only animate while moving? (not actually how cc2 works...)
export const CC2_TILESET_LAYOUT = {
'#ident': 'cc2',
'#name': "Chip's Challenge 2",
'#dimensions': [16, 32],
'#transparent-color': [0x52, 0xce, 0x6b, 0xff],
'#supported-versions': new Set(['cc1', 'cc2']),
'#wire-width': 1/16,
door_red: [0, 1],
@ -624,10 +635,16 @@ export const CC2_TILESET_LAYOUT = {
no_player1_sign: [6, 31],
hook: [7, 31],
// misc other stuff
..._omit_custom_lexy_vfx,
};
// XXX need to specify that you can't use this for cc2 levels, somehow
export const TILE_WORLD_TILESET_LAYOUT = {
'#ident': 'tw-static',
'#name': "Tile World (static)",
'#dimensions': [7, 16],
'#transparent-color': [0xff, 0x00, 0xff, 0xff],
'#supported-versions': new Set(['cc1']),
floor: [0, 0],
wall: [0, 1],
chip: [0, 2],
@ -795,9 +812,16 @@ export const TILE_WORLD_TILESET_LAYOUT = {
exploded: [3, 6],
failed: [3, 7],
},
..._omit_custom_lexy_vfx,
};
export const LL_TILESET_LAYOUT = Object.assign({}, CC2_TILESET_LAYOUT, {
'#ident': 'lexy',
'#name': "Lexy's Labyrinth",
// TODO dimensions, when this is stable?? might one day rearrange, leave some extra space
'#supported-versions': new Set(['cc1', 'cc2', 'll']),
// Completed teeth sprites
teeth: Object.assign({}, CC2_TILESET_LAYOUT.teeth, {
north: [[1, 32], [0, 32], [1, 32], [2, 32]],
@ -900,7 +924,6 @@ export const LL_TILESET_LAYOUT = Object.assign({}, CC2_TILESET_LAYOUT, {
},
chip: [[11, 3], [0, 39], [1, 39], [0, 39]],
green_chip: [[9, 3], [2, 39], [3, 39], [2, 39]],
// FIXME make these work with a stock tileset
player1_exit: [[8, 38], [9, 38], [10, 38], [11, 38]],
player2_exit: [[12, 38], [13, 38], [14, 38], [15, 38]],
puff: [[4, 39], [5, 39], [6, 39], [7, 39]],
@ -917,8 +940,15 @@ export const LL_TILESET_LAYOUT = Object.assign({}, CC2_TILESET_LAYOUT, {
sand: [10, 41],
});
export const TILESET_LAYOUTS = {
'tw-static': TILE_WORLD_TILESET_LAYOUT,
cc2: CC2_TILESET_LAYOUT,
lexy: LL_TILESET_LAYOUT,
};
export class Tileset {
constructor(image, layout, size_x, size_y) {
// XXX curiously, i note that .image is never used within this class
this.image = image;
this.layout = layout;
this.size_x = size_x;
@ -1256,7 +1286,13 @@ export class Tileset {
// without it you'll get defaults.
draw_type(name, tile, tic, perception, blit) {
let drawspec = this.layout[name];
if (drawspec === null) {
// This is explicitly never drawn (used for extra visual-only frills that don't exist in
// some tilesets)
return;
}
if (! drawspec) {
// This is just missing
console.error(`Don't know how to draw tile type ${name}!`);
return;
}

View File

@ -264,7 +264,7 @@ const TILE_TYPES = {
blocks_collision: COLLISION.block_cc1 | (COLLISION.monster_solid & ~COLLISION.rover),
on_ready(me, level) {
if (! level.compat.no_auto_convert_ccl_popwalls &&
level.stored_level.use_ccl_compat &&
level.stored_level.format === 'ccl' &&
me.cell.get_actor())
{
// Fix blocks and other actors on top of popwalls by turning them into double
@ -305,7 +305,7 @@ const TILE_TYPES = {
blocks_collision: COLLISION.all_but_ghost,
on_ready(me, level) {
if (! level.compat.no_auto_convert_ccl_blue_walls &&
level.stored_level.use_ccl_compat &&
level.stored_level.format === 'ccl' &&
me.cell.get_actor())
{
// Blocks can be pushed off of blue walls in TW Lynx, which only works due to a tiny

View File

@ -385,37 +385,16 @@ img.compat-icon,
/* Options dialog */
.dialog-options {
height: 60%;
width: 75%;
}
.dialog-options > section {
flex: 1;
}
nav.tabstrip {
.option-volume {
display: flex;
border-bottom: 1px solid #d0d0d0;
gap: 1em;
}
nav.tabstrip > a {
margin: 0 0.5em;
padding: 0.5em 1em;
color: inherit;
text-decoration: none;
border-top-left-radius: 0.5em;
border-top-right-radius: 0.5em;
.option-volume > input[type=range] {
flex: auto;
}
nav.tabstrip > a:hover {
background: #e8e8e8;
}
nav.tabstrip > a.--selected {
background: #d0d0d0;
}
.dialog section.tabblock {
display: none;
overflow: auto;
margin: 0.25em 0.5em;
}
.dialog section.tabblock.--selected {
display: initial;
.option-tileset canvas {
vertical-align: middle;
}
label.option {
display: flex;
@ -1170,22 +1149,10 @@ dl.score-chart .-sum {
#player-music {
grid-area: music;
display: flex;
gap: 1em;
margin: 0 1em;
text-transform: lowercase;
color: #909090;
}
#player-music #player-music-left {
/* allow me to wrap if need be */
flex: 1 0 0px;
}
#player-music #player-music-right {
text-align: right;
}
#player-music #player-music-volume {
width: 8em;
}
#player .controls {
grid-area: controls;