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> <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> <p>— an <a href="https://github.com/eevee/lexys-labyrinth">open source</a> game by <a href="https://eev.ee/">eevee</a></p>
<nav> <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> <button id="main-compat" type="button">compat mode: <output>lexy</output></button>
</nav> </nav>
</header> </header>
@ -190,13 +190,7 @@
<div class="inventory"></div> <div class="inventory"></div>
</section> </section>
<div id="player-music"> <div id="player-music">
<div id="player-music-left">
🎵 <a id="player-music-title">title</a> by <a id="player-music-author">author</a> 🎵 <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"> <audio loop preload="auto">
</div> </div>
</div> </div>

View File

@ -94,7 +94,10 @@ export class StoredLevel extends LevelInterface {
this.viewport_size = 9; this.viewport_size = 9;
this.extra_chunks = []; this.extra_chunks = [];
this.use_cc1_boots = false; 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) // 0 - deterministic (PRNG + simple convolution)
// 1 - 4 patterns (default; PRNG + rotating through 0-3) // 1 - 4 patterns (default; PRNG + rotating through 0-3)
// 2 - extra random (like deterministic, but initial seed is "actually" random) // 2 - extra random (like deterministic, but initial seed is "actually" random)

View File

@ -790,12 +790,14 @@ const TILE_ENCODING = {
0xe0: { 0xe0: {
name: 'gift_bow', name: 'gift_bow',
has_next: true, has_next: true,
is_extension: true,
}, },
0xe1: { 0xe1: {
name: 'circuit_block', name: 'circuit_block',
has_next: true, has_next: true,
modifier: modifier_wire, modifier: modifier_wire,
extra_args: [arg_direction], extra_args: [arg_direction],
is_extension: true,
}, },
}; };
const REVERSE_TILE_ENCODING = {}; const REVERSE_TILE_ENCODING = {};
@ -914,6 +916,8 @@ export function parse_level(buf, number = 1) {
} }
let level = new format_base.StoredLevel(number); 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 extra_hints = [];
let hint_tiles = []; let hint_tiles = [];
for (let [type, bytes] of read_c2m_sections(buf)) { 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; let name = spec.name;
// Make a tile template, possibly dealing with some special cases // 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) { function parse_level(bytes, number) {
let level = new format_base.StoredLevel(number); let level = new format_base.StoredLevel(number);
level.has_custom_connections = true; 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 // Map size is always fixed as 32x32 in CC1
level.size_x = 32; level.size_x = 32;
level.size_y = 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 // Superclass for the main display modes: the player, the editor, and the splash screen
export class PrimaryView { export class PrimaryView {
@ -24,6 +24,8 @@ export class PrimaryView {
this.root.setAttribute('hidden', ''); this.root.setAttribute('hidden', '');
this.active = false; this.active = false;
} }
reload_options(options) {}
} }
// Stackable modal overlay of some kind, usually a dialog // 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"); this.set_title("choose a level");
// Set up some infrastructure to lazily display level renders // 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.awaiting_renders = [];
this.observer = new IntersectionObserver((entries, observer) => { this.observer = new IntersectionObserver((entries, observer) => {
let any_new = false; 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.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); this.renderer.draw_static_region(0, 0, stored_level.size_x, stored_level.size_y);
let canvas = mk('canvas', { let canvas = mk('canvas', {
width: stored_level.size_x * this.conductor.tileset.size_x / 4, width: stored_level.size_x * this.renderer.tileset.size_x / 4,
height: stored_level.size_y * this.conductor.tileset.size_y / 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); canvas.getContext('2d').drawImage(this.renderer.canvas, 0, 0, canvas.width, canvas.height);
element.querySelector('.-preview').append(canvas); element.querySelector('.-preview').append(canvas);
@ -1051,8 +1052,8 @@ class CameraOperation extends MouseOperation {
} }
step(mx, my, gxf, gyf, gx, gy) { step(mx, my, gxf, gyf, gx, gy) {
// FIXME not right if we zoom, should use gxf // 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 dx = Math.floor((mx - this.mx0) / this.editor.renderer.tileset.size_x + 0.5);
let dy = Math.floor((my - this.my0) / this.editor.conductor.tileset.size_y + 0.5); let dy = Math.floor((my - this.my0) / this.editor.renderer.tileset.size_y + 0.5);
let stored_level = this.editor.stored_level; let stored_level = this.editor.stored_level;
if (this.mode === 'create') { if (this.mode === 'create') {
@ -2323,7 +2324,7 @@ class Selection {
console.error("Trying to float a selection that's already floating"); console.error("Trying to float a selection that's already floating");
this.floated_cells = []; this.floated_cells = [];
let tileset = this.editor.conductor.tileset; let tileset = this.editor.renderer.tileset;
let stored_level = this.editor.stored_level; let stored_level = this.editor.stored_level;
let bbox = this.rect; let bbox = this.rect;
let canvas = mk('canvas', {width: bbox.width * tileset.size_x, height: bbox.height * tileset.size_y}); 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; 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.tilesets['ll'], 32);
this.renderer.perception = 'editor'; this.renderer.perception = 'editor';
// FIXME need this in load_level which is called even if we haven't been setup yet // 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 dat from './format-dat.js';
import * as format_base from './format-base.js'; import * as format_base from './format-base.js';
import { Level } from './game.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 { Editor } from './main-editor.js';
import CanvasRenderer from './renderer-canvas.js'; import CanvasRenderer from './renderer-canvas.js';
import SOUNDTRACK from './soundtrack.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 TILE_TYPES from './tiletypes.js';
import { random_choice, mk, mk_svg, promise_event } from './util.js'; import { random_choice, mk, mk_svg, promise_event } from './util.js';
import * as util from './util.js'; import * as util from './util.js';
@ -121,6 +121,9 @@ const OBITUARIES = {
class SFXPlayer { class SFXPlayer {
constructor() { constructor() {
this.ctx = new (window.AudioContext || window.webkitAudioContext); // come the fuck on, safari 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 automatically reduces volume when a lot of sound effects are playing at once
this.compressor_node = this.ctx.createDynamicsCompressor(); this.compressor_node = this.ctx.createDynamicsCompressor();
this.compressor_node.threshold.value = -40; this.compressor_node.threshold.value = -40;
@ -217,6 +220,9 @@ class SFXPlayer {
} }
play_once(name, cell = null) { play_once(name, cell = null) {
if (! this.enabled)
return;
let data = this.sounds[name]; let data = this.sounds[name];
if (! data) { if (! data) {
// Hasn't loaded yet, not much we can do // Hasn't loaded yet, not much we can do
@ -240,25 +246,23 @@ class SFXPlayer {
let node = this.ctx.createBufferSource(); let node = this.ctx.createBufferSource();
node.buffer = data.audiobuf; node.buffer = data.audiobuf;
let volume = this.volume;
if (cell && this.player_x !== null) { if (cell && this.player_x !== null) {
// Reduce the volume for further-away sounds // Reduce the volume for further-away sounds
let dx = cell.x - this.player_x; let dx = cell.x - this.player_x;
let dy = cell.y - this.player_y; let dy = cell.y - this.player_y;
let dist = Math.sqrt(dx*dx + dy*dy); 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 // 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 // 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 // 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 // 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. // 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); node.connect(gain);
gain.connect(this.compressor_node); gain.connect(this.compressor_node);
}
else {
// Play at full volume
node.connect(this.compressor_node);
}
node.start(this.ctx.currentTime); node.start(this.ctx.currentTime);
} }
@ -292,8 +296,6 @@ class Player extends PrimaryView {
this.scale = 1; this.scale = 1;
this.play_speed = 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.level_el = this.root.querySelector('.level');
this.overlay_message_el = this.root.querySelector('.overlay-message'); this.overlay_message_el = this.root.querySelector('.overlay-message');
this.hint_el = this.root.querySelector('.player-hint'); 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_el = this.root.querySelector('#player-music');
this.music_audio_el = this.music_el.querySelector('audio'); this.music_audio_el = this.music_el.querySelector('audio');
this.music_index = null; 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 // 0: normal realtime mode
// 1: turn-based mode, at the start of a tic // 1: turn-based mode, at the start of a tic
@ -421,7 +397,9 @@ class Player extends PrimaryView {
}); });
this.use_interpolation = true; 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.level_el.append(this.renderer.canvas);
this.renderer.canvas.addEventListener('auxclick', ev => { this.renderer.canvas.addEventListener('auxclick', ev => {
if (ev.button !== 1) if (ev.button !== 1)
@ -436,14 +414,11 @@ class Player extends PrimaryView {
// actually "happens" // actually "happens"
}); });
// Populate inventory // Populate a skeleton inventory
this._inventory_tiles = {};
let floor_tile = this.render_inventory_tile('floor');
this.inventory_el.style.backgroundImage = `url(${floor_tile})`;
this.inventory_key_nodes = {}; this.inventory_key_nodes = {};
this.inventory_tool_nodes = []; this.inventory_tool_nodes = [];
for (let key of ['key_red', 'key_blue', 'key_yellow', 'key_green']) { 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 count = mk('span.-count');
let root = mk('span', img, count); let root = mk('span', img, count);
this.inventory_key_nodes[key] = {root, img, count}; this.inventory_key_nodes[key] = {root, img, count};
@ -678,12 +653,16 @@ class Player extends PrimaryView {
} }
setup() { setup() {
if (this._start_in_debug_mode) {
this.setup_debug();
}
} }
// Link up the debug panel and enable debug features // Link up the debug panel and enable debug features
// (note that this might be called /before/ setup!) // (note that this might be called /before/ setup!)
setup_debug() { setup_debug() {
document.body.classList.add('--debug'); document.body.classList.add('--debug');
document.querySelector('#header-icon').src = 'icon-debug.png';
let debug_el = this.root.querySelector('#player-debug'); let debug_el = this.root.querySelector('#player-debug');
this.debug = { this.debug = {
enabled: true, 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) { load_game(stored_game) {
} }
@ -1018,6 +1031,7 @@ class Player extends PrimaryView {
this.level = new Level(stored_level, this.conductor.compat); this.level = new Level(stored_level, this.conductor.compat);
this.level.sfx = this.sfx_player; this.level.sfx = this.sfx_player;
this.update_tileset();
this.renderer.set_level(this.level); this.renderer.set_level(this.level);
this.update_viewport_size(); 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... // 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 // but note that we have 2x4 extra tiles for the inventory depending on layout
let base_x, base_y; let base_x, base_y;
if (is_portrait) { if (is_portrait) {
base_x = this.conductor.tileset.size_x * this.renderer.viewport_size_x; base_x = this.renderer.tileset.size_x * this.renderer.viewport_size_x;
base_y = this.conductor.tileset.size_y * (this.renderer.viewport_size_y + 2); base_y = this.renderer.tileset.size_y * (this.renderer.viewport_size_y + 2);
} }
else { else {
base_x = this.conductor.tileset.size_x * (this.renderer.viewport_size_x + 4); base_x = this.renderer.tileset.size_x * (this.renderer.viewport_size_x + 4);
base_y = this.conductor.tileset.size_y * this.renderer.viewport_size_y; 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 // 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 // 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 // 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 // Report an error when a level fails to load
class LevelErrorOverlay extends DialogOverlay { class LevelErrorOverlay extends DialogOverlay {
constructor(conductor, error) { 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 // Options dialog
// functionality?: const TILESET_SLOTS = [{
// - store local levels and tilesets in localstorage? (will duplicate space but i'll be able to remember them) ident: 'cc1',
// aesthetics: name: "CC1",
// - 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.",
}, { }, {
key: 'offset_actors', ident: 'cc2',
label: "Offset some actors", name: "CC2",
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.", ident: 'll',
}]; name: "LL/editor",
const OPTIONS_TABS = [{
name: 'aesthetic',
label: "Aesthetics",
}]; }];
const CUSTOM_TILESET_BUCKETS = ['Custom 1', 'Custom 2', 'Custom 3'];
const CUSTOM_TILESET_PREFIX = "Lexy's Labyrinth custom tileset: ";
class OptionsOverlay extends DialogOverlay { class OptionsOverlay extends DialogOverlay {
constructor(conductor) { constructor(conductor) {
super(conductor); super(conductor);
this.root.classList.add('dialog-options'); this.root.classList.add('dialog-options');
this.set_title("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.close();
}); });
this.add_button("forget it", ev => {
this.main.append(mk('p', "Sorry! This stuff doesn't actually work yet.")); // Restore the player's music volume just in case
if (this.original_music_volume !== undefined) {
let tab_strip = mk('nav.tabstrip'); this.conductor.player.music_audio_el.volume = this.original_music_volume;
this.main.append(tab_strip); this.conductor.player.sfx_player.volume = this.original_sound_volume;
this.tab_links = {}; }
this.tab_blocks = {}; this.close();
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'));
}); });
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) { open() {
link.classList.add('--selected'); super.open();
block.classList.add('--selected');
// 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 _play_random_sfx() {
this._add_options(this.tab_blocks['aesthetic'], AESTHETIC_OPTIONS); 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) { _add_options(root, options) {
@ -2335,15 +2619,11 @@ class OptionsOverlay extends DialogOverlay {
} }
} }
switch_tab(tab) { close() {
if (this.current_tab === tab) // Ensure the player's music is set back how we left it
return; this.conductor.player.update_music_playback_state();
this.tab_links[this.current_tab].classList.remove('--selected'); super.close();
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');
} }
} }
const COMPAT_RULESETS = [ const COMPAT_RULESETS = [
@ -2589,12 +2869,11 @@ class PackTestDialog extends DialogOverlay {
this.close(); this.close();
}); });
this.renderer = new CanvasRenderer(this.conductor.tileset, 16); this.renderer = new CanvasRenderer(this.conductor.tilesets['ll'], 16);
} }
async run(handle) { async run(handle) {
let pack = this.conductor.stored_game; let pack = this.conductor.stored_game;
let tileset = this.conductor.tileset;
let dummy_sfx = { let dummy_sfx = {
set_player_position() {}, set_player_position() {},
play() {}, play() {},
@ -2660,6 +2939,8 @@ class PackTestDialog extends DialogOverlay {
} }
if (include_canvas && level) { if (include_canvas && level) {
try { try {
let tileset = this.conductor.choose_tileset_for_level(level.stored_level);
this.renderer.set_tileset(tileset);
let canvas = mk('canvas', { let canvas = mk('canvas', {
width: Math.min(this.renderer.canvas.width, level.size_x * tileset.size_x), 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), 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 // - list of the levels they own and basic metadata like name
// Stored individual levels: given dummy names, all indexed on their own // Stored individual levels: given dummy names, all indexed on their own
class Conductor { class Conductor {
constructor(tileset) { constructor() {
this.stored_game = null; this.stored_game = null;
this.tileset = tileset;
this.stash = JSON.parse(window.localStorage.getItem(STORAGE_KEY)); this.stash = JSON.parse(window.localStorage.getItem(STORAGE_KEY));
// TODO more robust way to ensure this is shaped how i expect? // TODO more robust way to ensure this is shaped how i expect?
@ -2936,12 +3216,16 @@ class Conductor {
if (! this.stash.options) { if (! this.stash.options) {
this.stash.options = {}; this.stash.options = {};
} }
if (! this.stash.options.tilesets) {
this.stash.options.tilesets = {};
}
if (! this.stash.compat) { if (! this.stash.compat) {
this.stash.compat = 'lexy'; this.stash.compat = 'lexy';
} }
if (! this.stash.packs) { if (! this.stash.packs) {
this.stash.packs = {}; this.stash.packs = {};
} }
// Handy aliases // Handy aliases
this.options = this.stash.options; this.options = this.stash.options;
this.compat = {}; this.compat = {};
@ -2959,9 +3243,40 @@ class Conductor {
} }
this.set_compat(this._compat_ruleset, this.compat); 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.splash = new Splash(this);
this.editor = new Editor(this); this.editor = new Editor(this);
this.player = new Player(this); this.player = new Player(this);
this.reload_all_options();
this.loaded_in_editor = false; this.loaded_in_editor = false;
this.loaded_in_player = false; this.loaded_in_player = false;
@ -3037,7 +3352,6 @@ class Conductor {
"but disable all saving of scores until you reload the page!", "but disable all saving of scores until you reload the page!",
() => { () => {
this.player.setup_debug(); this.player.setup_debug();
ev.target.src = 'icon-debug.png';
}, },
).open(); ).open();
} }
@ -3083,6 +3397,22 @@ class Conductor {
document.body.setAttribute('data-mode', 'player'); 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) { load_game(stored_game, identifier = null) {
this.stored_game = stored_game; this.stored_game = stored_game;
this._pack_test_dialog = null; this._pack_test_dialog = null;
@ -3336,44 +3666,12 @@ async function main() {
let local = !! location.host.match(/localhost/); let local = !! location.host.match(/localhost/);
let query = new URLSearchParams(location.search); let query = new URLSearchParams(location.search);
// Pick a tileset let conductor = new Conductor();
// 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);
window._conductor = conductor; window._conductor = conductor;
// Allow putting us in debug mode automatically if we're in development // Allow putting us in debug mode automatically if we're in development
if (local && query.has('debug')) { if (local && query.has('debug')) {
conductor.player.setup_debug(); conductor.player._start_in_debug_mode = true;
document.querySelector('#header-icon').src = 'icon-debug.png';
} }
// Pick a level (set) // Pick a level (set)

View File

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

View File

@ -1,6 +1,12 @@
import { DIRECTIONS } from './defs.js'; import { DIRECTIONS } from './defs.js';
import TILE_TYPES from './tiletypes.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 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 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? // 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) // 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...) // TODO monsters should only animate while moving? (not actually how cc2 works...)
export const CC2_TILESET_LAYOUT = { 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, '#wire-width': 1/16,
door_red: [0, 1], door_red: [0, 1],
@ -624,10 +635,16 @@ export const CC2_TILESET_LAYOUT = {
no_player1_sign: [6, 31], no_player1_sign: [6, 31],
hook: [7, 31], hook: [7, 31],
// misc other stuff // 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 = { 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], floor: [0, 0],
wall: [0, 1], wall: [0, 1],
chip: [0, 2], chip: [0, 2],
@ -795,9 +812,16 @@ export const TILE_WORLD_TILESET_LAYOUT = {
exploded: [3, 6], exploded: [3, 6],
failed: [3, 7], failed: [3, 7],
}, },
..._omit_custom_lexy_vfx,
}; };
export const LL_TILESET_LAYOUT = Object.assign({}, CC2_TILESET_LAYOUT, { 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 // Completed teeth sprites
teeth: Object.assign({}, CC2_TILESET_LAYOUT.teeth, { teeth: Object.assign({}, CC2_TILESET_LAYOUT.teeth, {
north: [[1, 32], [0, 32], [1, 32], [2, 32]], 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]], chip: [[11, 3], [0, 39], [1, 39], [0, 39]],
green_chip: [[9, 3], [2, 39], [3, 39], [2, 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]], player1_exit: [[8, 38], [9, 38], [10, 38], [11, 38]],
player2_exit: [[12, 38], [13, 38], [14, 38], [15, 38]], player2_exit: [[12, 38], [13, 38], [14, 38], [15, 38]],
puff: [[4, 39], [5, 39], [6, 39], [7, 39]], 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], sand: [10, 41],
}); });
export const TILESET_LAYOUTS = {
'tw-static': TILE_WORLD_TILESET_LAYOUT,
cc2: CC2_TILESET_LAYOUT,
lexy: LL_TILESET_LAYOUT,
};
export class Tileset { export class Tileset {
constructor(image, layout, size_x, size_y) { constructor(image, layout, size_x, size_y) {
// XXX curiously, i note that .image is never used within this class
this.image = image; this.image = image;
this.layout = layout; this.layout = layout;
this.size_x = size_x; this.size_x = size_x;
@ -1256,7 +1286,13 @@ export class Tileset {
// without it you'll get defaults. // without it you'll get defaults.
draw_type(name, tile, tic, perception, blit) { draw_type(name, tile, tic, perception, blit) {
let drawspec = this.layout[name]; 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) { if (! drawspec) {
// This is just missing
console.error(`Don't know how to draw tile type ${name}!`); console.error(`Don't know how to draw tile type ${name}!`);
return; return;
} }

View File

@ -264,7 +264,7 @@ const TILE_TYPES = {
blocks_collision: COLLISION.block_cc1 | (COLLISION.monster_solid & ~COLLISION.rover), blocks_collision: COLLISION.block_cc1 | (COLLISION.monster_solid & ~COLLISION.rover),
on_ready(me, level) { on_ready(me, level) {
if (! level.compat.no_auto_convert_ccl_popwalls && if (! level.compat.no_auto_convert_ccl_popwalls &&
level.stored_level.use_ccl_compat && level.stored_level.format === 'ccl' &&
me.cell.get_actor()) me.cell.get_actor())
{ {
// Fix blocks and other actors on top of popwalls by turning them into double // 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, blocks_collision: COLLISION.all_but_ghost,
on_ready(me, level) { on_ready(me, level) {
if (! level.compat.no_auto_convert_ccl_blue_walls && if (! level.compat.no_auto_convert_ccl_blue_walls &&
level.stored_level.use_ccl_compat && level.stored_level.format === 'ccl' &&
me.cell.get_actor()) me.cell.get_actor())
{ {
// Blocks can be pushed off of blue walls in TW Lynx, which only works due to a tiny // 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 */ /* Options dialog */
.dialog-options { .dialog-options {
height: 60%;
width: 75%;
} }
.dialog-options > section { .option-volume {
flex: 1;
}
nav.tabstrip {
display: flex; display: flex;
border-bottom: 1px solid #d0d0d0; gap: 1em;
} }
nav.tabstrip > a { .option-volume > input[type=range] {
margin: 0 0.5em; flex: auto;
padding: 0.5em 1em;
color: inherit;
text-decoration: none;
border-top-left-radius: 0.5em;
border-top-right-radius: 0.5em;
} }
nav.tabstrip > a:hover { .option-tileset canvas {
background: #e8e8e8; vertical-align: middle;
}
nav.tabstrip > a.--selected {
background: #d0d0d0;
}
.dialog section.tabblock {
display: none;
overflow: auto;
margin: 0.25em 0.5em;
}
.dialog section.tabblock.--selected {
display: initial;
} }
label.option { label.option {
display: flex; display: flex;
@ -1170,22 +1149,10 @@ dl.score-chart .-sum {
#player-music { #player-music {
grid-area: music; grid-area: music;
display: flex;
gap: 1em;
margin: 0 1em; margin: 0 1em;
text-transform: lowercase; text-transform: lowercase;
color: #909090; 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 { #player .controls {
grid-area: controls; grid-area: controls;