Merge remote-tracking branch 'upstream/master'
This commit is contained in:
commit
4388402850
BIN
icons/tool-camera.png
Normal file
BIN
icons/tool-camera.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 396 B |
15
index.html
15
index.html
@ -10,6 +10,7 @@
|
||||
<meta name="og:image" content="https://c.eev.ee/lexys-labyrinth/og-preview.png">
|
||||
<meta name="og:title" content="Lexy's Labyrinth">
|
||||
<meta name="og:description" content="A (work in progress) reimplementation of Chip's Challenge 1 and 2, using entirely free assets.">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<body data-mode="splash">
|
||||
<header id="header-main">
|
||||
@ -116,10 +117,10 @@
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div class="play-controls">
|
||||
<button class="control-pause" type="button">Pause (p)</button>
|
||||
<button class="control-pause" type="button">Pause <span class="keyhint">(p)</span></button>
|
||||
<button class="control-restart" type="button">Restart</button>
|
||||
<button class="control-undo" type="button">Undo</button>
|
||||
<button class="control-rewind" type="button">Rewind (z)</button>
|
||||
<button class="control-rewind" type="button">Rewind <span class="keyhint">(z)</span></button>
|
||||
<input class="turn-based" type="checkbox">Turn-Based</input>
|
||||
</div>
|
||||
<div class="demo-controls">
|
||||
@ -153,8 +154,14 @@
|
||||
-->
|
||||
</header>
|
||||
<div class="level"><!-- level canvas and any overlays go here --></div>
|
||||
<div class="controls">
|
||||
<nav class="controls">
|
||||
<div id="editor-toolbar">
|
||||
<!-- tools go here -->
|
||||
</div>
|
||||
<button id="editor-share-url" type="button">Share?</button>
|
||||
<div id="editor-tool-help">
|
||||
<strong>Pencil</strong> — <span>Select a tile and draw with the left mouse button. Erase with the right mouse button.</span>
|
||||
</div>
|
||||
<!--
|
||||
<p style>
|
||||
Tip: Right click to color drop.<br>
|
||||
@ -179,7 +186,7 @@
|
||||
map size
|
||||
</pre>
|
||||
-->
|
||||
</div>
|
||||
</nav>
|
||||
<div class="palette"></div>
|
||||
<!-- TODO:
|
||||
controls
|
||||
|
||||
@ -69,6 +69,7 @@ class CC2Demo {
|
||||
let modifier_wire = {
|
||||
decode(tile, modifier) {
|
||||
tile.wire_directions = modifier & 0x0f;
|
||||
// TODO wait, what happens if you use wire tunnels on steel or something other than floor?
|
||||
tile.wire_tunnel_directions = (modifier & 0xf0) >> 4;
|
||||
},
|
||||
encode(tile) {
|
||||
@ -340,7 +341,9 @@ const TILE_ENCODING = {
|
||||
0x46: {
|
||||
name: 'force_floor_all',
|
||||
},
|
||||
// 0x47: 'button_gray',
|
||||
0x47: {
|
||||
name: 'button_gray',
|
||||
},
|
||||
// FIXME swivel floors... argh...
|
||||
0x48: {
|
||||
name: 'swivel_sw',
|
||||
@ -902,7 +905,23 @@ export function parse_level(buf, number = 1) {
|
||||
}
|
||||
else if (section_type === 'RDNY') {
|
||||
}
|
||||
else if (section_type === 'END ') {
|
||||
// TODO LL custom chunks, should distinguish somehow
|
||||
else if (section_type === 'LXCM') {
|
||||
// Camera regions
|
||||
if (section_length % 4 !== 0)
|
||||
throw new Error(`Expected LXCM chunk to be a multiple of 4 bytes; got ${section_length}`);
|
||||
|
||||
let bytes = new Uint8Array(section_buf);
|
||||
let p = 0;
|
||||
while (p < section_length) {
|
||||
let x = bytes[p + 0];
|
||||
let y = bytes[p + 1];
|
||||
let w = bytes[p + 2];
|
||||
let h = bytes[p + 3];
|
||||
// TODO validate? must be smaller than map?
|
||||
level.camera_regions.push(new DOMRect(x, y, w, h));
|
||||
p += 4;
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.warn(`Unrecognized section type '${section_type}' at offset ${section_start}`);
|
||||
@ -1071,6 +1090,21 @@ export function synthesize_level(stored_level) {
|
||||
let c2m = new C2M;
|
||||
c2m.add_section('CC2M', '133');
|
||||
|
||||
// Store camera regions
|
||||
// TODO LL feature, should be distinguished somehow
|
||||
if (stored_level.camera_regions.length > 0) {
|
||||
let bytes = new Uint8Array(4 * stored_level.camera_regions.length);
|
||||
let p = 0;
|
||||
for (let region of stored_level.camera_regions) {
|
||||
bytes[p + 0] = region.x;
|
||||
bytes[p + 1] = region.y;
|
||||
bytes[p + 2] = region.width;
|
||||
bytes[p + 3] = region.height;
|
||||
p += 4;
|
||||
}
|
||||
c2m.add_section('LXCM', bytes.buffer);
|
||||
}
|
||||
|
||||
// FIXME well this will not do
|
||||
let map_bytes = new Uint8Array(4096);
|
||||
let map_view = new DataView(map_bytes.buffer);
|
||||
|
||||
@ -121,6 +121,8 @@ const TILE_ENCODING = {
|
||||
|
||||
function parse_level(buf, number) {
|
||||
let level = new util.StoredLevel(number);
|
||||
level.has_custom_connections = true;
|
||||
level.use_ccl_compat = true;
|
||||
// Map size is always fixed as 32x32 in CC1
|
||||
level.size_x = 32;
|
||||
level.size_y = 32;
|
||||
|
||||
@ -7,6 +7,7 @@ export class StoredCell extends Array {
|
||||
|
||||
export class StoredLevel {
|
||||
constructor(number) {
|
||||
// TODO still not sure this belongs here
|
||||
this.number = number; // one-based
|
||||
this.title = '';
|
||||
this.password = null;
|
||||
@ -16,6 +17,7 @@ export class StoredLevel {
|
||||
this.viewport_size = 9;
|
||||
this.extra_chunks = [];
|
||||
this.use_cc1_boots = false;
|
||||
this.use_ccl_compat = false;
|
||||
|
||||
this.size_x = 0;
|
||||
this.size_y = 0;
|
||||
@ -23,8 +25,13 @@ export class StoredLevel {
|
||||
|
||||
// Maps of button positions to trap/cloner positions, as scalar indexes
|
||||
// in the linear cell list
|
||||
// TODO merge these imo
|
||||
this.has_custom_connections = false;
|
||||
this.custom_trap_wiring = {};
|
||||
this.custom_cloner_wiring = {};
|
||||
|
||||
// New LL feature: custom camera regions, as lists of {x, y, width, height}
|
||||
this.camera_regions = [];
|
||||
}
|
||||
|
||||
scalar_to_coords(n) {
|
||||
|
||||
335
js/game.js
335
js/game.js
@ -125,6 +125,17 @@ export class Cell extends Array {
|
||||
return index;
|
||||
}
|
||||
|
||||
get_wired_tile() {
|
||||
let ret = null;
|
||||
for (let tile of this) {
|
||||
if (tile.wire_directions || tile.wire_tunnel_directions) {
|
||||
ret = tile;
|
||||
// Don't break; we want the topmost tile!
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
blocks_leaving(actor, direction) {
|
||||
for (let tile of this) {
|
||||
if (tile !== actor &&
|
||||
@ -148,6 +159,8 @@ export class Cell extends Array {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Cell.prototype.was_powered = false;
|
||||
Cell.prototype.is_powered = false;
|
||||
|
||||
class GameEnded extends Error {}
|
||||
|
||||
@ -162,7 +175,7 @@ export class Level {
|
||||
}
|
||||
|
||||
restart(compat) {
|
||||
this.compat = {};
|
||||
this.compat = compat;
|
||||
|
||||
// playing: normal play
|
||||
// success: has been won
|
||||
@ -205,6 +218,9 @@ export class Level {
|
||||
|
||||
let n = 0;
|
||||
let connectables = [];
|
||||
// Handle CC2 wiring; a contiguous region of wire is all updated as a single unit, so detect
|
||||
// those units ahead of time for simplicity and call them "clusters"
|
||||
this.wire_clusters = [];
|
||||
// FIXME handle traps correctly:
|
||||
// - if an actor is in the cell, set the trap to open and unstick everything in it
|
||||
for (let y = 0; y < this.height; y++) {
|
||||
@ -246,13 +262,11 @@ export class Level {
|
||||
}
|
||||
else if (tile.type.is_actor) {
|
||||
if (has_cloner) {
|
||||
// TODO is there any reason not to add clone templates to the actor
|
||||
// list?
|
||||
tile.stuck = true;
|
||||
}
|
||||
else {
|
||||
if (has_trap) {
|
||||
// FIXME wait, not if the trap is open! crap
|
||||
tile.stuck = true;
|
||||
}
|
||||
this.actors.push(tile);
|
||||
}
|
||||
}
|
||||
@ -272,60 +286,48 @@ export class Level {
|
||||
let x = cell.x;
|
||||
let y = cell.y;
|
||||
let goal = connectable.type.connects_to;
|
||||
let found = false;
|
||||
|
||||
// Check for custom wiring, for MSCC .DAT levels
|
||||
let n = x + y * this.width;
|
||||
let target_cell_n = null;
|
||||
if (goal === 'trap') {
|
||||
target_cell_n = this.stored_level.custom_trap_wiring[n] ?? null;
|
||||
}
|
||||
else if (goal === 'cloner') {
|
||||
target_cell_n = this.stored_level.custom_cloner_wiring[n] ?? null;
|
||||
}
|
||||
if (target_cell_n) {
|
||||
// TODO this N could be outside the map bounds
|
||||
let target_cell_x = target_cell_n % this.width;
|
||||
let target_cell_y = Math.floor(target_cell_n / this.width);
|
||||
for (let tile of this.cells[target_cell_y][target_cell_x]) {
|
||||
if (tile.type.name === goal) {
|
||||
connectable.connection = tile;
|
||||
found = true;
|
||||
break;
|
||||
if (this.stored_level.has_custom_connections) {
|
||||
let n = this.stored_level.coords_to_scalar(x, y);
|
||||
let target_cell_n = null;
|
||||
if (goal === 'trap') {
|
||||
target_cell_n = this.stored_level.custom_trap_wiring[n] ?? null;
|
||||
}
|
||||
else if (goal === 'cloner') {
|
||||
target_cell_n = this.stored_level.custom_cloner_wiring[n] ?? null;
|
||||
}
|
||||
if (target_cell_n && target_cell_n < this.width * this.height) {
|
||||
let [tx, ty] = this.stored_level.scalar_to_coords(target_cell_n);
|
||||
for (let tile of this.cells[ty][tx]) {
|
||||
if (tile.type.name === goal) {
|
||||
connectable.connection = tile;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (found)
|
||||
continue;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Otherwise, look in reading order
|
||||
let direction = 1;
|
||||
if (connectable.type.connect_order === 'backward') {
|
||||
direction = -1;
|
||||
for (let tile of this.iter_tiles_in_reading_order(cell, goal)) {
|
||||
// TODO ideally this should be a weak connection somehow, since dynamite can destroy
|
||||
// empty cloners and probably traps too
|
||||
connectable.connection = tile;
|
||||
// Just grab the first
|
||||
break;
|
||||
}
|
||||
for (let i = 0; i < num_cells - 1; i++) {
|
||||
x += direction;
|
||||
if (x >= this.width) {
|
||||
x -= this.width;
|
||||
y = (y + 1) % this.height;
|
||||
}
|
||||
else if (x < 0) {
|
||||
x += this.width;
|
||||
y = (y - 1 + this.height) % this.height;
|
||||
}
|
||||
}
|
||||
|
||||
for (let tile of this.cells[y][x]) {
|
||||
if (tile.type.name === goal) {
|
||||
// TODO should be weak, but you can't destroy cloners so in practice not a concern
|
||||
connectable.connection = tile;
|
||||
found = true;
|
||||
break;
|
||||
// Finally, let all tiles do any custom init behavior
|
||||
for (let row of this.cells) {
|
||||
for (let cell of row) {
|
||||
for (let tile of cell) {
|
||||
if (tile.type.on_ready) {
|
||||
tile.type.on_ready(tile, this);
|
||||
}
|
||||
}
|
||||
if (found)
|
||||
break;
|
||||
}
|
||||
// TODO soft warn for e.g. a button with no cloner? (or a cloner with no button?)
|
||||
}
|
||||
}
|
||||
|
||||
@ -430,6 +432,9 @@ export class Level {
|
||||
this.step_on_cell(actor, cell);
|
||||
}
|
||||
|
||||
// Now we handle wiring
|
||||
this.update_wiring();
|
||||
|
||||
// Only reset the player's is_pushing between movement, so it lasts for the whole push
|
||||
if (this.player.movement_cooldown <= 0) {
|
||||
this.player.is_pushing = false;
|
||||
@ -477,7 +482,7 @@ export class Level {
|
||||
if (actor.type.is_player && dir2 &&
|
||||
! old_cell.blocks_leaving(actor, dir2))
|
||||
{
|
||||
let neighbor = this.cell_with_offset(old_cell, dir2);
|
||||
let neighbor = this.get_neighboring_cell(old_cell, dir2);
|
||||
if (neighbor) {
|
||||
let could_push = ! neighbor.blocks_entering(actor, dir2, this, true);
|
||||
for (let tile of Array.from(neighbor)) {
|
||||
@ -544,10 +549,6 @@ export class Level {
|
||||
if (actor.movement_cooldown > 0)
|
||||
return;
|
||||
|
||||
// XXX does the cooldown drop while in a trap? is this even right?
|
||||
if (actor.stuck && ! actor.type.is_player)
|
||||
return;
|
||||
|
||||
// Teeth can only move the first 4 of every 8 tics, though "first"
|
||||
// can be adjusted
|
||||
if (actor.slide_mode == null &&
|
||||
@ -558,10 +559,15 @@ export class Level {
|
||||
}
|
||||
|
||||
let direction_preference;
|
||||
// Actors can't make voluntary moves on ice, so they're stuck with
|
||||
// whatever they've got
|
||||
if (this.compat.sliding_tanks_ignore_button &&
|
||||
actor.slide_mode && actor.pending_reverse)
|
||||
{
|
||||
this._set_prop(actor, 'pending_reverse', false);
|
||||
}
|
||||
if (actor.slide_mode === 'ice') {
|
||||
direction_preference = [actor.direction];
|
||||
// Actors can't make voluntary moves on ice; they just slide
|
||||
actor.decision = actor.direction;
|
||||
return;
|
||||
}
|
||||
else if (actor.slide_mode === 'force') {
|
||||
// Only the player can make voluntary moves on a force floor,
|
||||
@ -574,28 +580,38 @@ export class Level {
|
||||
p1_primary_direction &&
|
||||
actor.last_move_was_force)
|
||||
{
|
||||
if (p1_primary_direction != null)
|
||||
{
|
||||
direction_preference = [p1_primary_direction];
|
||||
this._set_prop(actor, 'last_move_was_force', false);
|
||||
}
|
||||
actor.decision = p1_primary_direction;
|
||||
this._set_prop(actor, 'last_move_was_force', false);
|
||||
}
|
||||
else {
|
||||
direction_preference = [actor.direction];
|
||||
actor.decision = actor.direction;
|
||||
if (actor === this.player) {
|
||||
this._set_prop(actor, 'last_move_was_force', true);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
else if (actor === this.player) {
|
||||
if (p1_primary_direction) {
|
||||
direction_preference = [p1_primary_direction];
|
||||
actor.decision = p1_primary_direction;
|
||||
this._set_prop(actor, 'last_move_was_force', false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
else if (actor.type.movement_mode === 'forward') {
|
||||
// blue tank behavior: keep moving forward
|
||||
direction_preference = [actor.direction];
|
||||
// blue tank behavior: keep moving forward, reverse if the flag is set
|
||||
let direction = actor.direction;
|
||||
if (actor.pending_reverse) {
|
||||
direction = DIRECTIONS[actor.direction].opposite;
|
||||
this._set_prop(actor, 'pending_reverse', false);
|
||||
}
|
||||
// Tanks are controlled explicitly so they don't check if they're blocked
|
||||
// TODO tanks in traps turn around, but tanks on cloners do not, and i use the same
|
||||
// prop for both
|
||||
if (! actor.cell.some(tile => tile.type.name === 'cloner')) {
|
||||
actor.decision = direction;
|
||||
}
|
||||
return;
|
||||
}
|
||||
else if (actor.type.movement_mode === 'follow-left') {
|
||||
// bug behavior: always try turning as left as possible, and
|
||||
@ -677,15 +693,9 @@ export class Level {
|
||||
|
||||
// Check which of those directions we *can*, probably, move in
|
||||
// TODO i think player on force floor will still have some issues here
|
||||
if (direction_preference) {
|
||||
// Players and sliding actors always move the way they want, even if blocked
|
||||
if (actor.type.is_player || actor.slide_mode) {
|
||||
actor.decision = direction_preference[0];
|
||||
return;
|
||||
}
|
||||
|
||||
if (direction_preference && ! actor.stuck) {
|
||||
for (let direction of direction_preference) {
|
||||
let dest_cell = this.cell_with_offset(actor.cell, direction);
|
||||
let dest_cell = this.get_neighboring_cell(actor.cell, direction);
|
||||
if (! dest_cell)
|
||||
continue;
|
||||
|
||||
@ -717,7 +727,7 @@ export class Level {
|
||||
|
||||
let move = DIRECTIONS[direction].movement;
|
||||
if (!actor.cell) console.error(actor);
|
||||
let goal_cell = this.cell_with_offset(actor.cell, direction);
|
||||
let goal_cell = this.get_neighboring_cell(actor.cell, direction);
|
||||
|
||||
// TODO this could be a lot simpler if i could early-return! should ice bumping be
|
||||
// somewhere else?
|
||||
@ -911,7 +921,7 @@ export class Level {
|
||||
}
|
||||
this.remove_tile(tile);
|
||||
}
|
||||
else if (tile.type.is_teleporter) {
|
||||
else if (tile.type.teleport_dest_order) {
|
||||
teleporter = tile;
|
||||
}
|
||||
else if (tile.type.on_arrive) {
|
||||
@ -922,42 +932,147 @@ export class Level {
|
||||
// Handle teleporting, now that the dust has cleared
|
||||
// FIXME something funny happening here, your input isn't ignore while walking out of it?
|
||||
if (teleporter) {
|
||||
let goal = teleporter;
|
||||
// TODO in pathological cases this might infinite loop
|
||||
while (true) {
|
||||
goal = goal.connection;
|
||||
|
||||
for (let dest of teleporter.type.teleport_dest_order(teleporter, this)) {
|
||||
// Teleporters already containing an actor are blocked and unusable
|
||||
if (goal.cell.some(tile => tile.type.is_actor && tile !== actor))
|
||||
if (dest.cell.some(tile => tile.type.is_actor && tile !== actor))
|
||||
continue;
|
||||
|
||||
// Physically move the actor to the new teleporter
|
||||
// XXX is this right, compare with tile world? i overhear it's actually implemented as a slide?
|
||||
// XXX not especially undo-efficient
|
||||
this.remove_tile(actor);
|
||||
this.add_tile(actor, goal.cell);
|
||||
this.add_tile(actor, dest.cell);
|
||||
if (this.attempt_step(actor, actor.direction)) {
|
||||
// Success, teleportation complete
|
||||
// Sound plays from the origin cell simply because that's where the sfx player
|
||||
// thinks the player is currently
|
||||
this.sfx.play_once('teleport', cell);
|
||||
// thinks the player is currently; position isn't updated til next turn
|
||||
this.sfx.play_once('teleport', dest.cell);
|
||||
break;
|
||||
}
|
||||
if (goal === teleporter)
|
||||
// We've tried every teleporter, including the one they
|
||||
// stepped on, so leave them on it
|
||||
break;
|
||||
|
||||
// Otherwise, try the next one
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cell_with_offset(cell, direction) {
|
||||
// Update the state of all wired tiles in the game.
|
||||
// XXX need to be clear on the order of events here. say everything starts out unpowered.
|
||||
// then:
|
||||
// 1. you step on a pink button, which flags itself as going to be powered next frame
|
||||
// 2. this pass happens. every unpowered-but-wired cell is inspected. if a powered one is
|
||||
// found, floodfill from there
|
||||
// FIXME can probably skip this if we know there are no wires at all, like in a CCL, or just an
|
||||
// unwired map
|
||||
// FIXME this feels inefficient. most of the time none of the inputs have changed so none of
|
||||
// this needs to happen at all
|
||||
// FIXME none of this is currently undoable
|
||||
update_wiring() {
|
||||
// Turn off power to every cell
|
||||
// TODO wonder if i need a linear cell list, or even a flat list of all tiles (that sounds
|
||||
// like hell to keep updated though)
|
||||
for (let row of this.cells) {
|
||||
for (let cell of row) {
|
||||
cell.was_powered = cell.is_powered;
|
||||
cell.is_powered = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Iterate through the grid looking for emitters — tiles that are generating current — and
|
||||
// propagated it via flood-fill through neighboring wires
|
||||
for (let row of this.cells) {
|
||||
for (let cell of row) {
|
||||
// TODO probably this should set a prop on the tile
|
||||
if (! cell.some(tile => tile.type.is_emitting && tile.type.is_emitting(tile, this)))
|
||||
continue;
|
||||
|
||||
// We have an emitter! Flood-fill outwards
|
||||
let neighbors = [cell];
|
||||
for (let neighbor of neighbors) {
|
||||
// Power it even if it's not wired itself, so that e.g. purple tiles work
|
||||
neighbor.is_powered = true;
|
||||
|
||||
let wire = neighbor.get_wired_tile();
|
||||
if (! wire)
|
||||
continue;
|
||||
|
||||
// Emit along every wire direction, and add any unpowered neighbors to the
|
||||
// pending list to continue the floodfill
|
||||
// TODO but only if wires connect
|
||||
// TODO handle wire tunnels
|
||||
for (let [direction, dirinfo] of Object.entries(DIRECTIONS)) {
|
||||
if (! (wire.wire_directions & dirinfo.bit))
|
||||
continue;
|
||||
|
||||
let neighbor2, wire2;
|
||||
let opposite_bit = DIRECTIONS[dirinfo.opposite].bit;
|
||||
if (wire.wire_tunnel_directions & dirinfo.bit) {
|
||||
// Search in the given direction until we find a matching tunnel
|
||||
// FIXME these act like nested parens!
|
||||
let x = neighbor.x;
|
||||
let y = neighbor.y;
|
||||
let nesting = 0;
|
||||
while (true) {
|
||||
x += dirinfo.movement[0];
|
||||
y += dirinfo.movement[1];
|
||||
if (! this.is_point_within_bounds(x, y))
|
||||
break;
|
||||
|
||||
let candidate = this.cells[y][x];
|
||||
wire2 = candidate.get_wired_tile();
|
||||
if (wire2 && (wire2.wire_tunnel_directions ?? 0) & opposite_bit) {
|
||||
neighbor2 = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Otherwise this is easy
|
||||
neighbor2 = this.get_neighboring_cell(neighbor, direction);
|
||||
wire2 = neighbor2.get_wired_tile();
|
||||
}
|
||||
|
||||
if (neighbor2 && ! neighbor2.is_powered &&
|
||||
// Unwired tiles are OK; they might be something activated by power.
|
||||
// Wired tiles that do NOT connect to us are ignored.
|
||||
(! wire2 || wire2.wire_directions & opposite_bit))
|
||||
{
|
||||
neighbors.push(neighbor2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inform any affected cells of power changes
|
||||
for (let row of this.cells) {
|
||||
for (let cell of row) {
|
||||
if (cell.was_powered !== cell.is_powered) {
|
||||
let method = cell.is_powered ? 'on_power' : 'on_depower';
|
||||
for (let tile of cell) {
|
||||
if (tile.type[method]) {
|
||||
tile.type[method](tile, this);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Performs a depth-first search for connected wires and wire objects, extending out from the
|
||||
// given starting cell
|
||||
*follow_circuit(cell) {
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Board inspection
|
||||
|
||||
is_point_within_bounds(x, y) {
|
||||
return (x >= 0 && x < this.width && y >= 0 && y < this.height);
|
||||
}
|
||||
|
||||
get_neighboring_cell(cell, direction) {
|
||||
let move = DIRECTIONS[direction].movement;
|
||||
let goal_x = cell.x + move[0];
|
||||
let goal_y = cell.y + move[1];
|
||||
if (goal_x >= 0 && goal_x < this.width && goal_y >= 0 && goal_y < this.height) {
|
||||
if (this.is_point_within_bounds(goal_x, goal_y)) {
|
||||
return this.cells[goal_y][goal_x];
|
||||
}
|
||||
else {
|
||||
@ -965,6 +1080,39 @@ export class Level {
|
||||
}
|
||||
}
|
||||
|
||||
// Iterates over the grid in (reverse?) reading order and yields all tiles with the given name.
|
||||
// The starting cell is iterated last.
|
||||
*iter_tiles_in_reading_order(start_cell, name, reverse = false) {
|
||||
let x = start_cell.x;
|
||||
let y = start_cell.y;
|
||||
while (true) {
|
||||
if (reverse) {
|
||||
x -= 1;
|
||||
if (x < 0) {
|
||||
x = this.width - 1;
|
||||
y = (y - 1 + this.height) % this.height;
|
||||
}
|
||||
}
|
||||
else {
|
||||
x += 1;
|
||||
if (x >= this.width) {
|
||||
x = 0;
|
||||
y = (y + 1) % this.height;
|
||||
}
|
||||
}
|
||||
|
||||
let cell = this.cells[y][x];
|
||||
for (let tile of cell) {
|
||||
if (tile.type.name === name) {
|
||||
yield tile;
|
||||
}
|
||||
}
|
||||
|
||||
if (cell === start_cell)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Undo handling
|
||||
|
||||
@ -1119,8 +1267,8 @@ export class Level {
|
||||
spawn_animation(cell, name) {
|
||||
let type = TILE_TYPES[name];
|
||||
let tile = new Tile(type);
|
||||
tile.animation_speed = type.ttl;
|
||||
tile.animation_progress = 0;
|
||||
this._set_prop(tile, 'animation_speed', tile.type.ttl);
|
||||
this._set_prop(tile, 'animation_progress', 0);
|
||||
cell._add(tile);
|
||||
this.actors.push(tile);
|
||||
this.pending_undo.push(() => {
|
||||
@ -1144,6 +1292,7 @@ export class Level {
|
||||
}
|
||||
}
|
||||
|
||||
// Give an item to an actor, even if it's not supposed to have an inventory
|
||||
give_actor(actor, name) {
|
||||
if (! actor.type.is_actor)
|
||||
return false;
|
||||
|
||||
105
js/main-base.js
Normal file
105
js/main-base.js
Normal file
@ -0,0 +1,105 @@
|
||||
import { mk, mk_svg, walk_grid } from './util.js';
|
||||
|
||||
// Superclass for the main display modes: the player, the editor, and the splash screen
|
||||
export class PrimaryView {
|
||||
constructor(conductor, root) {
|
||||
this.conductor = conductor;
|
||||
this.root = root;
|
||||
this.active = false;
|
||||
this._done_setup = false;
|
||||
}
|
||||
|
||||
setup() {}
|
||||
|
||||
activate() {
|
||||
this.root.removeAttribute('hidden');
|
||||
this.active = true;
|
||||
if (! this._done_setup) {
|
||||
this.setup();
|
||||
this._done_setup = true;
|
||||
}
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this.root.setAttribute('hidden', '');
|
||||
this.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Stackable modal overlay of some kind, usually a dialog
|
||||
export class Overlay {
|
||||
constructor(conductor, root) {
|
||||
this.conductor = conductor;
|
||||
this.root = root;
|
||||
|
||||
// Don't propagate clicks on the root element, so they won't trigger a
|
||||
// parent overlay's automatic dismissal
|
||||
this.root.addEventListener('click', ev => {
|
||||
ev.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
open() {
|
||||
// FIXME ah, but keystrokes can still go to the game, including
|
||||
// spacebar to begin it if it was waiting. how do i completely disable
|
||||
// an entire chunk of the page?
|
||||
if (this.conductor.player.state === 'playing') {
|
||||
this.conductor.player.set_state('paused');
|
||||
}
|
||||
|
||||
let overlay = mk('div.overlay', this.root);
|
||||
document.body.append(overlay);
|
||||
|
||||
// Remove the overlay when clicking outside the element
|
||||
overlay.addEventListener('click', ev => {
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.root.closest('.overlay').remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay styled like a dialog box
|
||||
export class DialogOverlay extends Overlay {
|
||||
constructor(conductor) {
|
||||
super(conductor, mk('div.dialog'));
|
||||
|
||||
this.root.append(
|
||||
this.header = mk('header'),
|
||||
this.main = mk('section'),
|
||||
this.footer = mk('footer'),
|
||||
);
|
||||
}
|
||||
|
||||
set_title(title) {
|
||||
this.header.textContent = '';
|
||||
this.header.append(mk('h1', {}, title));
|
||||
}
|
||||
|
||||
add_button(label, onclick) {
|
||||
let button = mk('button', {type: 'button'}, label);
|
||||
button.addEventListener('click', onclick);
|
||||
this.footer.append(button);
|
||||
}
|
||||
}
|
||||
|
||||
// Yes/no popup dialog
|
||||
export class ConfirmOverlay extends DialogOverlay {
|
||||
constructor(conductor, message, what) {
|
||||
super(conductor);
|
||||
this.set_title("just checking");
|
||||
this.main.append(mk('p', {}, message));
|
||||
let yes = mk('button', {type: 'button'}, "yep");
|
||||
let no = mk('button', {type: 'button'}, "nope");
|
||||
yes.addEventListener('click', ev => {
|
||||
this.close();
|
||||
what();
|
||||
});
|
||||
no.addEventListener('click', ev => {
|
||||
this.close();
|
||||
});
|
||||
this.footer.append(yes, no);
|
||||
}
|
||||
}
|
||||
796
js/main-editor.js
Normal file
796
js/main-editor.js
Normal file
@ -0,0 +1,796 @@
|
||||
import { DIRECTIONS, TICS_PER_SECOND } from './defs.js';
|
||||
import * as c2m from './format-c2m.js';
|
||||
import { PrimaryView, DialogOverlay } from './main-base.js';
|
||||
import CanvasRenderer from './renderer-canvas.js';
|
||||
import TILE_TYPES from './tiletypes.js';
|
||||
import { mk, mk_svg, walk_grid } from './util.js';
|
||||
|
||||
class EditorShareOverlay extends DialogOverlay {
|
||||
constructor(conductor, url) {
|
||||
super(conductor);
|
||||
this.set_title("give this to friends");
|
||||
this.main.append(mk('p', "Give this URL out to let others try your level:"));
|
||||
this.main.append(mk('p.editor-share-url', {}, url));
|
||||
let copy_button = mk('button', {type: 'button'}, "Copy to clipboard");
|
||||
copy_button.addEventListener('click', ev => {
|
||||
navigator.clipboard.writeText(url);
|
||||
// TODO feedback?
|
||||
});
|
||||
this.main.append(copy_button);
|
||||
|
||||
let ok = mk('button', {type: 'button'}, "neato");
|
||||
ok.addEventListener('click', ev => {
|
||||
this.close();
|
||||
});
|
||||
this.footer.append(ok);
|
||||
}
|
||||
}
|
||||
|
||||
// Stores and controls what the mouse is doing during a movement, mostly by dispatching to functions
|
||||
// defined for the individual tools
|
||||
const MOUSE_BUTTON_MASKS = [1, 4, 2]; // MouseEvent.button/buttons are ordered differently
|
||||
class MouseOperation {
|
||||
constructor(editor, ev, target = null) {
|
||||
this.editor = editor;
|
||||
this.target = target;
|
||||
this.button_mask = MOUSE_BUTTON_MASKS[ev.button];
|
||||
|
||||
// Client coordinates of the initial click
|
||||
this.mx0 = ev.clientX;
|
||||
this.my0 = ev.clientY;
|
||||
// Real cell coordinates (i.e. including fractional position within a cell) of the click
|
||||
[this.gx0f, this.gy0f] = this.editor.renderer.real_cell_coords_from_event(ev);
|
||||
// Cell coordinates
|
||||
this.gx0 = Math.floor(this.gx0f);
|
||||
this.gy0 = Math.floor(this.gy0f);
|
||||
|
||||
// Same as above but for the previous mouse position
|
||||
this.mx1 = this.mx0;
|
||||
this.my1 = this.mx1;
|
||||
this.gx1f = this.gx0f;
|
||||
this.gy1f = this.gy0f;
|
||||
this.gx1 = this.gx0;
|
||||
this.gy1 = this.gy0;
|
||||
|
||||
this.start(ev);
|
||||
}
|
||||
|
||||
cell(gx, gy) {
|
||||
return this.editor.stored_level.cells[Math.floor(gy)][Math.floor(gx)];
|
||||
}
|
||||
|
||||
do_mousemove(ev) {
|
||||
let [gxf, gyf] = this.editor.renderer.real_cell_coords_from_event(ev);
|
||||
let gx = Math.floor(gxf);
|
||||
let gy = Math.floor(gyf);
|
||||
|
||||
this.step(ev.clientX, ev.clientY, gxf, gyf, gx, gy);
|
||||
|
||||
this.mx1 = ev.clientX;
|
||||
this.my1 = ev.clientY;
|
||||
this.gx1f = gxf;
|
||||
this.gy1f = gyf;
|
||||
this.gx1 = gx;
|
||||
this.gy1 = gy;
|
||||
}
|
||||
|
||||
do_commit() {
|
||||
this.commit();
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
do_abort() {
|
||||
this.abort();
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
// Implement these
|
||||
start() {}
|
||||
step(x, y) {}
|
||||
commit() {}
|
||||
abort() {}
|
||||
cleanup() {}
|
||||
}
|
||||
|
||||
class PanOperation extends MouseOperation {
|
||||
step(mx, my) {
|
||||
this.editor.viewport_el.scrollLeft -= mx - this.mx1;
|
||||
this.editor.viewport_el.scrollTop -= my - this.my1;
|
||||
}
|
||||
}
|
||||
|
||||
class DrawOperation extends MouseOperation {
|
||||
}
|
||||
|
||||
class PencilOperation extends DrawOperation {
|
||||
start() {
|
||||
this.editor.place_in_cell(this.gx1, this.gy1, this.editor.palette_selection);
|
||||
}
|
||||
step(mx, my, gxf, gyf) {
|
||||
for (let [x, y] of walk_grid(this.gx1f, this.gy1f, gxf, gyf)) {
|
||||
this.editor.place_in_cell(x, y, this.editor.palette_selection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ForceFloorOperation extends DrawOperation {
|
||||
start() {
|
||||
// Begin by placing an all-way force floor under the mouse
|
||||
this.editor.place_in_cell(x, y, 'force_floor_all');
|
||||
}
|
||||
step(mx, my, gxf, gyf) {
|
||||
// Walk the mouse movement and change each we touch to match the direction we
|
||||
// crossed the border
|
||||
// FIXME occasionally i draw a tetris S kinda shape and both middle parts point
|
||||
// the same direction, but shouldn't
|
||||
let i = 0;
|
||||
let prevx, prevy;
|
||||
for (let [x, y] of walk_grid(this.gx1f, this.gy1f, gxf, gyf)) {
|
||||
i++;
|
||||
// The very first cell is the one the mouse was already in, and we don't
|
||||
// have a movement direction yet, so leave that alone
|
||||
if (i === 1) {
|
||||
prevx = x;
|
||||
prevy = y;
|
||||
continue;
|
||||
}
|
||||
let name;
|
||||
if (x === prevx) {
|
||||
if (y > prevy) {
|
||||
name = 'force_floor_s';
|
||||
}
|
||||
else {
|
||||
name = 'force_floor_n';
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (x > prevx) {
|
||||
name = 'force_floor_e';
|
||||
}
|
||||
else {
|
||||
name = 'force_floor_w';
|
||||
}
|
||||
}
|
||||
|
||||
// The second cell tells us the direction to use for the first, assuming it
|
||||
// had some kind of force floor
|
||||
if (i === 2) {
|
||||
let prevcell = this.editor.stored_level.cells[prevy][prevx];
|
||||
if (prevcell[0].type.name.startsWith('force_floor_')) {
|
||||
prevcell[0].type = TILE_TYPES[name];
|
||||
}
|
||||
}
|
||||
|
||||
// Drawing a loop with force floors creates ice (but not in the previous
|
||||
// cell, obviously)
|
||||
let cell = this.editor.stored_level.cells[y][x];
|
||||
if (cell[0].type.name.startsWith('force_floor_') &&
|
||||
cell[0].type.name !== name)
|
||||
{
|
||||
name = 'ice';
|
||||
}
|
||||
this.editor.place_in_cell(x, y, name);
|
||||
|
||||
prevx = x;
|
||||
prevy = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tiles the "adjust" tool will turn into each other
|
||||
const ADJUST_TOGGLES = {
|
||||
floor_custom_green: 'wall_custom_green',
|
||||
floor_custom_pink: 'wall_custom_pink',
|
||||
floor_custom_yellow: 'wall_custom_yellow',
|
||||
floor_custom_blue: 'wall_custom_blue',
|
||||
wall_custom_green: 'floor_custom_green',
|
||||
wall_custom_pink: 'floor_custom_pink',
|
||||
wall_custom_yellow: 'floor_custom_yellow',
|
||||
wall_custom_blue: 'floor_custom_blue',
|
||||
fake_floor: 'fake_wall',
|
||||
fake_wall: 'fake_floor',
|
||||
wall_invisible: 'wall_appearing',
|
||||
wall_appearing: 'wall_invisible',
|
||||
green_floor: 'green_wall',
|
||||
green_wall: 'green_floor',
|
||||
green_bomb: 'green_chip',
|
||||
green_chip: 'green_bomb',
|
||||
purple_floor: 'purple_wall',
|
||||
purple_wall: 'purple_floor',
|
||||
thief_keys: 'thief_tools',
|
||||
thief_tools: 'thief_keys',
|
||||
};
|
||||
class AdjustOperation extends MouseOperation {
|
||||
start() {
|
||||
let cell = this.editor.stored_level.cells[this.gy1][this.gx1];
|
||||
for (let tile of cell) {
|
||||
// Toggle tiles that go in obvious pairs
|
||||
let other = ADJUST_TOGGLES[tile.type.name];
|
||||
if (other) {
|
||||
tile.type = TILE_TYPES[other];
|
||||
}
|
||||
|
||||
// Rotate actors
|
||||
if (TILE_TYPES[tile.type.name].is_actor) {
|
||||
tile.direction = DIRECTIONS[tile.direction ?? 'south'].right;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Adjust tool doesn't support dragging
|
||||
// TODO should it?
|
||||
}
|
||||
|
||||
// FIXME currently allows creating outside the map bounds and moving beyond the right/bottom, sigh
|
||||
class CameraOperation extends MouseOperation {
|
||||
start(ev) {
|
||||
this.offset_x = 0;
|
||||
this.offset_y = 0;
|
||||
this.resize_x = 0;
|
||||
this.resize_y = 0;
|
||||
|
||||
let cursor;
|
||||
|
||||
this.target = ev.target.closest('.overlay-camera');
|
||||
if (! this.target) {
|
||||
// Clicking in empty space creates a new camera region
|
||||
this.mode = 'create';
|
||||
cursor = 'move';
|
||||
this.region = new DOMRect(this.gx0, this.gy0, 1, 1);
|
||||
this.target = mk_svg('rect.overlay-camera', {
|
||||
x: this.gx0, y: this.gy1, width: 1, height: 1,
|
||||
'data-region-index': this.editor.stored_level.camera_regions.length,
|
||||
});
|
||||
this.editor.connections_g.append(this.target);
|
||||
}
|
||||
else {
|
||||
this.region = this.editor.stored_level.camera_regions[parseInt(this.target.getAttribute('data-region-index'), 10)];
|
||||
|
||||
// If we're grabbing an edge, resize it
|
||||
let rect = this.target.getBoundingClientRect();
|
||||
let grab_left = (this.mx0 < rect.left + 16);
|
||||
let grab_right = (this.mx0 > rect.right - 16);
|
||||
let grab_top = (this.my0 < rect.top + 16);
|
||||
let grab_bottom = (this.my0 > rect.bottom - 16);
|
||||
if (grab_left || grab_right || grab_top || grab_bottom) {
|
||||
this.mode = 'resize';
|
||||
|
||||
if (grab_left) {
|
||||
this.resize_edge_x = -1;
|
||||
}
|
||||
else if (grab_right) {
|
||||
this.resize_edge_x = 1;
|
||||
}
|
||||
else {
|
||||
this.resize_edge_x = 0;
|
||||
}
|
||||
|
||||
if (grab_top) {
|
||||
this.resize_edge_y = -1;
|
||||
}
|
||||
else if (grab_bottom) {
|
||||
this.resize_edge_y = 1;
|
||||
}
|
||||
else {
|
||||
this.resize_edge_y = 0;
|
||||
}
|
||||
|
||||
if ((grab_top && grab_left) || (grab_bottom && grab_right)) {
|
||||
cursor = 'nwse-resize';
|
||||
}
|
||||
else if ((grab_top && grab_right) || (grab_bottom && grab_left)) {
|
||||
cursor = 'nesw-resize';
|
||||
}
|
||||
else if (grab_top || grab_bottom) {
|
||||
cursor = 'ns-resize';
|
||||
}
|
||||
else {
|
||||
cursor = 'ew-resize';
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.mode = 'move';
|
||||
cursor = 'move';
|
||||
}
|
||||
}
|
||||
|
||||
this.editor.viewport_el.style.cursor = cursor;
|
||||
|
||||
// Create a text element to show the size while editing
|
||||
this.size_text = mk_svg('text.overlay-edit-tip', {
|
||||
// Center it within the rectangle probably (x and y are set in _update_size_text)
|
||||
'text-anchor': 'middle', 'dominant-baseline': 'middle',
|
||||
});
|
||||
this._update_size_text();
|
||||
this.editor.svg_overlay.append(this.size_text);
|
||||
}
|
||||
_update_size_text() {
|
||||
this.size_text.setAttribute('x', this.region.x + this.offset_x + (this.region.width + this.resize_x) / 2);
|
||||
this.size_text.setAttribute('y', this.region.y + this.offset_y + (this.region.height + this.resize_y) / 2);
|
||||
this.size_text.textContent = `${this.region.width + this.resize_x} × ${this.region.height + this.resize_y}`;
|
||||
}
|
||||
step(mx, my, gxf, gyf, gx, gy) {
|
||||
// FIXME not right if we zoom, should use gxf
|
||||
let dx = Math.floor((mx - this.mx0) / this.editor.conductor.tileset.size_x + 0.5);
|
||||
let dy = Math.floor((my - this.my0) / this.editor.conductor.tileset.size_y + 0.5);
|
||||
|
||||
let stored_level = this.editor.stored_level;
|
||||
if (this.mode === 'create') {
|
||||
// Just make the new region span between the original click and the new position
|
||||
this.region.x = Math.min(gx, this.gx0);
|
||||
this.region.y = Math.min(gy, this.gy0);
|
||||
this.region.width = Math.max(gx, this.gx0) + 1 - this.region.x;
|
||||
this.region.height = Math.max(gy, this.gy0) + 1 - this.region.y;
|
||||
}
|
||||
else if (this.mode === 'move') {
|
||||
// Keep it within the map!
|
||||
this.offset_x = Math.max(- this.region.x, Math.min(stored_level.size_x - this.region.width, dx));
|
||||
this.offset_y = Math.max(- this.region.y, Math.min(stored_level.size_y - this.region.height, dy));
|
||||
}
|
||||
else {
|
||||
// Resize, based on the edge we originally grabbed
|
||||
if (this.resize_edge_x < 0) {
|
||||
// Left
|
||||
dx = Math.max(-this.region.x, Math.min(this.region.width - 1, dx));
|
||||
this.resize_x = -dx;
|
||||
this.offset_x = dx;
|
||||
}
|
||||
else if (this.resize_edge_x > 0) {
|
||||
// Right
|
||||
dx = Math.max(-(this.region.width - 1), Math.min(stored_level.size_x - this.region.right, dx));
|
||||
this.resize_x = dx;
|
||||
this.offset_x = 0;
|
||||
}
|
||||
|
||||
if (this.resize_edge_y < 0) {
|
||||
// Top
|
||||
dy = Math.max(-this.region.y, Math.min(this.region.height - 1, dy));
|
||||
this.resize_y = -dy;
|
||||
this.offset_y = dy;
|
||||
}
|
||||
else if (this.resize_edge_y > 0) {
|
||||
// Bottom
|
||||
dy = Math.max(-(this.region.height - 1), Math.min(stored_level.size_y - this.region.bottom, dy));
|
||||
this.resize_y = dy;
|
||||
this.offset_y = 0;
|
||||
}
|
||||
}
|
||||
|
||||
this.target.setAttribute('x', this.region.x + this.offset_x);
|
||||
this.target.setAttribute('y', this.region.y + this.offset_y);
|
||||
this.target.setAttribute('width', this.region.width + this.resize_x);
|
||||
this.target.setAttribute('height', this.region.height + this.resize_y);
|
||||
this._update_size_text();
|
||||
}
|
||||
commit() {
|
||||
if (this.mode === 'create') {
|
||||
// Region is already updated, just add it to the level
|
||||
this.editor.stored_level.camera_regions.push(this.region);
|
||||
}
|
||||
else {
|
||||
// Actually edit the underlying region
|
||||
this.region.x += this.offset_x;
|
||||
this.region.y += this.offset_y;
|
||||
this.region.width += this.resize_x;
|
||||
this.region.height += this.resize_y;
|
||||
}
|
||||
}
|
||||
abort() {
|
||||
if (this.mode === 'create') {
|
||||
// The element was fake, so delete it
|
||||
this.target.remove();
|
||||
}
|
||||
else {
|
||||
// Move the element back to its original location
|
||||
this.target.setAttribute('x', this.region.x);
|
||||
this.target.setAttribute('y', this.region.y);
|
||||
this.target.setAttribute('width', this.region.width);
|
||||
this.target.setAttribute('height', this.region.height);
|
||||
}
|
||||
}
|
||||
cleanup() {
|
||||
this.editor.viewport_el.style.cursor = '';
|
||||
this.size_text.remove();
|
||||
}
|
||||
}
|
||||
|
||||
class CameraEraseOperation extends MouseOperation {
|
||||
start(ev) {
|
||||
let target = ev.target.closest('.overlay-camera');
|
||||
if (target) {
|
||||
let index = parseInt(target.getAttribute('data-region-index'), 10);
|
||||
target.remove();
|
||||
this.editor.stored_level.camera_regions.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const EDITOR_TOOLS = {
|
||||
pencil: {
|
||||
icon: 'icons/tool-pencil.png',
|
||||
name: "Pencil",
|
||||
desc: "Draw individual tiles",
|
||||
op1: PencilOperation,
|
||||
//op2: EraseOperation,
|
||||
},
|
||||
line: {
|
||||
// TODO not implemented
|
||||
icon: 'icons/tool-line.png',
|
||||
name: "Line",
|
||||
desc: "Draw straight lines",
|
||||
},
|
||||
box: {
|
||||
// TODO not implemented
|
||||
icon: 'icons/tool-box.png',
|
||||
name: "Box",
|
||||
desc: "Fill a rectangular area with tiles",
|
||||
},
|
||||
fill: {
|
||||
// TODO not implemented
|
||||
icon: 'icons/tool-fill.png',
|
||||
name: "Fill",
|
||||
desc: "Flood-fill an area with tiles",
|
||||
},
|
||||
'force-floors': {
|
||||
icon: 'icons/tool-force-floors.png',
|
||||
name: "Force floors",
|
||||
desc: "Draw force floors in the direction you draw",
|
||||
op1: ForceFloorOperation,
|
||||
},
|
||||
adjust: {
|
||||
icon: 'icons/tool-adjust.png',
|
||||
name: "Adjust",
|
||||
desc: "Toggle blocks and rotate actors",
|
||||
op1: AdjustOperation,
|
||||
},
|
||||
connect: {
|
||||
// TODO not implemented
|
||||
icon: 'icons/tool-connect.png',
|
||||
name: "Connect",
|
||||
desc: "Set up CC1 clone and trap connections",
|
||||
},
|
||||
wire: {
|
||||
// TODO not implemented
|
||||
icon: 'icons/tool-wire.png',
|
||||
name: "Wire",
|
||||
desc: "Draw CC2 wiring",
|
||||
},
|
||||
camera: {
|
||||
icon: 'icons/tool-camera.png',
|
||||
name: "Camera",
|
||||
desc: "Draw and edit custom camera regions",
|
||||
help: "Draw and edit camera regions. Right-click to erase a region. When the player is within a camera region, the camera will avoid showing anything outside that region. LL only.",
|
||||
op1: CameraOperation,
|
||||
op2: CameraEraseOperation,
|
||||
},
|
||||
// TODO text tool; thin walls tool; ice tool; map generator?; subtools for select tool (copy, paste, crop)
|
||||
// TODO interesting option: rotate an actor as you draw it by dragging? or hold a key like in
|
||||
// slade when you have some selected?
|
||||
// TODO ah, railroads...
|
||||
};
|
||||
const EDITOR_TOOL_ORDER = ['pencil', 'force-floors', 'adjust', 'camera'];
|
||||
|
||||
// TODO this MUST use a LL tileset!
|
||||
const EDITOR_PALETTE = [{
|
||||
title: "Basics",
|
||||
tiles: [
|
||||
'player',
|
||||
'chip', 'chip_extra',
|
||||
'floor', 'wall', 'hint', 'socket', 'exit',
|
||||
],
|
||||
}, {
|
||||
title: "Terrain",
|
||||
tiles: [
|
||||
'popwall',
|
||||
'fake_floor', 'fake_wall',
|
||||
'wall_invisible', 'wall_appearing',
|
||||
'gravel',
|
||||
'dirt',
|
||||
'door_blue', 'door_red', 'door_yellow', 'door_green',
|
||||
'water', 'turtle', 'fire',
|
||||
'ice', 'ice_nw', 'ice_ne', 'ice_sw', 'ice_se',
|
||||
'force_floor_n', 'force_floor_s', 'force_floor_w', 'force_floor_e', 'force_floor_all',
|
||||
],
|
||||
}, {
|
||||
title: "Items",
|
||||
tiles: [
|
||||
'key_blue', 'key_red', 'key_yellow', 'key_green',
|
||||
'flippers', 'fire_boots', 'cleats', 'suction_boots',
|
||||
],
|
||||
}, {
|
||||
title: "Creatures",
|
||||
tiles: [
|
||||
'tank_blue',
|
||||
'ball',
|
||||
'fireball',
|
||||
'glider',
|
||||
'bug',
|
||||
'paramecium',
|
||||
'walker',
|
||||
'teeth',
|
||||
'blob',
|
||||
],
|
||||
}, {
|
||||
title: "Mechanisms",
|
||||
tiles: [
|
||||
'bomb',
|
||||
'dirt_block',
|
||||
'ice_block',
|
||||
'button_gray',
|
||||
'button_green',
|
||||
'green_floor',
|
||||
'green_wall',
|
||||
'green_chip',
|
||||
'green_bomb',
|
||||
'button_blue',
|
||||
'button_red', 'cloner',
|
||||
'button_brown', 'trap',
|
||||
'teleport_blue',
|
||||
'teleport_red',
|
||||
'teleport_green',
|
||||
'teleport_yellow',
|
||||
],
|
||||
}];
|
||||
|
||||
export class Editor extends PrimaryView {
|
||||
constructor(conductor) {
|
||||
super(conductor, document.body.querySelector('main#editor'));
|
||||
|
||||
this.viewport_el = this.root.querySelector('.level');
|
||||
|
||||
// FIXME don't hardcode size here, convey this to renderer some other way
|
||||
this.renderer = new CanvasRenderer(this.conductor.tileset, 32);
|
||||
|
||||
// FIXME need this in load_level which is called even if we haven't been setup yet
|
||||
this.connections_g = mk_svg('g');
|
||||
}
|
||||
|
||||
setup() {
|
||||
// Level canvas and mouse handling
|
||||
// This SVG draws vectors on top of the editor, like monster paths and button connections
|
||||
// FIXME change viewBox in load_level, can't right now because order of ops
|
||||
this.svg_overlay = mk_svg('svg.level-editor-overlay', {viewBox: '0 0 32 32'}, this.connections_g);
|
||||
this.viewport_el.append(this.renderer.canvas, this.svg_overlay);
|
||||
this.mouse_op = null;
|
||||
this.viewport_el.addEventListener('mousedown', ev => {
|
||||
this.cancel_mouse_operation();
|
||||
|
||||
if (ev.button === 0) {
|
||||
// Left button: activate tool
|
||||
let op_type = EDITOR_TOOLS[this.current_tool].op1;
|
||||
if (! op_type)
|
||||
return;
|
||||
|
||||
this.mouse_op = new op_type(this, ev);
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
this.renderer.draw();
|
||||
}
|
||||
else if (ev.button === 1) {
|
||||
// Middle button: always pan
|
||||
this.mouse_op = new PanOperation(this, ev);
|
||||
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}
|
||||
else if (ev.button === 2) {
|
||||
// Right button: activate tool's alt mode
|
||||
let op_type = EDITOR_TOOLS[this.current_tool].op2;
|
||||
if (! op_type)
|
||||
return;
|
||||
|
||||
this.mouse_op = new op_type(this, ev);
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
this.renderer.draw();
|
||||
}
|
||||
});
|
||||
this.viewport_el.addEventListener('mousemove', ev => {
|
||||
if (! this.mouse_op)
|
||||
return;
|
||||
if ((ev.buttons & this.mouse_op.button_mask) === 0) {
|
||||
this.cancel_mouse_operation();
|
||||
return;
|
||||
}
|
||||
|
||||
this.mouse_op.do_mousemove(ev);
|
||||
|
||||
this.renderer.draw();
|
||||
});
|
||||
// TODO should this happen for a mouseup anywhere?
|
||||
this.viewport_el.addEventListener('mouseup', ev => {
|
||||
if (this.mouse_op) {
|
||||
this.mouse_op.do_commit();
|
||||
this.mouse_op = null;
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}
|
||||
});
|
||||
// Disable context menu, which interferes with right-click tools
|
||||
this.viewport_el.addEventListener('contextmenu', ev => {
|
||||
ev.preventDefault();
|
||||
});
|
||||
window.addEventListener('blur', ev => {
|
||||
this.cancel_mouse_operation();
|
||||
});
|
||||
|
||||
// Toolbar buttons
|
||||
this.root.querySelector('#editor-share-url').addEventListener('click', ev => {
|
||||
let buf = c2m.synthesize_level(this.stored_level);
|
||||
// FIXME Not ideal, but btoa() wants a string rather than any of the myriad binary types
|
||||
let stringy_buf = Array.from(new Uint8Array(buf)).map(n => String.fromCharCode(n)).join('');
|
||||
// Make URL-safe and strip trailing padding
|
||||
let data = btoa(stringy_buf).replace(/[+]/g, '-').replace(/[/]/g, '_').replace(/=+$/, '');
|
||||
let url = new URL(location);
|
||||
url.searchParams.delete('level');
|
||||
url.searchParams.delete('setpath');
|
||||
url.searchParams.append('level', data);
|
||||
new EditorShareOverlay(this.conductor, url.toString()).open();
|
||||
});
|
||||
|
||||
// Toolbox
|
||||
let toolbox = mk('div.icon-button-set')
|
||||
this.root.querySelector('.controls').append(toolbox);
|
||||
this.tool_button_els = {};
|
||||
for (let toolname of EDITOR_TOOL_ORDER) {
|
||||
let tooldef = EDITOR_TOOLS[toolname];
|
||||
let button = mk(
|
||||
'button', {
|
||||
type: 'button',
|
||||
'data-tool': toolname,
|
||||
},
|
||||
mk('img', {
|
||||
src: tooldef.icon,
|
||||
alt: tooldef.name,
|
||||
title: `${tooldef.name}: ${tooldef.desc}`,
|
||||
}),
|
||||
);
|
||||
this.tool_button_els[toolname] = button;
|
||||
toolbox.append(button);
|
||||
}
|
||||
this.current_tool = 'pencil';
|
||||
this.tool_button_els['pencil'].classList.add('-selected');
|
||||
toolbox.addEventListener('click', ev => {
|
||||
let button = ev.target.closest('.icon-button-set button');
|
||||
if (! button)
|
||||
return;
|
||||
|
||||
this.select_tool(button.getAttribute('data-tool'));
|
||||
});
|
||||
|
||||
// Tile palette
|
||||
let palette_el = this.root.querySelector('.palette');
|
||||
this.palette = {}; // name => element
|
||||
for (let sectiondef of EDITOR_PALETTE) {
|
||||
let section_el = mk('section');
|
||||
palette_el.append(mk('h2', sectiondef.title), section_el);
|
||||
for (let name of sectiondef.tiles) {
|
||||
let entry = this.renderer.create_tile_type_canvas(name);
|
||||
entry.setAttribute('data-tile-name', name);
|
||||
entry.classList = 'palette-entry';
|
||||
this.palette[name] = entry;
|
||||
section_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');
|
||||
}
|
||||
|
||||
activate() {
|
||||
super.activate();
|
||||
this.renderer.draw();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Load connections
|
||||
this.connections_g.textContent = '';
|
||||
for (let [src, dest] of Object.entries(this.stored_level.custom_trap_wiring)) {
|
||||
let [sx, sy] = this.stored_level.scalar_to_coords(src);
|
||||
let [dx, dy] = this.stored_level.scalar_to_coords(dest);
|
||||
this.connections_g.append(
|
||||
mk_svg('rect.overlay-cxn', {x: sx, y: sy, width: 1, height: 1}),
|
||||
mk_svg('line.overlay-cxn', {x1: sx + 0.5, y1: sy + 0.5, x2: dx + 0.5, y2: dy + 0.5}),
|
||||
);
|
||||
}
|
||||
// TODO why are these in connections_g lol
|
||||
for (let [i, region] of this.stored_level.camera_regions.entries()) {
|
||||
let el = mk_svg('rect.overlay-camera', {x: region.x, y: region.y, width: region.width, height: region.height});
|
||||
this.connections_g.append(el);
|
||||
}
|
||||
|
||||
this.renderer.set_level(stored_level);
|
||||
if (this.active) {
|
||||
this.renderer.draw();
|
||||
}
|
||||
}
|
||||
|
||||
select_tool(tool) {
|
||||
if (tool === this.current_tool)
|
||||
return;
|
||||
if (! this.tool_button_els[tool])
|
||||
return;
|
||||
|
||||
this.tool_button_els[this.current_tool].classList.remove('-selected');
|
||||
this.current_tool = tool;
|
||||
this.tool_button_els[this.current_tool].classList.add('-selected');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
// Some tools obviously don't work with a palette selection, in which case changing tiles
|
||||
// should default you back to the pencil
|
||||
if (this.current_tool === 'adjust') {
|
||||
this.select_tool('pencil');
|
||||
}
|
||||
}
|
||||
|
||||
place_in_cell(x, y, name) {
|
||||
// TODO weird api?
|
||||
if (! name)
|
||||
return;
|
||||
|
||||
let type = TILE_TYPES[name];
|
||||
let cell = this.stored_level.cells[y][x];
|
||||
// For terrain tiles, erase the whole cell. For other tiles, only
|
||||
// replace whatever's on the same layer
|
||||
// TODO probably not the best heuristic yet, since i imagine you can
|
||||
// combine e.g. the tent with thin walls
|
||||
if (type.draw_layer === 0) {
|
||||
cell.length = 0;
|
||||
cell.push({type});
|
||||
}
|
||||
else {
|
||||
for (let i = cell.length - 1; i >= 0; i--) {
|
||||
if (cell[i].type.draw_layer === type.draw_layer) {
|
||||
cell.splice(i, 1);
|
||||
}
|
||||
}
|
||||
cell.push({type});
|
||||
cell.sort((a, b) => a.type.draw_layer - b.type.draw_layer);
|
||||
}
|
||||
}
|
||||
|
||||
cancel_mouse_operation() {
|
||||
if (this.mouse_op) {
|
||||
this.mouse_op.do_abort();
|
||||
this.mouse_op = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
830
js/main.js
830
js/main.js
@ -5,120 +5,15 @@ import * as c2m from './format-c2m.js';
|
||||
import * as dat from './format-dat.js';
|
||||
import * as format_util from './format-util.js';
|
||||
import { Level } from './game.js';
|
||||
import { PrimaryView, Overlay, DialogOverlay, ConfirmOverlay } from './main-base.js';
|
||||
import { Editor } from './main-editor.js';
|
||||
import CanvasRenderer from './renderer-canvas.js';
|
||||
import SOUNDTRACK from './soundtrack.js';
|
||||
import { Tileset, CC2_TILESET_LAYOUT, LL_TILESET_LAYOUT, TILE_WORLD_TILESET_LAYOUT } from './tileset.js';
|
||||
import TILE_TYPES from './tiletypes.js';
|
||||
import { random_choice, mk, mk_svg, promise_event, fetch, walk_grid } from './util.js';
|
||||
import { random_choice, mk, mk_svg, promise_event, fetch } from './util.js';
|
||||
|
||||
const PAGE_TITLE = "Lexy's Labyrinth";
|
||||
// Stackable modal overlay of some kind, usually a dialog
|
||||
class Overlay {
|
||||
constructor(conductor, root) {
|
||||
this.conductor = conductor;
|
||||
this.root = root;
|
||||
|
||||
// Don't propagate clicks on the root element, so they won't trigger a
|
||||
// parent overlay's automatic dismissal
|
||||
this.root.addEventListener('click', ev => {
|
||||
ev.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
open() {
|
||||
// FIXME ah, but keystrokes can still go to the game, including
|
||||
// spacebar to begin it if it was waiting. how do i completely disable
|
||||
// an entire chunk of the page?
|
||||
if (this.conductor.player.state === 'playing') {
|
||||
this.conductor.player.set_state('paused');
|
||||
}
|
||||
|
||||
let overlay = mk('div.overlay', this.root);
|
||||
document.body.append(overlay);
|
||||
|
||||
// Remove the overlay when clicking outside the element
|
||||
overlay.addEventListener('click', ev => {
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.root.closest('.overlay').remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay styled like a dialog box
|
||||
class DialogOverlay extends Overlay {
|
||||
constructor(conductor) {
|
||||
super(conductor, mk('div.dialog'));
|
||||
|
||||
this.root.append(
|
||||
this.header = mk('header'),
|
||||
this.main = mk('section'),
|
||||
this.footer = mk('footer'),
|
||||
);
|
||||
}
|
||||
|
||||
set_title(title) {
|
||||
this.header.textContent = '';
|
||||
this.header.append(mk('h1', {}, title));
|
||||
}
|
||||
|
||||
add_button(label, onclick) {
|
||||
let button = mk('button', {type: 'button'}, label);
|
||||
button.addEventListener('click', onclick);
|
||||
this.footer.append(button);
|
||||
}
|
||||
}
|
||||
|
||||
// Yes/no popup dialog
|
||||
class ConfirmOverlay extends DialogOverlay {
|
||||
constructor(conductor, message, what) {
|
||||
super(conductor);
|
||||
this.set_title("just checking");
|
||||
this.main.append(mk('p', {}, message));
|
||||
let yes = mk('button', {type: 'button'}, "yep");
|
||||
let no = mk('button', {type: 'button'}, "nope");
|
||||
yes.addEventListener('click', ev => {
|
||||
this.close();
|
||||
what();
|
||||
});
|
||||
no.addEventListener('click', ev => {
|
||||
this.close();
|
||||
});
|
||||
this.footer.append(yes, no);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Main display... modes
|
||||
|
||||
class PrimaryView {
|
||||
constructor(conductor, root) {
|
||||
this.conductor = conductor;
|
||||
this.root = root;
|
||||
this.active = false;
|
||||
this._done_setup = false;
|
||||
}
|
||||
|
||||
setup() {}
|
||||
|
||||
activate() {
|
||||
this.root.removeAttribute('hidden');
|
||||
this.active = true;
|
||||
if (! this._done_setup) {
|
||||
this.setup();
|
||||
this._done_setup = true;
|
||||
}
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this.root.setAttribute('hidden', '');
|
||||
this.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// TODO:
|
||||
// - level password, if any
|
||||
@ -215,7 +110,13 @@ const OBITUARIES = {
|
||||
// Helper class used to let the game play sounds without knowing too much about the Player
|
||||
class SFXPlayer {
|
||||
constructor() {
|
||||
this.ctx = new window.AudioContext;
|
||||
this.ctx = new (window.AudioContext || window.webkitAudioContext); // come the fuck on, safari
|
||||
// This automatically reduces volume when a lot of sound effects are playing at once
|
||||
this.compressor_node = this.ctx.createDynamicsCompressor();
|
||||
this.compressor_node.threshold.value = -40;
|
||||
this.compressor_node.ratio.value = 16;
|
||||
this.compressor_node.connect(this.ctx.destination);
|
||||
|
||||
this.player_x = null;
|
||||
this.player_y = null;
|
||||
this.sounds = {};
|
||||
@ -310,17 +211,17 @@ class SFXPlayer {
|
||||
let dist = Math.sqrt(dx*dx + dy*dy);
|
||||
let gain = this.ctx.createGain();
|
||||
// x/(x + a) is a common and delightful way to get an easy asymptote and output between
|
||||
// 0 and 1. Here, the result is above 80% for almost everything on screen; drops down
|
||||
// to 50% for things 20 tiles away (which is, roughly, the periphery when standing in
|
||||
// the center of a CC1 map), and bottoms out at 12.5% for standing in one corner of a
|
||||
// 0 and 1. Here, the result is above 2/3 for almost everything on screen; drops down
|
||||
// to 1/3 for things 20 tiles away (which is, roughly, the periphery when standing in
|
||||
// the center of a CC1 map), and bottoms out at 1/15 for standing in one corner of a
|
||||
// CC2 map of max size and hearing something on the far opposite corner.
|
||||
gain.gain.value = 1 - dist / (dist + 20);
|
||||
gain.gain.value = 1 - dist / (dist + 10);
|
||||
node.connect(gain);
|
||||
gain.connect(this.ctx.destination);
|
||||
gain.connect(this.compressor_node);
|
||||
}
|
||||
else {
|
||||
// Play at full volume
|
||||
node.connect(this.ctx.destination);
|
||||
node.connect(this.compressor_node);
|
||||
}
|
||||
node.start(this.ctx.currentTime);
|
||||
}
|
||||
@ -355,7 +256,12 @@ class Player extends PrimaryView {
|
||||
this.scale = 1;
|
||||
|
||||
this.compat = {
|
||||
popwalls_react_on_arrive: false,
|
||||
auto_convert_ccl_popwalls: true,
|
||||
auto_convert_ccl_blue_walls: true,
|
||||
sliding_tanks_ignore_button: true,
|
||||
tiles_react_instantly: false,
|
||||
allow_flick: false,
|
||||
};
|
||||
|
||||
this.root.style.setProperty('--tile-width', `${this.conductor.tileset.size_x}px`);
|
||||
@ -508,6 +414,7 @@ class Player extends PrimaryView {
|
||||
let key_target = document.body;
|
||||
this.previous_input = new Set; // actions that were held last tic
|
||||
this.previous_action = null; // last direction we were moving, if any
|
||||
this.using_touch = false; // true if using touch controls
|
||||
this.current_keys = new Set; // keys that are currently held
|
||||
this.current_keys_new = new Set; //for keys that have only been held a frame
|
||||
// TODO this could all probably be more rigorous but it's fine for now
|
||||
@ -554,6 +461,7 @@ class Player extends PrimaryView {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
|
||||
// TODO for demo compat, this should happen as part of input reading?
|
||||
if (this.state === 'waiting') {
|
||||
this.set_state('playing');
|
||||
}
|
||||
@ -572,13 +480,97 @@ class Player extends PrimaryView {
|
||||
ev.preventDefault();
|
||||
}
|
||||
});
|
||||
// Similarly, grab touch events and translate them to directions
|
||||
this.current_touches = {}; // ident => action
|
||||
let touch_target = this.root.querySelector('.-main-area');
|
||||
let collect_touches = ev => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
|
||||
// If state is anything other than playing/waiting, probably switch to playing, similar
|
||||
// to pressing spacebar
|
||||
if (ev.type === 'touchstart') {
|
||||
if (this.state === 'paused') {
|
||||
this.toggle_pause();
|
||||
return;
|
||||
}
|
||||
else if (this.state === 'stopped') {
|
||||
if (this.level.state === 'success') {
|
||||
// Advance to the next level
|
||||
// TODO game ending?
|
||||
// TODO this immediately begins it too, not sure why
|
||||
this.conductor.change_level(this.conductor.level_index + 1);
|
||||
}
|
||||
else {
|
||||
// Restart
|
||||
this.restart_level();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Figure out where these touches are, relative to the game area
|
||||
// TODO allow starting a level without moving?
|
||||
let rect = touch_target.getBoundingClientRect();
|
||||
for (let touch of ev.changedTouches) {
|
||||
// Normalize touch coordinates to [-1, 1]
|
||||
let rx = (touch.clientX - rect.left) / rect.width * 2 - 1;
|
||||
let ry = (touch.clientY - rect.top) / rect.height * 2 - 1;
|
||||
// Divine a direction from the results
|
||||
let action;
|
||||
if (Math.abs(rx) > Math.abs(ry)) {
|
||||
if (rx < 0) {
|
||||
action = 'left';
|
||||
}
|
||||
else {
|
||||
action = 'right';
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (ry < 0) {
|
||||
action = 'up';
|
||||
}
|
||||
else {
|
||||
action = 'down';
|
||||
}
|
||||
}
|
||||
this.current_touches[touch.identifier] = action;
|
||||
}
|
||||
|
||||
// TODO for demo compat, this should happen as part of input reading?
|
||||
if (this.state === 'waiting') {
|
||||
this.set_state('playing');
|
||||
}
|
||||
};
|
||||
touch_target.addEventListener('touchstart', collect_touches);
|
||||
touch_target.addEventListener('touchmove', collect_touches);
|
||||
let dismiss_touches = ev => {
|
||||
for (let touch of ev.changedTouches) {
|
||||
delete this.current_touches[touch.identifier];
|
||||
}
|
||||
};
|
||||
touch_target.addEventListener('touchend', dismiss_touches);
|
||||
touch_target.addEventListener('touchcancel', dismiss_touches);
|
||||
|
||||
// When we lose focus, act as though every key was released, and pause the game
|
||||
window.addEventListener('blur', ev => {
|
||||
this.current_keys.clear();
|
||||
this.current_touches = {};
|
||||
|
||||
if (this.state === 'playing' || this.state === 'rewinding') {
|
||||
this.set_state('paused');
|
||||
this.autopause();
|
||||
}
|
||||
});
|
||||
// Same when the window becomes hidden (especially important on phones, where this covers
|
||||
// turning the screen off!)
|
||||
document.addEventListener('visibilitychange', ev => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
this.current_keys.clear();
|
||||
this.current_touches = {};
|
||||
|
||||
if (this.state === 'playing' || this.state === 'rewinding') {
|
||||
this.autopause();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -638,7 +630,7 @@ class Player extends PrimaryView {
|
||||
}
|
||||
this.conductor.save_savefile();
|
||||
|
||||
this.level = new Level(stored_level, this.compat);
|
||||
this.level = new Level(stored_level, this.gather_compat_options(stored_level));
|
||||
this.level.sfx = this.sfx_player;
|
||||
this.renderer.set_level(this.level);
|
||||
this.root.classList.toggle('--has-demo', !!this.level.stored_level.demo);
|
||||
@ -648,10 +640,20 @@ class Player extends PrimaryView {
|
||||
}
|
||||
|
||||
restart_level() {
|
||||
this.level.restart(this.compat);
|
||||
this.level.restart(this.gather_compat_options(this.level.stored_level));
|
||||
this._clear_state();
|
||||
}
|
||||
|
||||
gather_compat_options(stored_level) {
|
||||
let ret = {};
|
||||
if (stored_level.use_ccl_compat) {
|
||||
for (let [key, value] of Object.entries(this.compat)) {
|
||||
ret[key] = value;
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
// Call after loading or restarting a level
|
||||
_clear_state() {
|
||||
this.set_state('waiting');
|
||||
@ -705,6 +707,9 @@ class Player extends PrimaryView {
|
||||
input.add(this.key_mapping[key]);
|
||||
}
|
||||
this.current_keys_new = new Set;
|
||||
for (let action of Object.values(this.current_touches)) {
|
||||
input.add(action);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
}
|
||||
@ -929,9 +934,9 @@ class Player extends PrimaryView {
|
||||
this.time_el.textContent = '---';
|
||||
}
|
||||
else {
|
||||
this.time_el.textContent = Math.ceil(this.level.time_remaining / 20);
|
||||
this.time_el.classList.toggle('--warning', this.level.time_remaining < 30 * 20);
|
||||
this.time_el.classList.toggle('--danger', this.level.time_remaining < 10 * 20);
|
||||
this.time_el.textContent = Math.ceil(this.level.time_remaining / TICS_PER_SECOND);
|
||||
this.time_el.classList.toggle('--warning', this.level.time_remaining < 30 * TICS_PER_SECOND);
|
||||
this.time_el.classList.toggle('--danger', this.level.time_remaining < 10 * TICS_PER_SECOND);
|
||||
}
|
||||
|
||||
this.bonus_el.textContent = this.level.bonus_points;
|
||||
@ -980,6 +985,10 @@ class Player extends PrimaryView {
|
||||
}
|
||||
}
|
||||
|
||||
autopause() {
|
||||
this.set_state('paused');
|
||||
}
|
||||
|
||||
// waiting: haven't yet pressed a key so the timer isn't going
|
||||
// playing: playing normally
|
||||
// paused: um, paused
|
||||
@ -1004,7 +1013,12 @@ class Player extends PrimaryView {
|
||||
else if (this.state === 'paused') {
|
||||
overlay_reason = 'paused';
|
||||
overlay_bottom = "/// paused ///";
|
||||
overlay_keyhint = "press P to resume";
|
||||
if (this.using_touch) {
|
||||
overlay_keyhint = "tap to resume";
|
||||
}
|
||||
else {
|
||||
overlay_keyhint = "press P to resume";
|
||||
}
|
||||
}
|
||||
else if (this.state === 'stopped') {
|
||||
if (this.level.state === 'failure') {
|
||||
@ -1012,7 +1026,13 @@ class Player extends PrimaryView {
|
||||
overlay_top = "whoops";
|
||||
let obits = OBITUARIES[this.level.fail_reason] ?? OBITUARIES['generic'];
|
||||
overlay_bottom = random_choice(obits);
|
||||
overlay_keyhint = "press space to try again, or Z to rewind";
|
||||
if (this.using_touch) {
|
||||
// TODO touch gesture to rewind?
|
||||
overlay_keyhint = "tap to try again, or tap undo/rewind above";
|
||||
}
|
||||
else {
|
||||
overlay_keyhint = "press space to try again, or Z to rewind";
|
||||
}
|
||||
}
|
||||
else {
|
||||
// We just beat the level! Hey, that's cool.
|
||||
@ -1023,7 +1043,9 @@ class Player extends PrimaryView {
|
||||
let savefile = this.conductor.current_pack_savefile;
|
||||
let old_scorecard;
|
||||
if (! savefile.scorecards[level_index] ||
|
||||
savefile.scorecards[level_index].score < scorecard.score)
|
||||
savefile.scorecards[level_index].score < scorecard.score ||
|
||||
(savefile.scorecards[level_index].score === scorecard.score &&
|
||||
savefile.scorecards[level_index].aid > scorecard.aid))
|
||||
{
|
||||
old_scorecard = savefile.scorecards[level_index];
|
||||
|
||||
@ -1045,7 +1067,7 @@ class Player extends PrimaryView {
|
||||
// TODO done on first try; took many tries
|
||||
let time_left_fraction = null;
|
||||
if (this.level.time_remaining !== null && this.level.stored_level.time_limit !== null) {
|
||||
time_left_fraction = this.level.time_remaining / 20 / this.level.stored_level.time_limit;
|
||||
time_left_fraction = this.level.time_remaining / TICS_PER_SECOND / this.level.stored_level.time_limit;
|
||||
}
|
||||
|
||||
if (this.level.chips_remaining > 0) {
|
||||
@ -1075,7 +1097,12 @@ class Player extends PrimaryView {
|
||||
"alphanumeric!", "nice dynamic typing!",
|
||||
]);
|
||||
}
|
||||
overlay_keyhint = "press space to move on";
|
||||
if (this.using_touch) {
|
||||
overlay_keyhint = "tap to move on";
|
||||
}
|
||||
else {
|
||||
overlay_keyhint = "press space to move on";
|
||||
}
|
||||
|
||||
overlay_middle = mk('dl.score-chart',
|
||||
mk('dt', "base score"),
|
||||
@ -1207,14 +1234,23 @@ class Player extends PrimaryView {
|
||||
// Auto-size the game canvas to fit the screen, if possible
|
||||
adjust_scale() {
|
||||
// TODO make this optional
|
||||
// The base size is the size of the canvas, i.e. the viewport size times the tile size --
|
||||
// but note that horizontally we have 4 extra tiles for the inventory
|
||||
// TODO if there's ever a portrait view for phones, this will need adjusting
|
||||
let base_x = this.conductor.tileset.size_x * (this.renderer.viewport_size_x + 4);
|
||||
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
|
||||
// extra space available is the size of those margins
|
||||
let style = window.getComputedStyle(this.root);
|
||||
let is_portrait = !! style.getPropertyValue('--is-portrait');
|
||||
// The base size is the size of the canvas, i.e. the viewport size times the tile size --
|
||||
// but note that we have 2x4 extra tiles for the inventory depending on layout
|
||||
// TODO if there's ever a portrait view for phones, this will need adjusting
|
||||
let base_x, base_y;
|
||||
if (is_portrait) {
|
||||
base_x = this.conductor.tileset.size_x * this.renderer.viewport_size_x;
|
||||
base_y = this.conductor.tileset.size_y * (this.renderer.viewport_size_y + 2);
|
||||
}
|
||||
else {
|
||||
base_x = this.conductor.tileset.size_x * (this.renderer.viewport_size_x + 4);
|
||||
base_y = this.conductor.tileset.size_y * this.renderer.viewport_size_y;
|
||||
}
|
||||
// The main UI is centered in a flex item with auto margins, so the extra space available is
|
||||
// the size of those margins (which naturally discounts the size of the buttons and music
|
||||
// title and whatnot, so those automatically reserve their own space)
|
||||
if (style['display'] === 'none') {
|
||||
// the computed margins can be 'auto' in this case
|
||||
return;
|
||||
@ -1226,9 +1262,10 @@ class Player extends PrimaryView {
|
||||
let total_x = extra_x + this.renderer.canvas.offsetWidth + this.inventory_el.offsetWidth;
|
||||
let total_y = extra_y + this.renderer.canvas.offsetHeight;
|
||||
let dpr = window.devicePixelRatio || 1.0;
|
||||
// Divide to find the biggest scale that still fits. But don't
|
||||
// exceed 90% of the available space, or it'll feel cramped.
|
||||
let scale = Math.floor(0.9 * dpr * Math.min(total_x / base_x, total_y / base_y));
|
||||
// Divide to find the biggest scale that still fits. But don't exceed 90% of the available
|
||||
// space, or it'll feel cramped (except on small screens, where being too small HURTS).
|
||||
let maxfrac = total_x < 800 ? 1 : 0.9;
|
||||
let scale = Math.floor(maxfrac * dpr * Math.min(total_x / base_x, total_y / base_y));
|
||||
if (scale <= 1) {
|
||||
scale = 1;
|
||||
}
|
||||
@ -1243,486 +1280,6 @@ class Player extends PrimaryView {
|
||||
}
|
||||
|
||||
|
||||
class EditorShareOverlay extends DialogOverlay {
|
||||
constructor(conductor, url) {
|
||||
super(conductor);
|
||||
this.set_title("give this to friends");
|
||||
this.main.append(mk('p', "Give this URL out to let others try your level:"));
|
||||
this.main.append(mk('p.editor-share-url', {}, url));
|
||||
let copy_button = mk('button', {type: 'button'}, "Copy to clipboard");
|
||||
copy_button.addEventListener('click', ev => {
|
||||
navigator.clipboard.writeText(url);
|
||||
// TODO feedback?
|
||||
});
|
||||
this.main.append(copy_button);
|
||||
|
||||
let ok = mk('button', {type: 'button'}, "neato");
|
||||
ok.addEventListener('click', ev => {
|
||||
this.close();
|
||||
});
|
||||
this.footer.append(ok);
|
||||
}
|
||||
}
|
||||
|
||||
const EDITOR_TOOLS = [{
|
||||
mode: 'pencil',
|
||||
icon: 'icons/tool-pencil.png',
|
||||
name: "Pencil",
|
||||
desc: "Draw individual tiles",
|
||||
/* TODO not implemented
|
||||
}, {
|
||||
mode: 'line',
|
||||
icon: 'icons/tool-line.png',
|
||||
name: "Line",
|
||||
desc: "Draw straight lines",
|
||||
}, {
|
||||
mode: 'box',
|
||||
icon: 'icons/tool-box.png',
|
||||
name: "Box",
|
||||
desc: "Fill a rectangular area with tiles",
|
||||
}, {
|
||||
mode: 'fill',
|
||||
icon: 'icons/tool-fill.png',
|
||||
name: "Fill",
|
||||
desc: "Flood-fill an area with tiles",
|
||||
*/
|
||||
}, {
|
||||
mode: 'force-floors',
|
||||
icon: 'icons/tool-force-floors.png',
|
||||
name: "Force floors",
|
||||
desc: "Draw force floors in the direction you draw",
|
||||
}, {
|
||||
mode: 'adjust',
|
||||
icon: 'icons/tool-adjust.png',
|
||||
name: "Adjust",
|
||||
desc: "Toggle blocks and rotate actors",
|
||||
/* TODO not implemented
|
||||
}, {
|
||||
mode: 'connect',
|
||||
icon: 'icons/tool-connect.png',
|
||||
name: "Connect",
|
||||
desc: "Set up CC1 clone and trap connections",
|
||||
}, {
|
||||
mode: 'wire',
|
||||
icon: 'icons/tool-wire.png',
|
||||
name: "Wire",
|
||||
desc: "Draw CC2 wiring",
|
||||
// TODO text tool; thin walls tool; ice tool; map generator?; subtools for select tool (copy, paste, crop)
|
||||
// TODO interesting option: rotate an actor as you draw it by dragging? or hold a key like in
|
||||
// slade when you have some selected?
|
||||
// TODO ah, railroads...
|
||||
*/
|
||||
}];
|
||||
// Tiles the "adjust" tool will turn into each other
|
||||
const EDITOR_ADJUST_TOGGLES = {
|
||||
floor_custom_green: 'wall_custom_green',
|
||||
floor_custom_pink: 'wall_custom_pink',
|
||||
floor_custom_yellow: 'wall_custom_yellow',
|
||||
floor_custom_blue: 'wall_custom_blue',
|
||||
wall_custom_green: 'floor_custom_green',
|
||||
wall_custom_pink: 'floor_custom_pink',
|
||||
wall_custom_yellow: 'floor_custom_yellow',
|
||||
wall_custom_blue: 'floor_custom_blue',
|
||||
fake_floor: 'fake_wall',
|
||||
fake_wall: 'fake_floor',
|
||||
wall_invisible: 'wall_appearing',
|
||||
wall_appearing: 'wall_invisible',
|
||||
green_floor: 'green_wall',
|
||||
green_wall: 'green_floor',
|
||||
green_bomb: 'green_chip',
|
||||
green_chip: 'green_bomb',
|
||||
purple_floor: 'purple_wall',
|
||||
purple_wall: 'purple_floor',
|
||||
thief_keys: 'thief_tools',
|
||||
thief_tools: 'thief_keys',
|
||||
};
|
||||
// TODO this MUST use a cc2 tileset!
|
||||
const EDITOR_PALETTE = [{
|
||||
title: "Basics",
|
||||
tiles: [
|
||||
'player',
|
||||
'chip', 'chip_extra',
|
||||
'floor', 'wall', 'hint', 'socket', 'exit',
|
||||
],
|
||||
}, {
|
||||
title: "Terrain",
|
||||
tiles: [
|
||||
'popwall',
|
||||
'fake_floor', 'fake_wall',
|
||||
'wall_invisible', 'wall_appearing',
|
||||
'gravel',
|
||||
'dirt',
|
||||
'door_blue', 'door_red', 'door_yellow', 'door_green',
|
||||
'water', 'turtle', 'fire',
|
||||
'ice', 'ice_nw', 'ice_ne', 'ice_sw', 'ice_se',
|
||||
'force_floor_n', 'force_floor_s', 'force_floor_w', 'force_floor_e', 'force_floor_all',
|
||||
],
|
||||
}, {
|
||||
title: "Items",
|
||||
tiles: [
|
||||
'key_blue', 'key_red', 'key_yellow', 'key_green',
|
||||
'flippers', 'fire_boots', 'cleats', 'suction_boots',
|
||||
],
|
||||
}, {
|
||||
title: "Creatures",
|
||||
tiles: [
|
||||
'tank_blue',
|
||||
'ball',
|
||||
'fireball',
|
||||
'glider',
|
||||
'bug',
|
||||
'paramecium',
|
||||
'walker',
|
||||
'teeth',
|
||||
'blob',
|
||||
],
|
||||
}, {
|
||||
title: "Mechanisms",
|
||||
tiles: [
|
||||
'bomb',
|
||||
'dirt_block',
|
||||
'ice_block',
|
||||
'button_blue',
|
||||
'button_red', 'cloner',
|
||||
'button_brown', 'trap',
|
||||
'teleport_blue',
|
||||
'teleport_red',
|
||||
'teleport_green',
|
||||
'teleport_yellow',
|
||||
],
|
||||
}];
|
||||
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);
|
||||
|
||||
// FIXME need this in load_level which is called even if we haven't been setup yet
|
||||
this.connections_g = mk_svg('g');
|
||||
}
|
||||
|
||||
setup() {
|
||||
// Level canvas and mouse handling
|
||||
// This SVG draws vectors on top of the editor, like monster paths and button connections
|
||||
// FIXME change viewBox in load_level, can't right now because order of ops
|
||||
this.svg_overlay = mk_svg('svg.level-editor-overlay', {viewBox: '0 0 32 32'}, this.connections_g);
|
||||
this.root.querySelector('.level').append(
|
||||
this.renderer.canvas,
|
||||
this.svg_overlay);
|
||||
this.mouse_mode = null;
|
||||
this.mouse_button = null;
|
||||
this.mouse_cell = null;
|
||||
this.renderer.canvas.addEventListener('mousedown', ev => {
|
||||
if (ev.button === 0) {
|
||||
// Left button: draw
|
||||
this.mouse_mode = 'draw';
|
||||
this.mouse_button_mask = 1;
|
||||
this.mouse_coords = [ev.clientX, ev.clientY];
|
||||
|
||||
let [x, y] = this.renderer.cell_coords_from_event(ev);
|
||||
this.mouse_cell = [x, y];
|
||||
|
||||
if (this.current_tool === 'pencil') {
|
||||
this.place_in_cell(x, y, this.palette_selection);
|
||||
}
|
||||
else if (this.current_tool === 'force-floors') {
|
||||
// Begin by placing an all-way force floor under the mouse
|
||||
this.place_in_cell(x, y, 'force_floor_all');
|
||||
}
|
||||
else if (this.current_tool === 'adjust') {
|
||||
let cell = this.stored_level.cells[y][x];
|
||||
for (let tile of cell) {
|
||||
// Toggle tiles that go in obvious pairs
|
||||
let other = EDITOR_ADJUST_TOGGLES[tile.type.name];
|
||||
if (other) {
|
||||
tile.type = TILE_TYPES[other];
|
||||
}
|
||||
|
||||
// Rotate actors
|
||||
if (TILE_TYPES[tile.type.name].is_actor) {
|
||||
tile.direction = DIRECTIONS[tile.direction ?? 'south'].right;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.renderer.draw();
|
||||
}
|
||||
else if (ev.button === 1) {
|
||||
// Middle button: pan
|
||||
this.mouse_mode = 'pan';
|
||||
this.mouse_button_mask = 4;
|
||||
this.mouse_coords = [ev.clientX, ev.clientY];
|
||||
ev.preventDefault();
|
||||
}
|
||||
});
|
||||
this.renderer.canvas.addEventListener('mousemove', ev => {
|
||||
if (this.mouse_mode === null)
|
||||
return;
|
||||
// TODO check for the specific button we're holding
|
||||
if ((ev.buttons & this.mouse_button_mask) === 0) {
|
||||
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
|
||||
if (this.current_tool === 'pencil') {
|
||||
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);
|
||||
}
|
||||
}
|
||||
else if (this.current_tool === 'force-floors') {
|
||||
// Walk the mouse movement and change each we touch to match the direction we
|
||||
// crossed the border
|
||||
// FIXME occasionally i draw a tetris S kinda shape and both middle parts point
|
||||
// the same direction, but shouldn't
|
||||
let i = 0;
|
||||
let prevx, prevy;
|
||||
for (let [cx, cy] of walk_grid(this.mouse_cell[0], this.mouse_cell[1], x, y)) {
|
||||
i++;
|
||||
// The very first cell is the one the mouse was already in, and we don't
|
||||
// have a movement direction yet, so leave that alone
|
||||
if (i === 1) {
|
||||
prevx = cx;
|
||||
prevy = cy;
|
||||
continue;
|
||||
}
|
||||
let name;
|
||||
if (cx === prevx) {
|
||||
if (cy > prevy) {
|
||||
name = 'force_floor_s';
|
||||
}
|
||||
else {
|
||||
name = 'force_floor_n';
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (cx > prevx) {
|
||||
name = 'force_floor_e';
|
||||
}
|
||||
else {
|
||||
name = 'force_floor_w';
|
||||
}
|
||||
}
|
||||
|
||||
// The second cell tells us the direction to use for the first, assuming it
|
||||
// had some kind of force floor
|
||||
if (i === 2) {
|
||||
let prevcell = this.stored_level.cells[prevy][prevx];
|
||||
if (prevcell[0].type.name.startsWith('force_floor_')) {
|
||||
prevcell[0].type = TILE_TYPES[name];
|
||||
}
|
||||
}
|
||||
|
||||
// Drawing a loop with force floors creates ice (but not in the previous
|
||||
// cell, obviously)
|
||||
let cell = this.stored_level.cells[cy][cx];
|
||||
if (cell[0].type.name.startsWith('force_floor_') &&
|
||||
cell[0].type.name !== name)
|
||||
{
|
||||
name = 'ice';
|
||||
}
|
||||
this.place_in_cell(cx, cy, name);
|
||||
|
||||
prevx = cx;
|
||||
prevy = cy;
|
||||
}
|
||||
}
|
||||
else if (this.current_tool === 'adjust') {
|
||||
// Adjust tool doesn't support dragging
|
||||
// TODO should it
|
||||
}
|
||||
this.renderer.draw();
|
||||
|
||||
this.mouse_cell = [x, y];
|
||||
}
|
||||
else if (this.mouse_mode === 'pan') {
|
||||
let dx = ev.clientX - this.mouse_coords[0];
|
||||
let dy = ev.clientY - this.mouse_coords[1];
|
||||
this.renderer.canvas.parentNode.scrollLeft -= dx;
|
||||
this.renderer.canvas.parentNode.scrollTop -= dy;
|
||||
this.mouse_coords = [ev.clientX, ev.clientY];
|
||||
}
|
||||
});
|
||||
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;
|
||||
});
|
||||
|
||||
// Toolbar buttons
|
||||
this.root.querySelector('#editor-share-url').addEventListener('click', ev => {
|
||||
let buf = c2m.synthesize_level(this.stored_level);
|
||||
// FIXME Not ideal, but btoa() wants a string rather than any of the myriad binary types
|
||||
let stringy_buf = Array.from(new Uint8Array(buf)).map(n => String.fromCharCode(n)).join('');
|
||||
// Make URL-safe and strip trailing padding
|
||||
let data = btoa(stringy_buf).replace(/[+]/g, '-').replace(/[/]/g, '_').replace(/=+$/, '');
|
||||
let url = new URL(location);
|
||||
url.searchParams.delete('level');
|
||||
url.searchParams.delete('setpath');
|
||||
url.searchParams.append('level', data);
|
||||
new EditorShareOverlay(this.conductor, url.toString()).open();
|
||||
});
|
||||
|
||||
// Toolbox
|
||||
let toolbox = mk('div.icon-button-set')
|
||||
this.root.querySelector('.controls').append(toolbox);
|
||||
this.tool_button_els = {};
|
||||
for (let tooldef of EDITOR_TOOLS) {
|
||||
let button = mk(
|
||||
'button', {
|
||||
type: 'button',
|
||||
'data-tool': tooldef.mode,
|
||||
},
|
||||
mk('img', {
|
||||
src: tooldef.icon,
|
||||
alt: tooldef.name,
|
||||
title: `${tooldef.name}: ${tooldef.desc}`,
|
||||
}),
|
||||
);
|
||||
this.tool_button_els[tooldef.mode] = button;
|
||||
toolbox.append(button);
|
||||
}
|
||||
this.current_tool = 'pencil';
|
||||
this.tool_button_els['pencil'].classList.add('-selected');
|
||||
toolbox.addEventListener('click', ev => {
|
||||
let button = ev.target.closest('.icon-button-set button');
|
||||
if (! button)
|
||||
return;
|
||||
|
||||
this.select_tool(button.getAttribute('data-tool'));
|
||||
});
|
||||
|
||||
// Tile palette
|
||||
let palette_el = this.root.querySelector('.palette');
|
||||
this.palette = {}; // name => element
|
||||
for (let sectiondef of EDITOR_PALETTE) {
|
||||
let section_el = mk('section');
|
||||
palette_el.append(mk('h2', sectiondef.title), section_el);
|
||||
for (let name of sectiondef.tiles) {
|
||||
let entry = this.renderer.create_tile_type_canvas(name);
|
||||
entry.setAttribute('data-tile-name', name);
|
||||
entry.classList = 'palette-entry';
|
||||
this.palette[name] = entry;
|
||||
section_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');
|
||||
}
|
||||
|
||||
activate() {
|
||||
super.activate();
|
||||
this.renderer.draw();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Load connections
|
||||
this.connections_g.textContent = '';
|
||||
for (let [src, dest] of Object.entries(this.stored_level.custom_trap_wiring)) {
|
||||
let [sx, sy] = this.stored_level.scalar_to_coords(src);
|
||||
let [dx, dy] = this.stored_level.scalar_to_coords(dest);
|
||||
this.connections_g.append(
|
||||
mk_svg('rect.overlay-cxn', {x: sx, y: sy, width: 1, height: 1}),
|
||||
mk_svg('line.overlay-cxn', {x1: sx + 0.5, y1: sy + 0.5, x2: dx + 0.5, y2: dy + 0.5}),
|
||||
);
|
||||
}
|
||||
|
||||
this.renderer.set_level(stored_level);
|
||||
if (this.active) {
|
||||
this.renderer.draw();
|
||||
}
|
||||
}
|
||||
|
||||
select_tool(tool) {
|
||||
if (tool === this.current_tool)
|
||||
return;
|
||||
if (! this.tool_button_els[tool])
|
||||
return;
|
||||
|
||||
this.tool_button_els[this.current_tool].classList.remove('-selected');
|
||||
this.current_tool = tool;
|
||||
this.tool_button_els[this.current_tool].classList.add('-selected');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
// Some tools obviously don't work with a palette selection, in which case changing tiles
|
||||
// should default you back to the pencil
|
||||
if (this.current_tool === 'adjust') {
|
||||
this.select_tool('pencil');
|
||||
}
|
||||
}
|
||||
|
||||
place_in_cell(x, y, name) {
|
||||
// TODO weird api?
|
||||
if (! name)
|
||||
return;
|
||||
|
||||
let type = TILE_TYPES[name];
|
||||
let cell = this.stored_level.cells[y][x];
|
||||
// For terrain tiles, erase the whole cell. For other tiles, only
|
||||
// replace whatever's on the same layer
|
||||
// TODO probably not the best heuristic yet, since i imagine you can
|
||||
// combine e.g. the tent with thin walls
|
||||
if (type.draw_layer === 0) {
|
||||
cell.length = 0;
|
||||
cell.push({type});
|
||||
}
|
||||
else {
|
||||
for (let i = cell.length - 1; i >= 0; i--) {
|
||||
if (cell[i].type.draw_layer === type.draw_layer) {
|
||||
cell.splice(i, 1);
|
||||
}
|
||||
}
|
||||
cell.push({type});
|
||||
cell.sort((a, b) => a.type.draw_layer - b.type.draw_layer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const BUILTIN_LEVEL_PACKS = [{
|
||||
path: 'levels/CCLP1.ccl',
|
||||
ident: 'cclp1',
|
||||
@ -1911,15 +1468,40 @@ const AESTHETIC_OPTIONS = [{
|
||||
note: "Chip's Challenge typically draws everything in a grid, which looks a bit funny for tall skinny objects like... the player. And teeth. This option draws both of them raised up slightly, so they'll break the grid and add a slight 3D effect. May not work for all tilesets.",
|
||||
}];
|
||||
const COMPAT_OPTIONS = [{
|
||||
key: 'popwalls_react_on_arrive',
|
||||
label: "Recessed walls trigger when stepped on",
|
||||
impls: ['lynx', 'ms'],
|
||||
note: "This was the behavior in both versions of CC1, but CC2 changed them to trigger when stepped off of (probably to match the behavior of turtles). Some CCLP levels depend on the old behavior. See the next option for a more conservative solution.",
|
||||
}, {
|
||||
key: 'auto_convert_ccl_popwalls',
|
||||
label: "Fix loaded recessed walls",
|
||||
impls: ['lynx', 'ms'],
|
||||
note: "This is a more conservative solution to the problem with recessed walls. It replaces recessed walls with a new tile, \"doubly recessed walls\", only if they begin the level with something on top of them. This should resolve compatibility issues without changing the behavior of recessed walls.",
|
||||
}, {
|
||||
key: 'auto_convert_ccl_blue_walls',
|
||||
label: "Fix loaded blue walls",
|
||||
impls: ['lynx'],
|
||||
note: "Generally, you can only push a block if it's in a space you could otherwise move into, but Tile World Lynx allows pushing blocks off of blue walls. (Unclear whether this is a Tile World bug, or a Lynx bug that Tile World is replicating.) The same effect can be achieved in Steam by using a recessed wall instead, so this replaces such walls with recessed walls. Note that this fix may have unintended side effects in conjunction with the recessed wall compat option.",
|
||||
}, {
|
||||
key: 'sliding_tanks_ignore_button',
|
||||
label: "Blue tanks ignore blue buttons while sliding",
|
||||
impls: ['lynx'],
|
||||
note: "In Lynx, due to what is almost certainly a bug, blue tanks would simply not react at all if a blue button were pressed while they were in mid-movement. Steam fixed this, but it also made blue tanks \"remember\" a button press if they were in the middle of a slide and then turn around once they were finished, and this subtle change broke at least one CCLP level. (There is no compat option for ignoring a button press while moving normally, as that makes the game worse for no known benefit.)",
|
||||
}, {
|
||||
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.",
|
||||
impls: ['ms'],
|
||||
note: "CC originally had objects slide smoothly from one tile to another, so most tiles only responded when the movement completed. In the Microsoft port, though, everything moves instantly (and then waits before moving again), so tiles respond right away.",
|
||||
}, {
|
||||
key: 'allow_flick',
|
||||
label: "Allow flicking",
|
||||
impls: ['ms'],
|
||||
note: "Generally, you can only push a block if it's in a space you could otherwise move into. Due to a bug, the Microsoft port allows pushing blocks that are on top of walls, thin walls, ice corners, etc., and this maneuver is called a \"flick\".",
|
||||
}];
|
||||
const COMPAT_IMPLS = {
|
||||
lynx: "Lynx, the original version",
|
||||
ms: "Microsoft's Windows port",
|
||||
cc2bug: "Bug present in CC2",
|
||||
steam: "The canonical Steam version, but off by default because it's considered a bug",
|
||||
};
|
||||
const OPTIONS_TABS = [{
|
||||
name: 'aesthetic',
|
||||
@ -1967,8 +1549,10 @@ class OptionsOverlay extends DialogOverlay {
|
||||
|
||||
// Compat tab
|
||||
this.tab_blocks['compat'].append(
|
||||
mk('p', "If you don't know what any of these are for, you can pretty safely ignore them."),
|
||||
mk('p', "Changes won't take effect until you restart the level."),
|
||||
mk('p', "Revert to:", mk('button', "Default"), mk('button', "Lynx"), mk('button', "Microsoft"), mk('button', "Steam")),
|
||||
mk('p', "These settings are for compatibility with player-created levels, which sometimes relied on subtle details of the Microsoft or Lynx games and no longer work with the now-canonical Steam rules. The default is to follow the Steam rules as closely as possible (except for bugs), but make a few small tweaks to keep CCL-format levels working."),
|
||||
mk('p', "Changes won't take effect until you restart the level or change levels."),
|
||||
mk('p', "Please note that Microsoft had a number of subtle but complex bugs that Lexy's Labyrinth cannot ever reasonably emulate. The Microsoft settings here are best-effort and not intended to be 100% compatible."),
|
||||
);
|
||||
this._add_options(this.tab_blocks['compat'], COMPAT_OPTIONS);
|
||||
}
|
||||
@ -2048,7 +1632,9 @@ class LevelBrowserOverlay extends DialogOverlay {
|
||||
|
||||
// Express absolute time as mm:ss, with two decimals on the seconds (which should be
|
||||
// able to exactly count a number of tics)
|
||||
abstime = `${Math.floor(scorecard.abstime / 20 / 60)}:${(scorecard.abstime / 20 % 60).toFixed(2)}`;
|
||||
let absmin = Math.floor(scorecard.abstime / TICS_PER_SECOND / 60);
|
||||
let abssec = scorecard.abstime / TICS_PER_SECOND % 60;
|
||||
abstime = `${absmin}:${abssec < 10 ? '0' : ''}${abssec.toFixed(2)}`;
|
||||
}
|
||||
|
||||
tbody.append(mk(i >= savefile.highest_level ? 'tr.--unvisited' : 'tr',
|
||||
|
||||
@ -42,6 +42,15 @@ export class CanvasRenderer {
|
||||
return [x, y];
|
||||
}
|
||||
|
||||
real_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 = (ev.clientX - rect.x) / scale_x / this.tileset.size_x + this.viewport_x;
|
||||
let y = (ev.clientY - rect.y) / scale_y / this.tileset.size_y + this.viewport_y;
|
||||
return [x, y];
|
||||
}
|
||||
|
||||
// Draw to a canvas using tile coordinates
|
||||
blit(ctx, sx, sy, dx, dy, w = 1, h = w) {
|
||||
let tw = this.tileset.size_x;
|
||||
@ -78,8 +87,24 @@ export class CanvasRenderer {
|
||||
[px, py] = [0, 0];
|
||||
}
|
||||
// Figure out where to start drawing
|
||||
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));
|
||||
// TODO support overlapping regions better
|
||||
let x0 = px - xmargin;
|
||||
let y0 = py - ymargin;
|
||||
// FIXME editor vs player again ugh, which is goofy since none of this is even relevant;
|
||||
// maybe need to have a separate positioning method
|
||||
if (this.level.stored_level) {
|
||||
for (let region of this.level.stored_level.camera_regions) {
|
||||
if (px >= region.left && px < region.right &&
|
||||
py >= region.top && py < region.bottom)
|
||||
{
|
||||
x0 = Math.max(region.left, Math.min(region.right - this.viewport_size_x, x0));
|
||||
y0 = Math.max(region.top, Math.min(region.bottom - this.viewport_size_y, y0));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Always keep us within the map bounds
|
||||
x0 = Math.max(0, Math.min(this.level.size_x - this.viewport_size_x, x0));
|
||||
y0 = Math.max(0, Math.min(this.level.size_y - this.viewport_size_y, y0));
|
||||
// Round to the pixel grid
|
||||
x0 = Math.floor(x0 * tw + 0.5) / tw;
|
||||
y0 = Math.floor(y0 * th + 0.5) / th;
|
||||
@ -88,12 +113,12 @@ export class CanvasRenderer {
|
||||
// The viewport might not be aligned to the grid, so split off any fractional part.
|
||||
let xf0 = Math.floor(x0);
|
||||
let yf0 = Math.floor(y0);
|
||||
// Note that when the viewport is exactly aligned to the grid, we need to draw the cells
|
||||
// just outside of it, or we'll miss objects partway through crossing the border
|
||||
if (xf0 === x0 && xf0 > 0) {
|
||||
// We need to draw one cell beyond the viewport, or we'll miss objects partway through
|
||||
// crossing the border moving away from us
|
||||
if (xf0 > 0) {
|
||||
xf0 -= 1;
|
||||
}
|
||||
if (yf0 === y0 && yf0 > 0) {
|
||||
if (yf0 > 0) {
|
||||
yf0 -= 1;
|
||||
}
|
||||
// Find where to stop drawing. As with above, if we're aligned to the grid, we need to
|
||||
|
||||
@ -43,7 +43,7 @@ export default [{
|
||||
title: "conundrum",
|
||||
author: "fluffy",
|
||||
url: 'https://beesbuzz.biz/',
|
||||
beepbox: 'https://www.beepbox.co/#8n31sbk0l00e0rt2mm0a7g0rj07i0r1o3210T1v1L4u01q3d7fay3z6C0c1A5F4B0V1Q0248Pac74E0085T1v1L4u01q1d1f4y4z9C0c1A1F0B0V1Q200ePd593E0787T5v1L4u05q3d0f1y4z0C2c0h0H_--D-quSRIAJJST4v1L4u04q1z6666ji8k8k3jSBKSJJAArriiiiii07JCABrzrrrrrrr00YrkqHrsrrrrjr005zrAqzrjzrrqr1jRjrqGGrrzsrsA099ijrABJJJIAzrrtirqrqjqixzsrAjrqjiqaqqysttAJqjikikrizrHtBJJAzArzrIsRCITKSS099ijrAJS____Qg99habbCAYrDzh00bkzgid18Q4zgid0000000018Nwz55p88Nhnh5t000000ichgR5zkicg004hcx4O4jhkohd14z4isp25PFJvy1wqY58llp2GhKFLh_wFLh-czArdWfRuKrdQ_p13c5FKfZ_tRBPjub_aq_TYtjhZgtBdJlldtlldtlleKGGCKGGCK1wqoiGGiOdI3j1RlkOJBtdZllmG1wqoiGGCK1JFxaEm1STTQMBlldtlldtlJKoHrarqGGqWGGqWGGth7ihT4sChON7a4sIpqPhDkCz9FFOG8YwzOF8Wp8WFOHyfAVARQVB4ughVkAtcAtkVl97Op8Wd8Td6nl9EOaqSqcyIz9UzMbaXbAenShhAPvehhrBRApcVmnlsV5vaGysD9CKsyyLbH8OpOIKGVEGKttV56jdkV55uTnpeldpuptSltdRKpEPpFFOW8YwzOF8Wp8WFOGU0',
|
||||
beepbox: 'https://www.beepbox.co/#8n31sbk0l00e0rt2mm0a7g0rj07i0r1o3210T1v1L4u01q3d7fay3z6C0c1A5F4B0V1Q0248Pac74E0085T1v1L4u01q1d1f4y4z9C0c1A1F0B0V1Q200ePd593E0787T5v1L4u05q3d0f1y4z0C2c0h0H_--D-quSRIAJJST4v1L4u04q1z6666ji8k8k3jSBKSJJAArriiiiii07JCABrzrrrrrrr00YrkqHrsrrrrjr005zrAqzrjzrrqr1jRjrqGGrrzsrsA099ijrABJJJIAzrrtirqrqjqixzsrAjrqjiqaqqysttAJqjikikrizrHtBJJAzArzrIsRCITKSS099ijrAJS____Qg99habbCAYrDzh00b4zgid18Q4zgid0000000018Nwz55p88Nhnh5t000000ichgR5zkicg004hcx4O4jhkohd14z4isp25PFJvy1wqY58llp2GhKFLh_wFLh-czArdWfRuKrdQ_p13c5FKfZ_tRBPjub_aq_TYtjhZgtBdJlldtlldtlleKGGCKGGCK1wqoiGGiOdI3j1RlkOJBtdZllmG1wqoiGGCK1JFxaEm1STTQMBlldtlldtlJKoHrarqGGqWGGqWGGth7ihT4sChON7a4sIpqPhDkCz9FFOG8YwzOF8Wp8WFOHyfAVARQVB4ughVkAtcAtkVl97Op8Wd8Td6nl9EOaqSqcyIz9UzMbaXbAenShhAPvehhrBRApcVmnlsV5vaGysD9CKsyyLbH8OpOIKGVEGKttV56jdkV55uTnpeldpuptSltdRKpEPpFFOW8YwzOF8Wp8WFOGU0',
|
||||
path: 'music/conundrum.ogg',
|
||||
}, {
|
||||
title: "kinda song",
|
||||
@ -51,4 +51,14 @@ export default [{
|
||||
twitter: "glitchedpuppet",
|
||||
beepbox: 'https://jummbus.bitbucket.io/#j2N07Unnamedn310s0k0l00e0jt2mm0a7g0jj07i0r1O_U0000o3210T1v0wL0OD0Ou01q1d5f6y0z6C1c0A1F2B5V6Q20a0Pe64bE0171T1v0pL0OD0Ou92q1d4f7y2z1C0c2AbF6B6V9Q0490Pb976E0001T1v0pL0OD0Ou94q1d2f7y2z1C0c2A9F5B5V6Q290dPa883E0011T4v0pL0OD0Ouf0q1z6666ji8k8k3jSBKSJJAArriiiiii07JCABrzrrrrrrr00YrkqHrsrrrrjr005zrAqzrjzrrqr1jRjrqGGrrzsrsA099ijrABJJJIAzrrtirqrqjqixzsrAjrqjiqaqqysttAJqjikikrizrHtBJJAzArzrIsRCITKSS099ijrAJS____Qg99habbCAYrDzh00b4x8Qd000lBu7014x4i4Qd3gQlmoh4ia2cz8OcChA4h4y8w01cPhjl0p27hFCLwATnMkCKChW2ZcngbUxcnikQ7sQO_inQ5WCkXyW9jbZ9vlO_lODhdcDjQNtgJ0Gp7IpAukFczR2FyX2frIzQ4zOGOOfpauP9vainRihQPK4tClqoKMnaAzQQnQQnRknXx7pBnK2OFjSO_oELFAzFIOWa8WF8WpHW3nEdv26LgqZcLQQap7Iu6P9j5R2Q2Q2Q2VeRfbEbWGq2-DinQBZttkHUMRWPn9HFAuwzEe3E8W2ehFyUsNncLQThuCnW2_aGydcngbkO_rZdkPjdcSpvx9jbZ3cRZtcO_lipvFSFkO_lN4YlAjBpdp6hahFyWz5OXbWFbWF8YEmChy3wWiehcK0bWaoEIlChw3JHp5K5E5E5w1sPb5P2f9as0LVds0bkbukQni0JFyQ5c0bokRyXrbxqxr2CyWSOSAzw1qxqxrNrxqxrhtwJgJMJJhvmbibikO8J5JwJEJEJtE',
|
||||
path: 'music/kinda-song.ogg',
|
||||
}, {
|
||||
title: "learning has occurred",
|
||||
author: "jneen",
|
||||
twitter: "jneen_",
|
||||
path: 'music/learning-has-occurred.ogg',
|
||||
}, {
|
||||
title: "escape on star road",
|
||||
author: "jneen",
|
||||
twitter: "jneen_",
|
||||
path: 'music/escape-on-star-road.ogg',
|
||||
}];
|
||||
|
||||
@ -197,9 +197,15 @@ export const CC2_TILESET_LAYOUT = {
|
||||
wired: [[4, 10], [5, 10], [6, 10], [7, 10]],
|
||||
},
|
||||
popwall: [8, 10],
|
||||
popwall2: [8, 10],
|
||||
gravel: [9, 10],
|
||||
ball: [[10, 10], [11, 10], [12, 10], [13, 10], [14, 10]],
|
||||
steel: [15, 10],
|
||||
steel: {
|
||||
// Wiring!
|
||||
base: [15, 10],
|
||||
wired: [9, 26],
|
||||
is_wired_optional: true,
|
||||
},
|
||||
|
||||
// TODO only animates while moving
|
||||
teeth: {
|
||||
@ -215,7 +221,7 @@ export const CC2_TILESET_LAYOUT = {
|
||||
swivel_ne: [11, 11],
|
||||
swivel_se: [12, 11],
|
||||
swivel_floor: [13, 11],
|
||||
// TODO some kinda four-edges thing again
|
||||
'#wire-tunnel': [14, 11],
|
||||
stopwatch_penalty: [15, 11],
|
||||
paramecium: {
|
||||
north: [[0, 12], [1, 12], [2, 12]],
|
||||
@ -343,7 +349,7 @@ export const CC2_TILESET_LAYOUT = {
|
||||
west: [[6, 24], [7, 24]],
|
||||
},
|
||||
bogus_player_drowned: {
|
||||
overlay: [3, 3], // splash
|
||||
overlay: [5, 5], // splash
|
||||
base: 'water',
|
||||
},
|
||||
bogus_player_burned_fire: {
|
||||
@ -365,18 +371,45 @@ export const CC2_TILESET_LAYOUT = {
|
||||
'#powered': [15, 26],
|
||||
|
||||
player2: {
|
||||
moving: {
|
||||
north: [[0, 27], [1, 27], [2, 27], [3, 27], [4, 27], [5, 27], [6, 27], [7, 27]],
|
||||
south: [[0, 28], [1, 28], [2, 28], [3, 28], [4, 28], [5, 28], [6, 28], [7, 28]],
|
||||
west: [[8, 28], [9, 28], [10, 28], [11, 28], [12, 28], [13, 28], [14, 28], [15, 28]],
|
||||
east: [[8, 27], [9, 27], [10, 27], [11, 27], [12, 27], [13, 27], [14, 27], [15, 27]],
|
||||
},
|
||||
normal: {
|
||||
north: [0, 27],
|
||||
south: [0, 28],
|
||||
west: [8, 28],
|
||||
east: [8, 27],
|
||||
},
|
||||
blocked: 'pushing',
|
||||
moving: {
|
||||
north: [[0, 27], [1, 27], [2, 27], [3, 27], [4, 27], [5, 27], [6, 27], [7, 27]],
|
||||
south: [[0, 28], [1, 28], [2, 28], [3, 28], [4, 28], [5, 28], [6, 28], [7, 28]],
|
||||
west: [[8, 28], [9, 28], [10, 28], [11, 28], [12, 28], [13, 28], [14, 28], [15, 28]],
|
||||
east: [[8, 27], [9, 27], [10, 27], [11, 27], [12, 27], [13, 27], [14, 27], [15, 27]],
|
||||
},
|
||||
pushing: {
|
||||
north: [8, 29],
|
||||
east: [9, 29],
|
||||
south: [10, 29],
|
||||
west: [11, 29],
|
||||
},
|
||||
swimming: {
|
||||
north: [[0, 29], [1, 29]],
|
||||
east: [[2, 29], [3, 29]],
|
||||
south: [[4, 29], [5, 29]],
|
||||
west: [[6, 29], [7, 29]],
|
||||
},
|
||||
// The classic CC2 behavior, spinning on ice
|
||||
skating: [[0, 27], [8, 27], [0, 28], [8, 28]],
|
||||
// TODO i don't know what CC2 does
|
||||
forced: {
|
||||
north: [2, 27],
|
||||
east: [10, 27],
|
||||
south: [2, 28],
|
||||
west: [10, 28],
|
||||
},
|
||||
// These are frames from the splash/explosion animations
|
||||
drowned: [5, 5],
|
||||
burned: [1, 5],
|
||||
exploded: [1, 5],
|
||||
failed: [1, 5],
|
||||
},
|
||||
fire: [
|
||||
[12, 29],
|
||||
@ -451,6 +484,7 @@ export const TILE_WORLD_TILESET_LAYOUT = {
|
||||
wall_appearing: [2, 12],
|
||||
gravel: [2, 13],
|
||||
popwall: [2, 14],
|
||||
popwall2: [2, 14],
|
||||
hint: [2, 15],
|
||||
|
||||
thinwall_se: [3, 0],
|
||||
@ -564,6 +598,7 @@ export const LL_TILESET_LAYOUT = Object.assign({}, CC2_TILESET_LAYOUT, {
|
||||
teeth: Object.assign({}, CC2_TILESET_LAYOUT.teeth, {
|
||||
north: [[0, 32], [1, 32], [2, 32], [1, 32]],
|
||||
}),
|
||||
popwall2: [9, 32],
|
||||
|
||||
// Extra player sprites
|
||||
player: Object.assign({}, CC2_TILESET_LAYOUT.player, {
|
||||
@ -581,6 +616,7 @@ export const LL_TILESET_LAYOUT = Object.assign({}, CC2_TILESET_LAYOUT, {
|
||||
west: [7, 33],
|
||||
},
|
||||
}),
|
||||
// TODO player2 equivalents
|
||||
bogus_player_burned_fire: {
|
||||
overlay: [6, 33],
|
||||
base: 'fire',
|
||||
@ -635,10 +671,10 @@ export class Tileset {
|
||||
coords = drawspec.tile;
|
||||
}
|
||||
else if (drawspec.wired) {
|
||||
if (tile && tile.wire_directions !== undefined && tile.wire_directions !== 0) {
|
||||
// TODO all four is a different thing entirely
|
||||
if (tile && tile.wire_directions) {
|
||||
// TODO all four is a different thing entirely with two separate parts, ugh
|
||||
// Draw the appropriate wire underlay
|
||||
this.draw_type('#unpowered', tile, tic, blit);
|
||||
this.draw_type(tile.cell.is_powered ? '#powered' : '#unpowered', tile, tic, blit);
|
||||
|
||||
// Draw a masked part of the base tile
|
||||
let wiredir = tile.wire_directions;
|
||||
@ -695,7 +731,7 @@ export class Tileset {
|
||||
}
|
||||
|
||||
// Apply custom per-type visual states
|
||||
if (TILE_TYPES[name].visual_state) {
|
||||
if (TILE_TYPES[name] && TILE_TYPES[name].visual_state) {
|
||||
// Note that these accept null, too, and return a default
|
||||
let state = TILE_TYPES[name].visual_state(tile);
|
||||
// If it's a string, that's an alias for another state
|
||||
@ -766,6 +802,30 @@ export class Tileset {
|
||||
blit(coords[0], coords[1]);
|
||||
}
|
||||
|
||||
// Wired tiles may also have tunnels, drawn on top of everything else
|
||||
if (drawspec.wired && tile && tile.wire_tunnel_directions) {
|
||||
let tunnel_coords = this.layout['#wire-tunnel'];
|
||||
let tunnel_width = 6/32;
|
||||
let tunnel_length = 12/32;
|
||||
let tunnel_offset = (1 - tunnel_width) / 2;
|
||||
if (tile.wire_tunnel_directions & DIRECTIONS['north'].bit) {
|
||||
blit(tunnel_coords[0] + tunnel_offset, tunnel_coords[1],
|
||||
tunnel_offset, 0, tunnel_width, tunnel_length);
|
||||
}
|
||||
if (tile.wire_tunnel_directions & DIRECTIONS['south'].bit) {
|
||||
blit(tunnel_coords[0] + tunnel_offset, tunnel_coords[1] + 1 - tunnel_length,
|
||||
tunnel_offset, 1 - tunnel_length, tunnel_width, tunnel_length);
|
||||
}
|
||||
if (tile.wire_tunnel_directions & DIRECTIONS['west'].bit) {
|
||||
blit(tunnel_coords[0], tunnel_coords[1] + tunnel_offset,
|
||||
0, tunnel_offset, tunnel_length, tunnel_width);
|
||||
}
|
||||
if (tile.wire_tunnel_directions & DIRECTIONS['east'].bit) {
|
||||
blit(tunnel_coords[0] + 1 - tunnel_length, tunnel_coords[1] + tunnel_offset,
|
||||
1 - tunnel_length, tunnel_offset, tunnel_length, tunnel_width);
|
||||
}
|
||||
}
|
||||
|
||||
// Special behavior for special objects
|
||||
// TODO? hardcode this less?
|
||||
if (name === 'floor_letter') {
|
||||
|
||||
296
js/tiletypes.js
296
js/tiletypes.js
@ -1,4 +1,5 @@
|
||||
import { DIRECTIONS } from './defs.js';
|
||||
import { random_choice } from './util.js';
|
||||
|
||||
// Draw layers
|
||||
const LAYER_TERRAIN = 0;
|
||||
@ -106,34 +107,62 @@ const TILE_TYPES = {
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
blocks_monsters: true,
|
||||
blocks_blocks: true,
|
||||
on_ready(me, level) {
|
||||
if (level.compat.auto_convert_ccl_popwalls &&
|
||||
me.cell.some(tile => tile.type.is_actor))
|
||||
{
|
||||
// Fix blocks and other actors on top of popwalls by turning them into double
|
||||
// popwalls, which preserves CC2 popwall behavior
|
||||
me.type = TILE_TYPES['popwall2'];
|
||||
}
|
||||
},
|
||||
on_depart(me, level, other) {
|
||||
level.transmute_tile(me, 'wall');
|
||||
},
|
||||
},
|
||||
// FIXME these should be OVERLAY by cc2 rules, but the cc1 tiles are opaque and cover everything else
|
||||
thinwall_n: {
|
||||
// LL specific tile that can only be stepped on /twice/, originally used to repair differences
|
||||
// with popwall behavior between Lynx and Steam
|
||||
popwall2: {
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
blocks_monsters: true,
|
||||
blocks_blocks: true,
|
||||
on_depart(me, level, other) {
|
||||
level.transmute_tile(me, 'popwall');
|
||||
},
|
||||
},
|
||||
// FIXME in a cc1 tileset, these tiles are opaque >:S
|
||||
thinwall_n: {
|
||||
draw_layer: LAYER_OVERLAY,
|
||||
thin_walls: new Set(['north']),
|
||||
},
|
||||
thinwall_s: {
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
draw_layer: LAYER_OVERLAY,
|
||||
thin_walls: new Set(['south']),
|
||||
},
|
||||
thinwall_e: {
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
draw_layer: LAYER_OVERLAY,
|
||||
thin_walls: new Set(['east']),
|
||||
},
|
||||
thinwall_w: {
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
draw_layer: LAYER_OVERLAY,
|
||||
thin_walls: new Set(['west']),
|
||||
},
|
||||
thinwall_se: {
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
draw_layer: LAYER_OVERLAY,
|
||||
thin_walls: new Set(['south', 'east']),
|
||||
},
|
||||
fake_wall: {
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
blocks_all: true,
|
||||
on_ready(me, level) {
|
||||
if (level.compat.auto_convert_ccl_blue_walls &&
|
||||
me.cell.some(tile => tile.type.is_actor))
|
||||
{
|
||||
// Blocks can be pushed off of blue walls in TW Lynx, which only works due to a tiny
|
||||
// quirk of the engine that I don't want to replicate, so replace them with popwalls
|
||||
me.type = TILE_TYPES['popwall'];
|
||||
}
|
||||
},
|
||||
on_bump(me, level, other) {
|
||||
if (other.type.can_reveal_walls) {
|
||||
level.transmute_tile(me, 'wall');
|
||||
@ -142,7 +171,8 @@ const TILE_TYPES = {
|
||||
},
|
||||
fake_floor: {
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
blocks_all: true,
|
||||
blocks_monsters: true,
|
||||
blocks_blocks: true,
|
||||
on_bump(me, level, other) {
|
||||
if (other.type.can_reveal_walls) {
|
||||
level.transmute_tile(me, 'floor');
|
||||
@ -354,7 +384,7 @@ const TILE_TYPES = {
|
||||
level.transmute_tile(me, 'dirt');
|
||||
}
|
||||
else if (other.type.name === 'ice_block') {
|
||||
level.remove_tile(other);
|
||||
level.transmute_tile(other, 'splash');
|
||||
level.transmute_tile(me, 'ice');
|
||||
}
|
||||
else if (other.type.is_player) {
|
||||
@ -558,10 +588,16 @@ const TILE_TYPES = {
|
||||
},
|
||||
green_floor: {
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
on_gray_button(me, level) {
|
||||
level.transmute_tile(me, 'green_wall');
|
||||
},
|
||||
},
|
||||
green_wall: {
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
blocks_all: true,
|
||||
on_gray_button(me, level) {
|
||||
level.transmute_tile(me, 'green_floor');
|
||||
},
|
||||
},
|
||||
green_chip: {
|
||||
draw_layer: LAYER_ITEM,
|
||||
@ -575,6 +611,7 @@ const TILE_TYPES = {
|
||||
level.remove_tile(me);
|
||||
}
|
||||
},
|
||||
// Not affected by gray buttons
|
||||
},
|
||||
green_bomb: {
|
||||
draw_layer: LAYER_ITEM,
|
||||
@ -589,15 +626,32 @@ const TILE_TYPES = {
|
||||
level.transmute_tile(other, 'explosion');
|
||||
}
|
||||
},
|
||||
// Not affected by gray buttons
|
||||
},
|
||||
purple_floor: {
|
||||
// TODO wired
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
on_gray_button(me, level) {
|
||||
level.transmute_tile(me, 'purple_wall');
|
||||
},
|
||||
on_power(me, level) {
|
||||
me.type.on_gray_button(me, level);
|
||||
},
|
||||
on_depower(me, level) {
|
||||
me.type.on_gray_button(me, level);
|
||||
},
|
||||
},
|
||||
purple_wall: {
|
||||
// TODO wired
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
blocks_all: true,
|
||||
on_gray_button(me, level) {
|
||||
level.transmute_tile(me, 'purple_floor');
|
||||
},
|
||||
on_power(me, level) {
|
||||
me.type.on_gray_button(me, level);
|
||||
},
|
||||
on_depower(me, level) {
|
||||
me.type.on_gray_button(me, level);
|
||||
},
|
||||
},
|
||||
cloner: {
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
@ -628,11 +682,40 @@ const TILE_TYPES = {
|
||||
}
|
||||
}
|
||||
},
|
||||
// Also clones on rising pulse or gray button
|
||||
on_power(me, level) {
|
||||
me.type.activate(me, level);
|
||||
},
|
||||
on_gray_button(me, level) {
|
||||
me.type.activate(me, level);
|
||||
},
|
||||
},
|
||||
trap: {
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
on_ready(me, level) {
|
||||
// Trap any actor standing on us, unless we already know we're pressed
|
||||
if (me.presses)
|
||||
return;
|
||||
|
||||
for (let tile of me.cell) {
|
||||
if (tile.type.is_actor) {
|
||||
tile.stuck = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
add_press_ready(me, level, other) {
|
||||
// Same as below, but without using the undo stack or ejection
|
||||
me.presses = (me.presses ?? 0) + 1;
|
||||
if (me.presses === 1) {
|
||||
for (let tile of me.cell) {
|
||||
if (tile.type.is_actor) {
|
||||
tile.stuck = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
on_arrive(me, level, other) {
|
||||
if (me.open) {
|
||||
if (me.presses) {
|
||||
// Lynx: Traps immediately eject their contents, if possible
|
||||
// TODO compat this, cc2 doens't do it!
|
||||
//level.attempt_step(other, other.direction);
|
||||
@ -641,8 +724,42 @@ const TILE_TYPES = {
|
||||
level.set_actor_stuck(other, true);
|
||||
}
|
||||
},
|
||||
add_press(me, level) {
|
||||
level._set_prop(me, 'presses', (me.presses ?? 0) + 1);
|
||||
if (me.presses === 1) {
|
||||
// Free everything on us, if we went from 0 to 1 presses (i.e. closed to open)
|
||||
for (let tile of Array.from(me.cell)) {
|
||||
if (tile.type.is_actor) {
|
||||
level.set_actor_stuck(tile, false);
|
||||
// Forcibly move anything released from a trap, to keep it in sync with
|
||||
// whatever pushed the button
|
||||
level.attempt_step(tile, tile.direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
remove_press(me, level) {
|
||||
level._set_prop(me, 'presses', me.presses - 1);
|
||||
if (me.presses === 0) {
|
||||
// Trap everything on us, if we went from 1 to 0 presses (i.e. open to closed)
|
||||
for (let tile of me.cell) {
|
||||
if (tile.type.is_actor) {
|
||||
level.set_actor_stuck(tile, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
on_power(me, level) {
|
||||
// Treat being powered or not as an extra kind of brown button press
|
||||
me.type.add_press(me, level);
|
||||
console.log("powering UP", me.presses);
|
||||
},
|
||||
on_depower(me, level) {
|
||||
me.type.remove_press(me, level);
|
||||
console.log("powering down...", me.presses);
|
||||
},
|
||||
visual_state(me) {
|
||||
if (me && me.open) {
|
||||
if (me && me.presses) {
|
||||
return 'open';
|
||||
}
|
||||
else {
|
||||
@ -689,37 +806,57 @@ const TILE_TYPES = {
|
||||
},
|
||||
teleport_blue: {
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
connects_to: 'teleport_blue',
|
||||
connect_order: 'backward',
|
||||
is_teleporter: true,
|
||||
teleport_dest_order(me, level) {
|
||||
return level.iter_tiles_in_reading_order(me.cell, 'teleport_blue', true);
|
||||
},
|
||||
},
|
||||
teleport_red: {
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
connects_to: 'teleport_red',
|
||||
connect_order: 'forward',
|
||||
is_teleporter: true,
|
||||
teleport_dest_order(me, level) {
|
||||
// FIXME you can control your exit direction from red teleporters
|
||||
return level.iter_tiles_in_reading_order(me.cell, 'teleport_red');
|
||||
},
|
||||
},
|
||||
teleport_green: {
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
// connects_to: 'teleport_red',
|
||||
// connect_order: 'forward',
|
||||
// is_teleporter: true,
|
||||
// FIXME completely different behavior from other teleporters
|
||||
teleport_dest_order(me, level) {
|
||||
// FIXME exit direction is random; unclear if it's any direction or only unblocked ones
|
||||
let all = Array.from(level.iter_tiles_in_reading_order(me.cell, 'teleport_green'));
|
||||
// FIXME this should use the lynxish rng
|
||||
return [random_choice(all), me];
|
||||
},
|
||||
},
|
||||
teleport_yellow: {
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
connects_to: 'teleport_yellow',
|
||||
connect_order: 'backward',
|
||||
is_teleporter: true,
|
||||
// FIXME special pickup behavior
|
||||
teleport_dest_order(me, level) {
|
||||
// FIXME special pickup behavior
|
||||
return level.iter_tiles_in_reading_order(me.cell, 'teleport_yellow', true);
|
||||
},
|
||||
},
|
||||
// FIXME do i want these as separate objects? what would they do, turn into each other? or should it be one with state?
|
||||
flame_jet_off: {
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
activate(me, level) {
|
||||
level.transmute_tile(me, 'flame_jet_on');
|
||||
},
|
||||
on_gray_button(me, level) {
|
||||
me.type.activate(me, level);
|
||||
},
|
||||
on_power(me, level) {
|
||||
me.type.activate(me, level);
|
||||
},
|
||||
},
|
||||
flame_jet_on: {
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
// FIXME every tic, kills every actor in the cell
|
||||
activate(me, level) {
|
||||
level.transmute_tile(me, 'flame_jet_off');
|
||||
},
|
||||
on_gray_button(me, level) {
|
||||
me.type.activate(me, level);
|
||||
},
|
||||
on_power(me, level) {
|
||||
me.type.activate(me, level);
|
||||
},
|
||||
},
|
||||
// Buttons
|
||||
button_blue: {
|
||||
@ -731,7 +868,7 @@ const TILE_TYPES = {
|
||||
for (let actor of level.actors) {
|
||||
// TODO generify somehow??
|
||||
if (actor.type.name === 'tank_blue') {
|
||||
level.set_actor_direction(actor, DIRECTIONS[actor.direction].opposite);
|
||||
level._set_prop(actor, 'pending_reverse', ! actor.pending_reverse);
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -790,36 +927,32 @@ const TILE_TYPES = {
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
connects_to: 'trap',
|
||||
connect_order: 'forward',
|
||||
on_arrive(me, level, other) {
|
||||
level.sfx.play_once('button-press', me.cell);
|
||||
on_ready(me, level) {
|
||||
// Inform the trap of any actors that start out holding us down
|
||||
let trap = me.connection;
|
||||
if (! (trap && trap.cell))
|
||||
return;
|
||||
|
||||
if (me.connection && me.connection.cell) {
|
||||
let trap = me.connection;
|
||||
level._set_prop(trap, 'open', true);
|
||||
for (let tile of trap.cell) {
|
||||
if (tile.type.is_actor) {
|
||||
if (tile.stuck) {
|
||||
level.set_actor_stuck(tile, false);
|
||||
}
|
||||
// Forcibly move anything released from a trap, to keep
|
||||
// it in sync with whatever pushed the button
|
||||
level.attempt_step(tile, tile.direction);
|
||||
}
|
||||
for (let tile of me.cell) {
|
||||
if (tile.type.is_actor) {
|
||||
trap.type.add_press_ready(trap, level);
|
||||
}
|
||||
}
|
||||
},
|
||||
on_arrive(me, level, other) {
|
||||
level.sfx.play_once('button-press', me.cell);
|
||||
|
||||
let trap = me.connection;
|
||||
if (trap && trap.cell) {
|
||||
trap.type.add_press(trap, level);
|
||||
}
|
||||
},
|
||||
on_depart(me, level, other) {
|
||||
// TODO this doesn't play if you walk straight across
|
||||
level.sfx.play_once('button-release', me.cell);
|
||||
|
||||
if (me.connection && me.connection.cell) {
|
||||
let trap = me.connection;
|
||||
level._set_prop(trap, 'open', false);
|
||||
for (let tile of trap.cell) {
|
||||
if (tile.type.is_actor) {
|
||||
level.set_actor_stuck(tile, true);
|
||||
}
|
||||
}
|
||||
let trap = me.connection;
|
||||
if (trap && trap.cell) {
|
||||
trap.type.remove_press(trap, level);
|
||||
}
|
||||
},
|
||||
},
|
||||
@ -830,8 +963,9 @@ const TILE_TYPES = {
|
||||
on_arrive(me, level, other) {
|
||||
level.sfx.play_once('button-press', me.cell);
|
||||
|
||||
if (me.connection && me.connection.cell) {
|
||||
me.connection.type.activate(me.connection, level);
|
||||
let cloner = me.connection;
|
||||
if (cloner && cloner.cell) {
|
||||
cloner.type.activate(cloner, level);
|
||||
}
|
||||
},
|
||||
on_depart(me, level, other) {
|
||||
@ -843,13 +977,47 @@ const TILE_TYPES = {
|
||||
// FIXME toggles flame jets, connected somehow, ???
|
||||
},
|
||||
button_pink: {
|
||||
// TODO not implemented
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
is_emitting(me, level) {
|
||||
// We emit current as long as there's an actor on us
|
||||
return me.cell.some(tile => tile.type.is_actor);
|
||||
},
|
||||
on_arrive(me, level, other) {
|
||||
level.sfx.play_once('button-press', me.cell);
|
||||
},
|
||||
on_depart(me, level, other) {
|
||||
level.sfx.play_once('button-release', me.cell);
|
||||
},
|
||||
},
|
||||
button_black: {
|
||||
// TODO not implemented
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
},
|
||||
button_gray: {
|
||||
// TODO only partially implemented
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
on_arrive(me, level, other) {
|
||||
level.sfx.play_once('button-press', me.cell);
|
||||
|
||||
for (let x = Math.max(0, me.cell.x - 2); x <= Math.min(level.width - 1, me.cell.x + 2); x++) {
|
||||
for (let y = Math.max(0, me.cell.y - 2); y <= Math.min(level.height - 1, me.cell.y + 2); y++) {
|
||||
let cell = level.cells[y][x];
|
||||
// TODO wait is this right
|
||||
if (cell === me.cell)
|
||||
continue;
|
||||
|
||||
for (let tile of cell) {
|
||||
if (tile.type.on_gray_button) {
|
||||
tile.type.on_gray_button(tile, level);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
on_depart(me, level, other) {
|
||||
level.sfx.play_once('button-release', me.cell);
|
||||
},
|
||||
},
|
||||
|
||||
// Time alteration
|
||||
stopwatch_bonus: {
|
||||
@ -1017,17 +1185,32 @@ const TILE_TYPES = {
|
||||
movement_speed: 4,
|
||||
},
|
||||
|
||||
// Keys
|
||||
// Note that red and blue keys do NOT block monsters, but yellow and green DO
|
||||
// Keys, whose behavior varies
|
||||
key_red: {
|
||||
// TODO Red key can ONLY be picked up by players (and doppelgangers), no other actor that
|
||||
// has an inventory
|
||||
draw_layer: LAYER_ITEM,
|
||||
is_item: true,
|
||||
is_key: true,
|
||||
},
|
||||
key_blue: {
|
||||
// Blue key is picked up by dirt blocks and all monsters, including those that don't have an
|
||||
// inventory normally
|
||||
draw_layer: LAYER_ITEM,
|
||||
is_item: true,
|
||||
is_key: true,
|
||||
on_arrive(me, level, other) {
|
||||
// Call it... everything except ice and directional blocks? These rules are weird.
|
||||
// Note that the game itself normally handles picking items up, so we only get here for
|
||||
// actors who aren't supposed to have an inventory
|
||||
// TODO make this a... flag? i don't know?
|
||||
// TODO major difference from lynx...
|
||||
if (other.type.name !== 'ice_block' && other.type.name !== 'directional_block') {
|
||||
if (level.give_actor(other, me.type.name)) {
|
||||
level.remove_tile(me);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
key_yellow: {
|
||||
draw_layer: LAYER_ITEM,
|
||||
@ -1176,6 +1359,7 @@ const TILE_TYPES = {
|
||||
infinite_items: {
|
||||
key_yellow: true,
|
||||
},
|
||||
visual_state: player_visual_state,
|
||||
},
|
||||
chip: {
|
||||
draw_layer: LAYER_ITEM,
|
||||
|
||||
Binary file not shown.
BIN
music/escape-on-star-road.ogg
Normal file
BIN
music/escape-on-star-road.ogg
Normal file
Binary file not shown.
BIN
music/learning-has-occurred.ogg
Normal file
BIN
music/learning-has-occurred.ogg
Normal file
Binary file not shown.
BIN
sfx/mmf-high.ogg
BIN
sfx/mmf-high.ogg
Binary file not shown.
127
style.css
127
style.css
@ -281,7 +281,7 @@ body > header {
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
|
||||
margin: 0.25em;
|
||||
padding: 0.5em;
|
||||
line-height: 1.125;
|
||||
}
|
||||
body > header h1 {
|
||||
@ -291,14 +291,13 @@ body > header h2 {
|
||||
font-size: 1.33em;
|
||||
}
|
||||
body > header h3 {
|
||||
font-size: 1.25em;
|
||||
font-size: 1.75em;
|
||||
}
|
||||
body > header > nav {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5em;
|
||||
margin: 0.25rem 0.5rem;
|
||||
}
|
||||
body > header button {
|
||||
font-size: 0.75em;
|
||||
@ -317,8 +316,25 @@ body[data-mode=player] #editor-play {
|
||||
order: 3;
|
||||
color: #606060;
|
||||
}
|
||||
#header-level {
|
||||
font-size: 1.5em;
|
||||
|
||||
@media (max-width: 800px) {
|
||||
body > header {
|
||||
padding: 0.25em;
|
||||
}
|
||||
/* All these headings are way too big on phones */
|
||||
body > header h1 {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
body > header h2 {
|
||||
font-size: 1.125em;
|
||||
}
|
||||
body > header h3 {
|
||||
font-size: 1.0625em;
|
||||
}
|
||||
body > header p {
|
||||
/* "a game by eevee" takes up too much space :( */
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/**************************************************************************************************/
|
||||
@ -379,6 +395,23 @@ body[data-mode=player] #editor-play {
|
||||
grid-area: yours;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
#splash {
|
||||
/* Grid layout doesn't fit, just stack everything */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 10% padding is way way too much */
|
||||
padding: 1em;
|
||||
}
|
||||
/* Shrink logo and title */
|
||||
#splash > header img {
|
||||
width: 48px;
|
||||
}
|
||||
#splash > header h1 {
|
||||
font-size: 2em;
|
||||
}
|
||||
}
|
||||
|
||||
button.level-pack-button {
|
||||
display: grid;
|
||||
grid:
|
||||
@ -469,7 +502,7 @@ button.level-pack-button p {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
z-index: 1;
|
||||
z-index: 2;
|
||||
font-size: calc(0.5 * var(--tile-width) * var(--scale));
|
||||
padding: 2%;
|
||||
background: #0009;
|
||||
@ -552,7 +585,6 @@ dl.score-chart .-sum {
|
||||
.bonus output {
|
||||
flex: 1;
|
||||
font-size: 2em;
|
||||
padding: 0.125em;
|
||||
min-width: 2em;
|
||||
min-height: 1em;
|
||||
line-height: 1;
|
||||
@ -584,9 +616,11 @@ dl.score-chart .-sum {
|
||||
}
|
||||
#player .bonus {
|
||||
visibility: hidden;
|
||||
display: none;
|
||||
}
|
||||
#player.--bonus-visible .bonus {
|
||||
visibility: initial;
|
||||
display: initial;
|
||||
}
|
||||
|
||||
.message {
|
||||
@ -644,11 +678,14 @@ dl.score-chart .-sum {
|
||||
#player-music {
|
||||
grid-area: music;
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
margin: 0 1em;
|
||||
text-transform: lowercase;
|
||||
color: #909090;
|
||||
}
|
||||
#player-music #player-music-left {
|
||||
flex: 1 0 auto;
|
||||
/* allow me to wrap if need be */
|
||||
flex: 1 0 0px;
|
||||
}
|
||||
#player-music #player-music-right {
|
||||
text-align: right;
|
||||
@ -709,6 +746,45 @@ main.--has-demo .demo-controls {
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 800px) {
|
||||
#player {
|
||||
/* sentinel for js */
|
||||
--is-portrait: 1;
|
||||
/* The play area isn't necessarily the biggest thing any more, and it's ugly when stretched */
|
||||
align-items: center;
|
||||
}
|
||||
#player > .-main-area {
|
||||
/* Rearrange the grid to be vertical */
|
||||
grid:
|
||||
"level level"
|
||||
"chips inventory"
|
||||
"time inventory"
|
||||
"bonus inventory"
|
||||
/ min-content min-content
|
||||
;
|
||||
row-gap: 0.5em;
|
||||
column-gap: 1em;
|
||||
|
||||
padding: 0.5em;
|
||||
}
|
||||
#player .inventory {
|
||||
/* stick me in the center right */
|
||||
place-self: center end;
|
||||
}
|
||||
#player .message {
|
||||
/* Overlay hints on the inventory area */
|
||||
grid-row: chips / bonus;
|
||||
grid-column: level;
|
||||
z-index: 1;
|
||||
font-size: calc(var(--tile-height) * var(--scale) / 2.5);
|
||||
}
|
||||
#player-music {
|
||||
/* Stack the title/artist on the volume, since they don't fit well side by side */
|
||||
font-size: 0.875em;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**************************************************************************************************/
|
||||
/* Editor */
|
||||
|
||||
@ -716,9 +792,9 @@ main.--has-demo .demo-controls {
|
||||
flex: 1 1 auto;
|
||||
display: grid;
|
||||
grid:
|
||||
"controls level" min-content
|
||||
"palette level" 1fr
|
||||
/ minmax(25%, auto) auto
|
||||
"controls controls" min-content
|
||||
"palette level" 1fr
|
||||
/ minmax(25%, auto) 1fr
|
||||
;
|
||||
gap: 0.5em;
|
||||
|
||||
@ -731,7 +807,12 @@ main.--has-demo .demo-controls {
|
||||
grid-area: level;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
/* Padding and background make it easier to tell when we're at the edge of the map */
|
||||
/* TODO padding should be half a cell, and svg should respect it too */
|
||||
/* padding: 1em; */
|
||||
background: #202020;
|
||||
}
|
||||
/* SVG overlays */
|
||||
#editor svg.level-editor-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@ -741,16 +822,30 @@ main.--has-demo .demo-controls {
|
||||
height: 1024px;
|
||||
/* allow clicks to go through us! */
|
||||
pointer-events: none;
|
||||
}
|
||||
#editor .level-editor-overlay rect.overlay-cxn {
|
||||
|
||||
/* default svg properties */
|
||||
stroke-width: 0.0625;
|
||||
stroke: red;
|
||||
fill: none;
|
||||
}
|
||||
#editor .level-editor-overlay line.overlay-cxn {
|
||||
stroke-width: 0.0625;
|
||||
#editor .level-editor-overlay rect.overlay-cxn {
|
||||
stroke: red;
|
||||
}
|
||||
#editor .level-editor-overlay line.overlay-cxn {
|
||||
stroke: red;
|
||||
}
|
||||
#editor .level-editor-overlay rect.overlay-camera {
|
||||
stroke: #808080;
|
||||
fill: #80808040;
|
||||
pointer-events: auto;
|
||||
}
|
||||
#editor .level-editor-overlay text {
|
||||
/* Each cell is one "pixel", so text needs to be real small */
|
||||
font-size: 1px;
|
||||
}
|
||||
#editor .level-editor-overlay text.overlay-edit-tip {
|
||||
stroke: none;
|
||||
fill: black;
|
||||
}
|
||||
|
||||
#editor .controls {
|
||||
grid-area: controls;
|
||||
|
||||
BIN
tileset-lexy.png
BIN
tileset-lexy.png
Binary file not shown.
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 54 KiB |
Binary file not shown.
Loading…
Reference in New Issue
Block a user