Add a splash screen and the beginning of an editor

This commit is contained in:
Eevee (Evelyn Woods) 2020-09-05 16:21:31 -06:00
parent 25989fc75b
commit dea7a7b754
6 changed files with 902 additions and 348 deletions

View File

@ -7,5 +7,111 @@
<script type="module" src="js/main.js"></script> <script type="module" src="js/main.js"></script>
</head> </head>
<body> <body>
<header id="header-main">
<h1>Lexy's Labyrinth</h1>
<nav>
<button id="main-about" type="button">about</button>
<button id="main-help" type="button" disabled>help</button>
<button id="main-options" type="button">options</button>
</nav>
</header>
<header id="header-pack">
<h2 id="level-pack-name">Chip's Challenge Level Pack 1</h2>
<nav>
<button class="set-nav-return" type="button" disabled>Change pack</button>
<button id="player-edit" type="button">Return to editor</button>
</nav>
</header>
<header id="header-level">
<h3 id="level-name">Level 1 — Key Pyramid</h3>
<nav>
<button id="main-prev-level" type="button">⬅️&#xfe0e;</button>
<button id="main-choose-level" type="button">Level select</button>
<button id="main-next-level" type="button">➡️&#xfe0e;</button>
</nav>
</header>
<main id="splash">
<h2>Community levels</h2>
<ul id="level-pack-list">
</ul>
<!--
<h2>Commercial and other levels</h2>
<p>You can play the original levels, or any you've downloaded from the web!</p>
-->
<h2>Make your own</h2>
<p><button type="button" id="splash-create-level">Create a level</button></p>
</main>
<main id="player" hidden>
<div class="level"><!-- level canvas and any overlays go here --></div>
<div class="bummer"></div>
<div class="message"></div>
<div class="chips">
<h3>Chips</h3>
<output></output>
</div>
<div class="time">
<h3>Time</h3>
<output></output>
</div>
<div class="bonus">
<h3>Bonus</h3>
<output></output>
</div>
<div class="inventory"></div>
<div class="controls">
<div class="play-controls">
<button class="control-pause" type="button">Pause</button>
<button class="control-restart" type="button">Restart</button>
<button class="control-undo" type="button">Undo</button>
<button class="control-rewind" type="button">Rewind</button>
</div>
<div class="demo-controls">
<button class="demo-play" type="button">View replay</button>
<button class="demo-step-1" type="button">Step 1 tic</button>
<button class="demo-step-4" type="button">Step 1 move</button>
<div class="input"></div>
</div>
</div>
</main>
<main id="editor" hidden>
<header>
<!-- TODO
- close
- export
- delete??
- zoom
also deal with levels vs level /packs/ somehow, not sure how that'll work (including downloading them, yeargh?)
-->
</header>
<div class="level"><!-- level canvas and any overlays go here --></div>
<div class="controls">
<button id="editor-test" type="button">Test</button>
</div>
<div class="palette"></div>
<!-- TODO:
controls
- play!
- object palette
- choose direction
- choose layer to /modify/: terrain, item, creature, overlay
- stack (place item atop whatever terrain), or replace (placing a tile overwrites the whole cell)
[XXX mode that allows arbitrary stacking of objects?]
- level metadata
- change size
XXX how do i handle thin walls? treat specially, allow drawing/erasing them along edges instead of tiles? ehh then you can't control which tile they're in though... but the game seems to prefer south+east so maybe that works...
hotkeys
- mod a tile on the board: rotate a creature, alter thin walls??
- "pick up" a tile
cool stuff
- set chip count by hand, set extra ones automatically
-->
</main>
</body> </body>
</html> </html>

View File

@ -7,7 +7,7 @@ import * as format_util from './format-util.js';
import CanvasRenderer from './renderer-canvas.js'; import CanvasRenderer from './renderer-canvas.js';
import { Tileset, CC2_TILESET_LAYOUT, TILE_WORLD_TILESET_LAYOUT } from './tileset.js'; import { Tileset, CC2_TILESET_LAYOUT, TILE_WORLD_TILESET_LAYOUT } from './tileset.js';
import TILE_TYPES from './tiletypes.js'; import TILE_TYPES from './tiletypes.js';
import { mk, promise_event, fetch } from './util.js'; import { mk, promise_event, fetch, walk_grid } from './util.js';
const PAGE_TITLE = "Lexy's Labyrinth"; const PAGE_TITLE = "Lexy's Labyrinth";
@ -145,6 +145,8 @@ class Level {
this.stored_level = stored_level; this.stored_level = stored_level;
this.width = stored_level.size_x; this.width = stored_level.size_x;
this.height = stored_level.size_y; this.height = stored_level.size_y;
this.size_x = stored_level.size_x;
this.size_y = stored_level.size_y;
this.restart(compat); this.restart(compat);
} }
@ -811,8 +813,8 @@ class Level {
// Stackable modal overlay of some kind, usually a dialog // Stackable modal overlay of some kind, usually a dialog
class Overlay { class Overlay {
constructor(game, root) { constructor(conductor, root) {
this.game = game; this.conductor = conductor;
this.root = root; this.root = root;
// Don't propagate clicks on the root element, so they won't trigger a // Don't propagate clicks on the root element, so they won't trigger a
@ -826,8 +828,8 @@ class Overlay {
// FIXME ah, but keystrokes can still go to the game, including // FIXME ah, but keystrokes can still go to the game, including
// spacebar to begin it if it was waiting. how do i completely disable // spacebar to begin it if it was waiting. how do i completely disable
// an entire chunk of the page? // an entire chunk of the page?
if (this.game.state === 'playing') { if (this.conductor.player.state === 'playing') {
this.game.set_state('paused'); this.conductor.player.set_state('paused');
} }
let overlay = mk('div.overlay', this.root); let overlay = mk('div.overlay', this.root);
@ -846,8 +848,8 @@ class Overlay {
// Overlay styled like a dialog box // Overlay styled like a dialog box
class DialogOverlay extends Overlay { class DialogOverlay extends Overlay {
constructor(game) { constructor(conductor) {
super(game, mk('div.dialog')); super(conductor, mk('div.dialog'));
this.root.append( this.root.append(
this.header = mk('header'), this.header = mk('header'),
@ -870,8 +872,8 @@ class DialogOverlay extends Overlay {
// Yes/no popup dialog // Yes/no popup dialog
class ConfirmOverlay extends DialogOverlay { class ConfirmOverlay extends DialogOverlay {
constructor(game, message, what) { constructor(conductor, message, what) {
super(game); super(conductor);
this.set_title("just checking"); this.set_title("just checking");
this.main.append(mk('p', {}, message)); this.main.append(mk('p', {}, message));
let yes = mk('button', {type: 'button'}, "yep"); let yes = mk('button', {type: 'button'}, "yep");
@ -887,191 +889,35 @@ class ConfirmOverlay 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 exciting sequel <a href="https://store.steampowered.com/app/348300/Chips_Challenge_2/">Chip's Challenge 2</a>.</p> // Main display... modes
<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. With any luck, future releases will include other community level packs, the ability to play your own, and even a way to play the original levels once you've purchased them on Steam!</p> class PrimaryView {
<p>Source code is on <a href="https://github.com/eevee/lexys-labyrinth">GitHub</a>.</p> constructor(conductor, root) {
<p>Special thanks to the incredibly detailed <a href="https://bitbusters.club/">Bit Busters Club</a> and its associated wiki and Discord, the latter of which is full of welcoming people who've been more than happy to answer all my burning arcane questions about Chip's Challenge mechanics. Thank you also to <a href="https://tw2.bitbusters.club/">Tile World</a>, an open source Chip's Challenge 1 emulator whose source code was indispensable, and the origin of the default tileset.</p> this.conductor = conductor;
`; this.root = root;
class AboutOverlay extends DialogOverlay { }
constructor(game) {
super(game); activate() {
this.set_title("about"); this.root.removeAttribute('hidden');
this.main.innerHTML = ABOUT_HTML; }
this.add_button("cool", ev => {
this.close(); deactivate() {
}); this.root.setAttribute('hidden', '');
} }
} }
// 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 COMPAT_OPTIONS = [{
key: 'tiles_react_instantly',
label: "Tiles react instantly",
impls: ['lynx', 'ms'],
note: "In classic CC, actors moved instantly from one tile to another, so tiles would react (e.g., buttons would become pressed) instantly as well. CC2 made actors slide smoothly between tiles, and it made more sense visually for the reactions to only happen once the sliding animation had finished. That's technically a gameplay change, since it delays a lot of tile behavior for 4 tics (the time it takes most actors to move), so here's a compat option. Works best in conjunction with disabling smooth scrolling; otherwise you'll see strange behavior like completing a level before actually stepping onto the exit.",
}];
const OPTIONS_TABS = [{
name: 'compat',
label: "Compat",
}];
class OptionsOverlay extends DialogOverlay {
constructor(game) {
super(game);
this.set_title("options");
this.add_button("well alright then", ev => {
this.close();
});
let tab_strip = mk('nav.tabstrip');
this.main.append(tab_strip);
this.tab_links = {};
this.tab_blocks = {};
for (let tabdef of OPTIONS_TABS) {
let link = mk('a', {href: 'javascript:', 'data-tab': tabdef.name}, tabdef.label);
tab_strip.append(link);
this.tab_links[tabdef.name] = link;
let block = mk('section');
this.main.append(block);
this.tab_blocks[tabdef.name] = block;
}
// Compat tab
this.tab_blocks['compat'].append(mk('p', "Changes to compatibility settings won't take effect until you restart the level."));
let ul = mk('ul');
this.tab_blocks['compat'].append(ul);
for (let optdef of COMPAT_OPTIONS) {
let li = mk('li');
let label = mk('label');
li.append(label);
label.append(mk('input', {type: 'checkbox', name: optdef.key}));
for (let impl of optdef.impls) {
label.append(mk(`span.compat-${impl}`, impl));
}
label.append(optdef.label);
li.append(mk('p', optdef.note));
ul.append(li);
}
this.main.append(mk('p', "Sorry! This stuff doesn't actually work yet."));
}
}
// List of levels
class LevelBrowserOverlay extends DialogOverlay {
constructor(game) {
super(game);
this.set_title("choose a level");
let table = mk('table.level-browser');
this.main.append(table);
for (let [i, stored_level] of game.stored_game.levels.entries()) {
table.append(mk('tr',
{'data-index': i},
mk('td', i + 1),
mk('td', stored_level.title),
// TODO score?
// TODO other stats??
mk('td', '▶'),
));
}
table.addEventListener('click', ev => {
let tr = ev.target.closest('table.level-browser tr');
if (! tr)
return;
let index = parseInt(tr.getAttribute('data-index'), 10);
this.game.load_level(index);
this.close();
});
this.add_button("nevermind", ev => {
this.close();
});
}
}
// TODO: // TODO:
// - some kinda visual theme i guess lol // - some kinda visual theme i guess lol
// - level password, if any // - level password, if any
// - timer!!!!!
// - bonus points (cc2 only, or maybe only if got any so far this level) // - bonus points (cc2 only, or maybe only if got any so far this level)
// - intro splash with list of available level packs // - intro splash with list of available level packs
// - button: quit to splash // - button: quit to splash
// - implement winning and show score for this level // - implement winning and show score for this level
// - show current score so far // - show current score so far
// - about, help // - about, help
const GAME_UI_HTML = `
<header>
<h1>Lexy's Labyrinth</h1>
<nav>
<button class="nav-about" type="button">about</button>
<button class="nav-help" type="button" disabled>help</button>
<button class="nav-options" type="button">options</button>
</nav>
</header>
<main>
<header>
<h1 class="level-set">Chip's Challenge Level Pack 1</h1>
<nav>
<button class="set-nav-return" type="button" disabled>Change pack</button>
</nav>
<h2 class="level-name">Level 1 Key Pyramid</h2>
<nav class="nav">
<button class="nav-prev" type="button">\ufe0e</button>
<button class="nav-browse" type="button">Level select</button>
<button class="nav-next" type="button">\ufe0e</button>
</nav>
</header>
<div class="level"><!-- level canvas and any overlays go here --></div>
<div class="bummer"></div>
<div class="message"></div>
<div class="chips">
<h3>Chips</h3>
<output></output>
</div>
<div class="time">
<h3>Time</h3>
<output></output>
</div>
<div class="bonus">
<h3>Bonus</h3>
<output></output>
</div>
<div class="inventory"></div>
<div class="controls">
<div class="play-controls">
<button class="control-pause" type="button">Pause</button>
<button class="control-restart" type="button">Restart</button>
<button class="control-undo" type="button">Undo</button>
<button class="control-rewind" type="button">Rewind</button>
</div>
<div class="demo-controls">
<button class="demo-play" type="button">View replay</button>
<button class="demo-step-1" type="button">Step 1 tic</button>
<button class="demo-step-4" type="button">Step 1 move</button>
<div class="input"></div>
</div>
</div>
</main>
`;
const ACTION_LABELS = { const ACTION_LABELS = {
up: '⬆️\ufe0f', up: '⬆️\ufe0f',
down: '⬇️\ufe0f', down: '⬇️\ufe0f',
@ -1087,10 +933,10 @@ const ACTION_DIRECTIONS = {
left: 'west', left: 'west',
right: 'east', right: 'east',
}; };
class Game { class Player extends PrimaryView {
constructor(stored_game, tileset) { constructor(conductor) {
this.stored_game = stored_game; super(conductor, document.body.querySelector('main#player'));
this.tileset = tileset;
this.key_mapping = { this.key_mapping = {
ArrowLeft: 'left', ArrowLeft: 'left',
ArrowRight: 'right', ArrowRight: 'right',
@ -1105,92 +951,55 @@ class Game {
c: 'swap', c: 'swap',
}; };
// TODO obey level options; allow overriding
this.viewport_size_x = 9;
this.viewport_size_y = 9;
this.scale = 1; this.scale = 1;
this.compat = { this.compat = {
tiles_react_instantly: false, tiles_react_instantly: false,
}; };
document.body.innerHTML = GAME_UI_HTML; this.root.style.setProperty('--tile-width', `${this.conductor.tileset.size_x}px`);
this.container = document.body.querySelector('main'); this.root.style.setProperty('--tile-height', `${this.conductor.tileset.size_y}px`);
this.container.style.setProperty('--tile-width', `${this.tileset.size_x}px`); this.level_el = this.root.querySelector('.level');
this.container.style.setProperty('--tile-height', `${this.tileset.size_y}px`); this.message_el = this.root.querySelector('.message');
this.level_el = this.container.querySelector('.level'); this.chips_el = this.root.querySelector('.chips output');
this.level_name_el = this.container.querySelector('.level-name'); this.time_el = this.root.querySelector('.time output');
this.message_el = this.container.querySelector('.message'); this.bonus_el = this.root.querySelector('.bonus output');
this.chips_el = this.container.querySelector('.chips output'); this.inventory_el = this.root.querySelector('.inventory');
this.time_el = this.container.querySelector('.time output'); this.bummer_el = this.root.querySelector('.bummer');
this.bonus_el = this.container.querySelector('.bonus output'); this.input_el = this.root.querySelector('.input');
this.inventory_el = this.container.querySelector('.inventory'); this.demo_el = this.root.querySelector('.demo');
this.bummer_el = this.container.querySelector('.bummer');
this.input_el = this.container.querySelector('.input');
this.demo_el = this.container.querySelector('.demo');
// Populate stuff
let header = document.body.querySelector('body > header');
header.querySelector('.nav-about').addEventListener('click', ev => {
new AboutOverlay(this).open();
});
header.querySelector('.nav-options').addEventListener('click', ev => {
new OptionsOverlay(this).open();
});
// Populate navigation
let nav_el = this.container.querySelector('.nav');
this.nav_prev_button = nav_el.querySelector('.nav-prev');
this.nav_next_button = nav_el.querySelector('.nav-next');
this.nav_prev_button.addEventListener('click', ev => {
// TODO confirm
if (this.level_index > 0) {
this.load_level(this.level_index - 1);
}
ev.target.blur();
});
this.nav_next_button.addEventListener('click', ev => {
// TODO confirm
if (this.level_index < this.stored_game.levels.length - 1) {
this.load_level(this.level_index + 1);
}
ev.target.blur();
});
nav_el.querySelector('.nav-browse').addEventListener('click', ev => {
new LevelBrowserOverlay(this).open();
});
// Bind buttons // Bind buttons
this.pause_button = this.container.querySelector('.controls .control-pause'); this.pause_button = this.root.querySelector('.controls .control-pause');
this.pause_button.addEventListener('click', ev => { this.pause_button.addEventListener('click', ev => {
this.toggle_pause(); this.toggle_pause();
ev.target.blur(); ev.target.blur();
}); });
this.restart_button = this.container.querySelector('.controls .control-restart'); this.restart_button = this.root.querySelector('.controls .control-restart');
this.restart_button.addEventListener('click', ev => { this.restart_button.addEventListener('click', ev => {
new ConfirmOverlay(this, "Abandon this attempt and try again?", () => { new ConfirmOverlay(this, "Abandon this attempt and try again?", () => {
this.restart_level(); this.restart_level();
}).open(); }).open();
ev.target.blur(); ev.target.blur();
}); });
this.undo_button = this.container.querySelector('.controls .control-undo'); this.undo_button = this.root.querySelector('.controls .control-undo');
this.undo_button.addEventListener('click', ev => { this.undo_button.addEventListener('click', ev => {
let player_cell = this.level.player.cell; let player_cell = this.level.player.cell;
while (player_cell === this.level.player.cell && this.level.undo_stack.length > 0) { while (player_cell === this.level.player.cell && this.level.undo_stack.length > 0) {
this.level.undo(); this.level.undo();
} }
if (this.level.undo_stack.length === 0) { // TODO set back to waiting if we hit the start of the level? but
this.set_state('waiting'); // the stack trims itself so how do we know that
} if (this.state === 'stopped') {
else {
// Be sure to undo any success or failure // Be sure to undo any success or failure
this.set_state('playing'); this.set_state('playing');
} }
this.update_ui(); this.update_ui();
this._redraw();
ev.target.blur(); ev.target.blur();
}); });
// Demo playback // Demo playback
this.container.querySelector('.demo-controls .demo-play').addEventListener('click', ev => { this.root.querySelector('.demo-controls .demo-play').addEventListener('click', ev => {
if (this.state === 'playing' || this.state === 'paused' || this.state === 'rewinding') { if (this.state === 'playing' || this.state === 'paused' || this.state === 'rewinding') {
new ConfirmOverlay(this, "Abandon your progress and watch the replay?", () => { new ConfirmOverlay(this, "Abandon your progress and watch the replay?", () => {
this.play_demo(); this.play_demo();
@ -1200,11 +1009,11 @@ class Game {
this.play_demo(); this.play_demo();
} }
}); });
this.container.querySelector('.demo-controls .demo-step-1').addEventListener('click', ev => { this.root.querySelector('.demo-controls .demo-step-1').addEventListener('click', ev => {
this.advance_by(1); this.advance_by(1);
this._redraw(); this._redraw();
}); });
this.container.querySelector('.demo-controls .demo-step-4').addEventListener('click', ev => { this.root.querySelector('.demo-controls .demo-step-4').addEventListener('click', ev => {
this.advance_by(4); this.advance_by(4);
this._redraw(); this._redraw();
}); });
@ -1214,15 +1023,13 @@ class Game {
let floor_tile = this.render_inventory_tile('floor'); let floor_tile = this.render_inventory_tile('floor');
this.inventory_el.style.backgroundImage = `url(${floor_tile})`; this.inventory_el.style.backgroundImage = `url(${floor_tile})`;
this.renderer = new CanvasRenderer(tileset); this.renderer = new CanvasRenderer(this.conductor.tileset);
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)
return; return;
let rect = this.renderer.canvas.getBoundingClientRect(); let [x, y] = this.renderer.cell_coords_from_event(ev);
let x = Math.floor((ev.clientX - rect.x) / this.scale / this.tileset.size_x + this.renderer.viewport_x);
let y = Math.floor((ev.clientY - rect.y) / this.scale / this.tileset.size_y + this.renderer.viewport_y);
this.level.move_to(this.level.player, x, y); this.level.move_to(this.level.player, x, y);
}); });
@ -1250,7 +1057,7 @@ class Game {
if (this.level.state === 'success') { if (this.level.state === 'success') {
// Advance to the next level // Advance to the next level
// TODO game ending? // TODO game ending?
this.load_level(this.level_index + 1); this.conductor.change_level(this.conductor.level_index + 1);
} }
else { else {
// Restart // Restart
@ -1281,7 +1088,7 @@ class Game {
}); });
// Populate input debugger // Populate input debugger
this.input_el = this.container.querySelector('.input'); this.input_el = this.root.querySelector('.input');
this.input_action_elements = {}; this.input_action_elements = {};
for (let [action, label] of Object.entries(ACTION_LABELS)) { for (let [action, label] of Object.entries(ACTION_LABELS)) {
let el = mk('span.input-action', {'data-action': action}, label); let el = mk('span.input-action', {'data-action': action}, label);
@ -1302,9 +1109,6 @@ class Game {
this.tic_offset = 0; this.tic_offset = 0;
this.last_advance = 0; // performance.now timestamp this.last_advance = 0; // performance.now timestamp
// Done with UI, now we can load a level
this.load_level(0);
// Auto-size the level canvas, both now and on resize // Auto-size the level canvas, both now and on resize
this.adjust_scale(); this.adjust_scale();
window.addEventListener('resize', ev => { window.addEventListener('resize', ev => {
@ -1312,10 +1116,26 @@ class Game {
}); });
} }
load_level(level_index) { activate() {
// TODO clear out input? (when restarting, too?) // We can't resize when we're not visible, so do it now
this.level_index = level_index; super.activate();
this.level = new Level(this.stored_game.levels[level_index], this.compat); this.adjust_scale();
}
deactivate() {
// End the level when going away; the easiest way is by restarting it
// TODO could throw the level away entirely and create a new one on activate?
super.deactivate();
if (this.state !== 'waiting') {
this.restart_level();
}
}
load_game(stored_game) {
}
load_level(stored_level) {
this.level = new Level(stored_level, this.compat);
this.renderer.set_level(this.level); this.renderer.set_level(this.level);
// waiting: haven't yet pressed a key so the timer isn't going // waiting: haven't yet pressed a key so the timer isn't going
// playing: playing normally // playing: playing normally
@ -1327,16 +1147,8 @@ class Game {
this.tic_offset = 0; this.tic_offset = 0;
this.last_advance = 0; this.last_advance = 0;
// FIXME do better
this.level_name_el.textContent = `Level ${level_index + 1}${this.level.stored_level.title}`;
document.title = `${PAGE_TITLE} - ${this.level.stored_level.title}`;
this.nav_prev_button.disabled = level_index <= 0;
this.nav_next_button.disabled = level_index >= this.stored_game.levels.length;
this.demo_faucet = null; this.demo_faucet = null;
this.container.classList.toggle('--has-demo', !!this.level.stored_level.demo); this.root.classList.toggle('--has-demo', !!this.level.stored_level.demo);
this.update_ui(); this.update_ui();
// Force a redraw, which won't happen on its own since the game isn't running // Force a redraw, which won't happen on its own since the game isn't running
@ -1466,9 +1278,10 @@ class Game {
render_inventory_tile(name) { render_inventory_tile(name) {
if (! this._inventory_tiles[name]) { if (! this._inventory_tiles[name]) {
// TODO reuse the canvas // TODO put this on the renderer
let canvas = mk('canvas', {width: this.tileset.size_x, height: this.tileset.size_y}); // TODO reuse the canvas for data urls
this.tileset.draw({type: TILE_TYPES[name]}, null, canvas.getContext('2d'), 0, 0); let canvas = mk('canvas', {width: this.conductor.tileset.size_x, height: this.conductor.tileset.size_y});
this.conductor.tileset.draw({type: TILE_TYPES[name]}, null, canvas.getContext('2d'), 0, 0);
this._inventory_tiles[name] = canvas.toDataURL(); this._inventory_tiles[name] = canvas.toDataURL();
} }
return this._inventory_tiles[name]; return this._inventory_tiles[name];
@ -1531,7 +1344,7 @@ class Game {
} }
else { else {
this.bummer_el.textContent = ""; this.bummer_el.textContent = "";
let base = (this.level_index + 1) * 500; let base = (this.conductor.level_index + 1) * 500;
let time = (this.level.time_remaining || 0) * 10; let time = (this.level.time_remaining || 0) * 10;
this.bummer_el.append( this.bummer_el.append(
mk('p', "go bit buster!"), mk('p', "go bit buster!"),
@ -1570,11 +1383,11 @@ class Game {
// TODO make this optional // TODO make this optional
// The base size is the size of the canvas, i.e. the viewport size // The base size is the size of the canvas, i.e. the viewport size
// times the tile size // times the tile size
let base_x = this.tileset.size_x * this.viewport_size_x; let base_x = this.conductor.tileset.size_x * this.renderer.viewport_size_x;
let base_y = this.tileset.size_y * this.viewport_size_y; let base_y = this.conductor.tileset.size_y * this.renderer.viewport_size_y;
// The main UI is centered in a flex item with auto margins, so the // The main UI is centered in a flex item with auto margins, so the
// extra space available is the size of those margins // extra space available is the size of those margins
let style = window.getComputedStyle(this.container); let style = window.getComputedStyle(this.root);
let extra_x = parseFloat(style['margin-left']) + parseFloat(style['margin-right']); let extra_x = parseFloat(style['margin-left']) + parseFloat(style['margin-right']);
let extra_y = parseFloat(style['margin-top']) + parseFloat(style['margin-bottom']); let extra_y = parseFloat(style['margin-top']) + parseFloat(style['margin-bottom']);
// The total available space, then, is the current size of the // The total available space, then, is the current size of the
@ -1590,10 +1403,438 @@ class Game {
// FIXME the above logic doesn't take into account the inventory, which is also affected by scale // FIXME the above logic doesn't take into account the inventory, which is also affected by scale
this.scale = scale; this.scale = scale;
this.container.style.setProperty('--scale', scale); this.root.style.setProperty('--scale', scale);
} }
} }
class Editor extends PrimaryView {
constructor(conductor) {
super(conductor, document.body.querySelector('main#editor'));
// FIXME don't hardcode size here, convey this to renderer some other way
this.renderer = new CanvasRenderer(this.conductor.tileset, 32);
// Level canvas and mouse handling
this.root.querySelector('.level').append(this.renderer.canvas);
this.mouse_mode = null;
this.mouse_cell = null;
this.renderer.canvas.addEventListener('mousedown', ev => {
this.mouse_mode = 'draw';
let [x, y] = this.renderer.cell_coords_from_event(ev);
this.mouse_cell = [x, y];
this.place_in_cell(x, y, this.palette_selection);
this.renderer.draw();
});
this.renderer.canvas.addEventListener('mousemove', ev => {
if (this.mouse_mode === null)
return;
if (this.mouse_mode === 'draw') {
// FIXME also fill in a trail between previous cell and here, mousemove is not fired continuously
let [x, y] = this.renderer.cell_coords_from_event(ev);
if (x === this.mouse_cell[0] && y === this.mouse_cell[1])
return;
// TODO do a pixel-perfect draw too
for (let [cx, cy] of walk_grid(this.mouse_cell[0], this.mouse_cell[1], x, y)) {
this.place_in_cell(cx, cy, this.palette_selection);
}
this.renderer.draw();
this.mouse_cell = [x, y];
}
});
this.renderer.canvas.addEventListener('mouseup', ev => {
this.mouse_mode = null;
});
window.addEventListener('blur', ev => {
// Unbind the mouse if the page loses focus
this.mouse_mode = null;
});
// Misc controls
this.root.querySelector('#editor-test').addEventListener('click', ev => {
this.conductor.switch_to_player();
});
// Tile palette
let palette_el = this.root.querySelector('.palette');
this.palette = {}; // name => element
for (let name of ['floor', 'wall']) {
let entry = mk('canvas.palette-entry', {
width: this.conductor.tileset.size_x,
height: this.conductor.tileset.size_y,
'data-tile-name': name,
});
let ctx = entry.getContext('2d');
this.conductor.tileset.draw_type(name, null, null, ctx, 0, 0);
this.palette[name] = entry;
palette_el.append(entry);
}
palette_el.addEventListener('click', ev => {
let entry = ev.target.closest('canvas.palette-entry');
if (! entry)
return;
this.select_palette(entry.getAttribute('data-tile-name'));
});
this.palette_selection = null;
this.select_palette('floor');
}
load_game(stored_game) {
}
load_level(stored_level) {
// TODO support a game too i guess
this.stored_level = stored_level;
// XXX need this for renderer compat. but i guess it's nice in general idk
this.stored_level.cells = [];
let row;
for (let [i, cell] of this.stored_level.linear_cells.entries()) {
if (i % this.stored_level.size_x === 0) {
row = [];
this.stored_level.cells.push(row);
}
row.push(cell);
}
this.renderer.set_level(stored_level);
this.renderer.draw();
}
select_palette(name) {
if (name === this.palette_selection)
return;
if (this.palette_selection) {
this.palette[this.palette_selection].classList.remove('--selected');
}
this.palette_selection = name;
if (this.palette_selection) {
this.palette[this.palette_selection].classList.add('--selected');
}
}
place_in_cell(x, y, name) {
// TODO weird api?
if (! name)
return;
let cell = this.stored_level.cells[y][x];
cell.push({name});
}
}
const BUILTIN_LEVEL_PACKS = [{
path: 'levels/CCLP1.ccl',
title: "Chip's Challenge Level Pack 1",
desc: "Intended as an introduction to Chip's Challenge 1 for new players. Recommended.",
}];
class Splash extends PrimaryView {
constructor(conductor) {
super(conductor, document.body.querySelector('main#splash'));
// Populate the list of available level packs
let pack_list = document.querySelector('#level-pack-list');
for (let packdef of BUILTIN_LEVEL_PACKS) {
let li = mk('li',
mk('h3', packdef.title),
mk('p', packdef.desc),
);
li.addEventListener('click', ev => {
this.fetch_pack(packdef.path, packdef.title);
});
pack_list.append(li);
}
this.root.querySelector('#splash-create-level').addEventListener('click', ev => {
let stored_level = new format_util.StoredLevel;
stored_level.size_x = 32;
stored_level.size_y = 32;
for (let i = 0; i < 1024; i++) {
let cell = new format_util.StoredCell;
cell.push({name: 'floor'});
stored_level.linear_cells.push(cell);
}
stored_level.linear_cells[0].push({name: 'player'});
let stored_game = new format_util.StoredGame;
stored_game.levels.push(stored_level);
this.conductor.load_game(stored_game);
this.conductor.switch_to_editor();
});
}
async fetch_pack(path, title) {
// TODO indicate we're downloading something
// TODO handle errors
// TODO cancel a download if we start another one?
let data = await fetch(path);
let stored_game;
// TODO check magic numbers, not extensions
// TODO also support tile world's DAC when reading from local??
// TODO ah, there's more metadata in CCX, crapola
if (path.match(/\.(?:dat|ccl)$/i)) {
stored_game = dat.parse_game(data);
}
else {
stored_game = new format_util.StoredGame;
stored_game.levels.push(c2m.parse_level(data));
}
// TODO get title out of C2G when it's supported
this.conductor.level_pack_name_el.textContent = title || path;
this.conductor.load_game(stored_game);
this.conductor.switch_to_player();
}
}
// -------------------------------------------------------------------------------------------------
// Central controller, thingy
// 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 exciting 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. With any luck, future releases will include other community level packs, the ability to play your own, and even a way to play the original levels once you've purchased them on Steam!</p>
<p>Source code is on <a href="https://github.com/eevee/lexys-labyrinth">GitHub</a>.</p>
<p>Special thanks to the incredibly detailed <a href="https://bitbusters.club/">Bit Busters Club</a> and its associated wiki and Discord, the latter of which is full of welcoming people who've been more than happy to answer all my burning arcane questions about Chip's Challenge mechanics. Thank you also to <a href="https://tw2.bitbusters.club/">Tile World</a>, an open source Chip's Challenge 1 emulator whose source code was indispensable, and the origin of the default tileset.</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 COMPAT_OPTIONS = [{
key: 'tiles_react_instantly',
label: "Tiles react instantly",
impls: ['lynx', 'ms'],
note: "In classic CC, actors moved instantly from one tile to another, so tiles would react (e.g., buttons would become pressed) instantly as well. CC2 made actors slide smoothly between tiles, and it made more sense visually for the reactions to only happen once the sliding animation had finished. That's technically a gameplay change, since it delays a lot of tile behavior for 4 tics (the time it takes most actors to move), so here's a compat option. Works best in conjunction with disabling smooth scrolling; otherwise you'll see strange behavior like completing a level before actually stepping onto the exit.",
}];
const OPTIONS_TABS = [{
name: 'compat',
label: "Compat",
}];
class OptionsOverlay extends DialogOverlay {
constructor(conductor) {
super(conductor);
this.set_title("options");
this.add_button("well alright then", ev => {
this.close();
});
let tab_strip = mk('nav.tabstrip');
this.main.append(tab_strip);
this.tab_links = {};
this.tab_blocks = {};
for (let tabdef of OPTIONS_TABS) {
let link = mk('a', {href: 'javascript:', 'data-tab': tabdef.name}, tabdef.label);
tab_strip.append(link);
this.tab_links[tabdef.name] = link;
let block = mk('section');
this.main.append(block);
this.tab_blocks[tabdef.name] = block;
}
// Compat tab
this.tab_blocks['compat'].append(mk('p', "Changes to compatibility settings won't take effect until you restart the level."));
let ul = mk('ul');
this.tab_blocks['compat'].append(ul);
for (let optdef of COMPAT_OPTIONS) {
let li = mk('li');
let label = mk('label');
li.append(label);
label.append(mk('input', {type: 'checkbox', name: optdef.key}));
for (let impl of optdef.impls) {
label.append(mk(`span.compat-${impl}`, impl));
}
label.append(optdef.label);
li.append(mk('p', optdef.note));
ul.append(li);
}
this.main.append(mk('p', "Sorry! This stuff doesn't actually work yet."));
}
}
// List of levels
class LevelBrowserOverlay extends DialogOverlay {
constructor(conductor) {
super(conductor);
this.set_title("choose a level");
let table = mk('table.level-browser');
this.main.append(table);
for (let [i, stored_level] of conductor.stored_game.levels.entries()) {
table.append(mk('tr',
{'data-index': i},
mk('td', i + 1),
mk('td', stored_level.title),
// TODO score?
// TODO other stats??
mk('td', '▶'),
));
}
table.addEventListener('click', ev => {
let tr = ev.target.closest('table.level-browser tr');
if (! tr)
return;
let index = parseInt(tr.getAttribute('data-index'), 10);
this.conductor.change_level(index);
this.close();
});
this.add_button("nevermind", ev => {
this.close();
});
}
}
// Central dispatcher of what we're doing and what we've got loaded
class Conductor {
constructor(tileset) {
this.stored_game = null;
this.tileset = tileset;
// TODO options and whatnot should go here too
this.splash = new Splash(this);
this.editor = new Editor(this);
this.player = new Player(this);
// Bind the header buttons
document.querySelector('#main-about').addEventListener('click', ev => {
new AboutOverlay(this).open();
});
document.querySelector('#main-options').addEventListener('click', ev => {
new OptionsOverlay(this).open();
});
// Bind to the navigation headers, which list the current level pack
// and level
this.level_pack_name_el = document.querySelector('#level-pack-name');
this.level_name_el = document.querySelector('#level-name');
this.nav_prev_button = document.querySelector('#main-prev-level');
this.nav_next_button = document.querySelector('#main-next-level');
this.nav_choose_level_button = document.querySelector('#main-choose-level');
this.nav_prev_button.addEventListener('click', ev => {
// TODO confirm
if (this.stored_game && this.level_index > 0) {
this.change_level(this.level_index - 1);
}
ev.target.blur();
});
this.nav_next_button.addEventListener('click', ev => {
// TODO confirm
if (this.stored_game && this.level_index < this.stored_game.levels.length - 1) {
this.change_level(this.level_index + 1);
}
ev.target.blur();
});
this.nav_choose_level_button.addEventListener('click', ev => {
if (this.stored_game) {
new LevelBrowserOverlay(this).open();
}
ev.target.blur();
});
document.querySelector('#player-edit').addEventListener('click', ev => {
// TODO should be able to jump to editor if we started in the
// player too! but should disable score tracking and have a revert
// button
this.switch_to_editor();
});
this.update_nav_buttons();
this.switch_to_splash();
}
switch_to_splash() {
if (this.current) {
this.current.deactivate();
}
this.splash.activate();
this.current = this.splash;
document.body.setAttribute('data-mode', 'splash');
}
switch_to_editor() {
if (this.current) {
this.current.deactivate();
}
this.editor.activate();
this.current = this.editor;
document.body.setAttribute('data-mode', 'editor');
}
switch_to_player() {
if (this.current) {
this.current.deactivate();
}
this.player.activate();
this.current = this.player;
document.body.setAttribute('data-mode', 'player');
}
load_game(stored_game) {
this.stored_game = stored_game;
this.player.load_game(stored_game);
this.editor.load_game(stored_game);
this.change_level(0);
}
change_level(level_index) {
this.level_index = level_index;
this.stored_level = this.stored_game.levels[level_index];
// FIXME do better
this.level_name_el.textContent = `Level ${level_index + 1}${this.stored_level.title}`;
document.title = `${PAGE_TITLE} - ${this.stored_level.title}`;
this.update_nav_buttons();
this.player.load_level(this.stored_level);
this.editor.load_level(this.stored_level);
}
update_nav_buttons() {
this.nav_choose_level_button.disabled = !this.stored_game;
this.nav_prev_button.disabled = !this.stored_game || this.level_index <= 0;
this.nav_next_button.disabled = !this.stored_game || this.level_index >= this.stored_game.levels.length;
}
}
async function main() { async function main() {
let query = new URLSearchParams(location.search); let query = new URLSearchParams(location.search);
@ -1626,29 +1867,13 @@ async function main() {
await tilesheet.decode(); await tilesheet.decode();
let tileset = new Tileset(tilesheet, tilelayout, tilesize, tilesize); let tileset = new Tileset(tilesheet, tilelayout, tilesize, tilesize);
let conductor = new Conductor(tileset);
// Pick a level (set) // Pick a level (set)
// TODO error handling :( // TODO error handling :(
let stored_game;
let path = query.get('setpath'); let path = query.get('setpath');
if (path && path.match(/^levels[/]/)) { if (path && path.match(/^levels[/]/)) {
let data = await fetch(path); conductor.splash.fetch_pack(path);
if (path.match(/\.(?:dat|ccl)$/i)) {
stored_game = dat.parse_game(data);
}
else {
stored_game = new format_util.StoredGame;
stored_game.levels.push(c2m.parse_level(data));
}
}
else {
// TODO also support tile world's DAC when reading from local??
// TODO ah, there's more metadata in CCX, crapola
stored_game = dat.parse_game(await fetch('levels/CCLP1.ccl'));
}
let game = new Game(stored_game, tileset);
if (query.get('debug')) {
game.debug = true;
} }
} }

View File

@ -1,16 +1,24 @@
import { DIRECTIONS } from './defs.js'; import { DIRECTIONS } from './defs.js';
import { mk } from './util.js'; import { mk } from './util.js';
import TILE_TYPES from './tiletypes.js';
export class CanvasRenderer { export class CanvasRenderer {
constructor(tileset) { constructor(tileset, fixed_size = null) {
this.tileset = tileset; this.tileset = tileset;
// Default, unfortunately and arbitrarily, to the CC1 size of 9×9. We // Default, unfortunately and arbitrarily, to the CC1 size of 9×9. We
// don't know for sure what size to use until the Game loads a level, // don't know for sure what size to use until the Game loads a level,
// and it doesn't do that until creating a renderer! It could be fixed // and it doesn't do that until creating a renderer! It could be fixed
// to do so, but then we wouldn't make a canvas so it couldn't be // to do so, but then we wouldn't make a canvas so it couldn't be
// hooked, yadda yadda // hooked, yadda yadda
if (fixed_size) {
this.viewport_is_fixed = true;
this.viewport_size_x = fixed_size;
this.viewport_size_y = fixed_size;
}
else {
this.viewport_size_x = 9; this.viewport_size_x = 9;
this.viewport_size_y = 9; this.viewport_size_y = 9;
}
this.canvas = mk('canvas', {width: tileset.size_x * this.viewport_size_x, height: tileset.size_y * this.viewport_size_y}); this.canvas = mk('canvas', {width: tileset.size_x * this.viewport_size_x, height: tileset.size_y * this.viewport_size_y});
this.canvas.style.setProperty('--viewport-width', this.viewport_size_x); this.canvas.style.setProperty('--viewport-width', this.viewport_size_x);
this.canvas.style.setProperty('--viewport-height', this.viewport_size_y); this.canvas.style.setProperty('--viewport-height', this.viewport_size_y);
@ -23,6 +31,15 @@ export class CanvasRenderer {
// TODO update viewport size... or maybe Game should do that since you might be cheating // TODO update viewport size... or maybe Game should do that since you might be cheating
} }
cell_coords_from_event(ev) {
let rect = this.canvas.getBoundingClientRect();
let scale_x = rect.width / this.canvas.width;
let scale_y = rect.height / this.canvas.height;
let x = Math.floor((ev.clientX - rect.x) / scale_x / this.tileset.size_x + this.viewport_x);
let y = Math.floor((ev.clientY - rect.y) / scale_y / this.tileset.size_y + this.viewport_y);
return [x, y];
}
draw(tic_offset = 0) { draw(tic_offset = 0) {
if (! this.level) { if (! this.level) {
console.warn("CanvasRenderer.draw: No level to render"); console.warn("CanvasRenderer.draw: No level to render");
@ -39,9 +56,16 @@ export class CanvasRenderer {
// TODO what about levels smaller than the viewport...? shrink the canvas in set_level? // TODO what about levels smaller than the viewport...? shrink the canvas in set_level?
let xmargin = (this.viewport_size_x - 1) / 2; let xmargin = (this.viewport_size_x - 1) / 2;
let ymargin = (this.viewport_size_y - 1) / 2; let ymargin = (this.viewport_size_y - 1) / 2;
let [px, py] = this.level.player.visual_position(tic_offset); let px, py;
let x0 = Math.max(0, Math.min(this.level.width - this.viewport_size_x, px - xmargin)); // FIXME editor vs player
let y0 = Math.max(0, Math.min(this.level.height - this.viewport_size_y, py - xmargin)); if (this.level.player) {
[px, py] = this.level.player.visual_position(tic_offset);
}
else {
[px, py] = [0, 0];
}
let x0 = Math.max(0, Math.min(this.level.size_x - this.viewport_size_x, px - xmargin));
let y0 = Math.max(0, Math.min(this.level.size_y - this.viewport_size_y, py - ymargin));
// Round to the pixel grid // Round to the pixel grid
x0 = Math.floor(x0 * this.tileset.size_x + 0.5) / this.tileset.size_x; x0 = Math.floor(x0 * this.tileset.size_x + 0.5) / this.tileset.size_x;
y0 = Math.floor(y0 * this.tileset.size_y + 0.5) / this.tileset.size_y; y0 = Math.floor(y0 * this.tileset.size_y + 0.5) / this.tileset.size_y;
@ -59,10 +83,21 @@ export class CanvasRenderer {
for (let x = xf0; x <= x1; x++) { for (let x = xf0; x <= x1; x++) {
for (let y = yf0; y <= y1; y++) { for (let y = yf0; y <= y1; y++) {
for (let tile of this.level.cells[y][x]) { for (let tile of this.level.cells[y][x]) {
if (tile.type.draw_layer !== layer) let type;
if (tile.name) {
type = TILE_TYPES[tile.name];
}
else {
type = tile.type;
}
if (type.draw_layer !== layer)
continue; continue;
if (tile.type.is_actor) { if (! tile.type) {
this.tileset.draw_type(tile.name, null, this.level, ctx, x - x0, y - y0);
}
else if (type.is_actor) {
// Handle smooth scrolling // Handle smooth scrolling
let [vx, vy] = tile.visual_position(tic_offset); let [vx, vy] = tile.visual_position(tic_offset);
// Round this to the pixel grid too! // Round this to the pixel grid too!

View File

@ -487,7 +487,7 @@ export class Tileset {
// TODO this is getting really ad-hoc and clumsy lol, maybe // TODO this is getting really ad-hoc and clumsy lol, maybe
// have tiles expose a single 'state' prop or something // have tiles expose a single 'state' prop or something
if (coords.moving) { if (coords.moving) {
if (tile.animation_speed) { if (tile && tile.animation_speed) {
coords = coords.moving; coords = coords.moving;
} }
else { else {
@ -499,7 +499,7 @@ export class Tileset {
} }
if (coords[0] instanceof Array) { if (coords[0] instanceof Array) {
if (level) { if (level) {
if (tile.animation_speed) { if (tile && tile.animation_speed) {
coords = coords[Math.floor((tile.animation_progress + level.tic_offset) / tile.animation_speed * coords.length)]; coords = coords[Math.floor((tile.animation_progress + level.tic_offset) / tile.animation_speed * coords.length)];
} }
else { else {

View File

@ -55,3 +55,104 @@ export async function fetch(url) {
await promise; await promise;
return xhr.response; return xhr.response;
} }
// Cast a line through a grid and yield every cell it touches
export function* walk_grid(x0, y0, x1, y1) {
// TODO if the ray starts outside the grid (extremely unlikely), we should
// find the point where it ENTERS the grid, otherwise the 'while'
// conditions below will stop immediately
let a = Math.floor(x0);
let b = Math.floor(y0);
let dx = x1 - x0;
let dy = y1 - y0;
if (dx === 0 && dy === 0) {
// Special case: the ray goes nowhere, so only return this block
yield [a, b];
return;
}
let goal_x = Math.floor(x1);
let goal_y = Math.floor(y1);
// Use a modified Bresenham. Use mirroring to move everything into the
// first quadrant, then split it into two octants depending on whether dx
// or dy increases faster, and call that the main axis. Track an "error"
// value, which is the (negative) distance between the ray and the next
// grid line parallel to the main axis, but scaled up by dx. Every
// iteration, we move one cell along the main axis and increase the error
// value by dy (the ray's slope, scaled up by dx); when it becomes
// positive, we can subtract dx (1) and move one cell along the minor axis
// as well. Since the main axis is the faster one, we'll never traverse
// more than one cell on the minor axis for one cell on the main axis, and
// this readily provides every cell the ray hits in order.
// Based on: http://www.idav.ucdavis.edu/education/GraphicsNotes/Bresenhams-Algorithm/Bresenhams-Algorithm.html
// Setup: map to the first quadrant. The "offsets" are the distance
// between the starting point and the next grid point.
let step_a = 1;
let offset_x = 1 - (x0 - a);
if (dx < 0) {
dx = -dx;
step_a = -step_a;
offset_x = 1 - offset_x;
}
// Zero offset means we're on a grid line, so we're actually a full cell
// away from the next grid line
if (offset_x === 0) {
offset_x = 1;
}
let step_b = 1;
let offset_y = 1 - (y0 - b);
if (dy < 0) {
dy = -dy;
step_b = -step_b;
offset_y = 1 - offset_y;
}
if (offset_y === 0) {
offset_y = 1;
}
let err = dy * offset_x - dx * offset_y;
let min_a = 0, min_b = 0;
// TODO get these passed in fool
let max_a = 31, max_b = 31;
if (dx > dy) {
// Main axis is x/a
while (min_a <= a && a <= max_a && min_b <= b && b <= max_b) {
yield [a, b];
if (a === goal_x && b === goal_y)
return;
if (err > 0) {
err -= dx;
b += step_b;
yield [a, b];
if (a === goal_x && b === goal_y)
return;
}
err += dy;
a += step_a;
}
}
else {
err = -err;
// Main axis is y/b
while (min_a <= a && a <= max_a && min_b <= b && b <= max_b) {
yield [a, b];
if (a === goal_x && b === goal_y)
return;
if (err > 0) {
err -= dy;
a += step_a;
yield [a, b];
if (a === goal_x && b === goal_y)
return;
}
err += dx;
b += step_b;
}
}
}

177
style.css
View File

@ -15,6 +15,9 @@ body {
} }
/* Generic element styling */ /* Generic element styling */
main[hidden] {
display: none !important;
}
input[type=radio], input[type=radio],
input[type=checkbox] { input[type=checkbox] {
margin: 0.125em; margin: 0.125em;
@ -32,6 +35,11 @@ h1, h2, h3, h4, h5, h6 {
font-weight: normal; font-weight: normal;
margin: 0; margin: 0;
} }
ul, ol {
margin: 0;
padding: 0;
list-style: none;
}
p { p {
margin: 0.5em 0; margin: 0.5em 0;
} }
@ -42,22 +50,6 @@ p:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
/* Main page structure */
body > header {
display: flex;
align-items: center;
padding: 0.5em;
background: #00080c;
}
body > header > h1 {
flex: 1;
font-size: 1.25rem;
}
body > header > nav {
display: flex;
gap: 0.5em;
}
/* Overlay styling */ /* Overlay styling */
.overlay { .overlay {
display: flex; display: flex;
@ -139,20 +131,98 @@ table.level-browser tr:hover {
border-radius: 0.25em; border-radius: 0.25em;
} }
/* Game area */
main { /**************************************************************************************************/
/* Main page structure */
body > header {
display: flex;
align-items: center;
background: #00080c;
}
body > header h1,
body > header h2,
body > header h3 {
margin: 0.25rem 0.5rem;
line-height: 1.125;
}
body > header h1 {
font-size: 1.66rem;
}
body > header h2 {
font-size: 1.33rem;
}
body > header h3 {
font-size: 1.25rem;
}
body > header > nav {
flex: 1;
display: flex;
justify-content: end;
gap: 0.5em;
margin: 0.25rem 0.5rem;
}
body > header button {
font-size: 0.75em;
}
body[data-mode=splash] #header-pack,
body[data-mode=splash] #header-level {
display: none;
}
#header-main {
border-bottom: 1px solid #404040;
box-shadow: 0 0 3px #0009;
}
/**************************************************************************************************/
/* Splash (intro part) */
#splash {
flex: 0;
margin: auto;
overflow: auto;
}
#level-pack-list {
margin: 1em 0;
}
#level-pack-list > li {
margin: 1em 0;
padding: 0.5em 1em;
background: #ececec;
color: black;
border: 2px solid black;
border-radius: 0.5em;
box-shadow: inset 0 0 2px 2px #0006, 0 2px 0.25em #0006;
cursor: pointer;
}
#level-pack-list > li:hover {
background: hsl(45, 60%, 90%);
border-color: hsl(45, 80%, 50%);
}
#level-pack-list > li p {
font-size: 0.833em;
font-style: italic;
color: #606060;
}
/**************************************************************************************************/
/* Player */
#player {
flex: 0; flex: 0;
margin: auto; /* center in both directions baby */ margin: auto; /* center in both directions baby */
isolation: isolate; isolation: isolate;
display: grid; display: grid;
align-items: center; align-items: center;
grid: grid:
"header header" "level chips" min-content
"level chips" "level time" min-content
"level time" "level bonus" min-content
"level bonus"
"level message" 1fr "level message" 1fr
"level inventory" "level inventory" min-content
"controls controls" "controls controls"
/* Need explicit min-content to force the hint to wrap */ /* Need explicit min-content to force the hint to wrap */
/ min-content min-content / min-content min-content
@ -167,25 +237,6 @@ main {
--scale: 1; --scale: 1;
} }
main > header {
grid-area: header;
display: grid;
grid-auto-columns: 1fr auto;
align-items: center;
gap: 0.25em;
}
main > header > h1,
main > header > h2 {
grid-column: 1;
line-height: 1;
}
main > header > nav {
grid-column: 2;
justify-self: end;
display: flex;
gap: 0.25em;
}
.level { .level {
grid-area: level; grid-area: level;
@ -261,6 +312,7 @@ dl.score-chart .-sum {
.chips, .chips,
.time, .time,
.bonus { .bonus {
font-size: calc(0.5em * var(--scale));
display: flex; display: flex;
align-items: center; align-items: center;
} }
@ -268,13 +320,13 @@ dl.score-chart .-sum {
.time h3, .time h3,
.bonus h3 { .bonus h3 {
flex: 1; flex: 1;
font-size: 1.25rem; font-size: 1.25em;
line-height: 1; line-height: 1;
} }
.chips output, .chips output,
.time output, .time output,
.bonus output { .bonus output {
flex: 0; flex: 1;
font-size: 2em; font-size: 2em;
padding: 0.125em; padding: 0.125em;
min-width: 2em; min-width: 2em;
@ -291,12 +343,16 @@ dl.score-chart .-sum {
grid-area: message; grid-area: message;
align-self: stretch; align-self: stretch;
padding: 0.5em; font-size: calc(0.75em * var(--scale));
padding: 0.25em 0.5em;
font-family: serif; font-family: serif;
font-style: italic; font-style: italic;
color: hsl(45, 100%, 60%); color: hsl(45, 100%, 60%);
background: #080808; background: #080808;
border: 1px inset #202020; border: 1px inset #202020;
/* FIXME find a way to enforce that the message never makes the grid get bigger */
overflow: auto;
} }
.message:empty { .message:empty {
display: none; display: none;
@ -304,6 +360,7 @@ dl.score-chart .-sum {
.inventory { .inventory {
grid-area: inventory; grid-area: inventory;
justify-self: center;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: start; align-items: start;
@ -365,3 +422,33 @@ main.--has-demo .demo-controls {
color: white; color: white;
background: hsl(225, 75%, 25%); background: hsl(225, 75%, 25%);
} }
/**************************************************************************************************/
/* Editor */
#editor {
display: grid;
grid:
"level palette"
;
max-width: 95%;
max-height: 95%;
margin: auto;
}
#editor .level {
grid-area: level;
overflow: auto;
}
#editor .palette {
grid-area: palette;
}
.palette-entry {
margin: 0.25em;
}
.palette-entry.--selected {
outline: 2px solid gold;
}