Merge remote-tracking branch 'upstream/master'
This commit is contained in:
commit
509b3ca3b7
@ -38,7 +38,9 @@ Give it a try, I guess! [https://c.eev.ee/lexys-labyrinth/](https://c.eev.ee/le
|
||||
|
||||
## Special thanks
|
||||
|
||||
- The incredible nerds who put together the [Chip Wiki](https://wiki.bitbusters.club/) and also reside on the Bit Busters Discord
|
||||
- The incredible nerds who put together the [Chip Wiki](https://wiki.bitbusters.club/) and also reside on the Bit Busters Discord, including:
|
||||
- ruben for documenting the CC2 PRNG
|
||||
- The Architect for documenting the CC2 C2G parser
|
||||
- Everyone who worked on [Chip's Challenge Level Pack 1](https://wiki.bitbusters.club/Chip%27s_Challenge_Level_Pack_1), the default set of levels
|
||||
- [Tile World](https://wiki.bitbusters.club/Tile_World) for being an incredible reference on Lynx mechanics
|
||||
- Everyone who contributed music — see [`js/soundtrack.js`](js/soundtrack.js) for a list!
|
||||
|
||||
@ -16,6 +16,10 @@ export class StoredLevel {
|
||||
this.extra_chunks = [];
|
||||
this.use_cc1_boots = false;
|
||||
this.use_ccl_compat = false;
|
||||
// 0 - deterministic (PRNG + simple convolution)
|
||||
// 1 - 4 patterns (default; PRNG + rotating through 0-3)
|
||||
// 2 - extra random (like deterministic, but initial seed is "actually" random)
|
||||
this.blob_behavior = 1;
|
||||
|
||||
this.size_x = 0;
|
||||
this.size_y = 0;
|
||||
|
||||
@ -536,7 +536,7 @@ const TILE_ENCODING = {
|
||||
name: 'popdown_floor',
|
||||
},
|
||||
0x7f: {
|
||||
name: 'forbidden',
|
||||
name: 'no_sign',
|
||||
has_next: true,
|
||||
},
|
||||
0x80: {
|
||||
@ -835,7 +835,7 @@ export function parse_level(buf, number = 1) {
|
||||
|
||||
if (view.byteLength <= 24)
|
||||
continue;
|
||||
//level.blob_behavior = view.getUint8(24, true);
|
||||
level.blob_behavior = view.getUint8(24, true);
|
||||
}
|
||||
else if (type === 'MAP ' || type === 'PACK') {
|
||||
if (type === 'PACK') {
|
||||
|
||||
151
js/game.js
151
js/game.js
@ -212,6 +212,16 @@ export class Level {
|
||||
this.hint_shown = null;
|
||||
// TODO in lynx/steam, this carries over between levels; in tile world, you can set it manually
|
||||
this.force_floor_direction = 'north';
|
||||
// PRNG is initialized to zero
|
||||
this._rng1 = 0;
|
||||
this._rng2 = 0;
|
||||
if (this.stored_level.blob_behavior === 0) {
|
||||
this._blob_modifier = 0x55;
|
||||
}
|
||||
else {
|
||||
// The other two modes are initialized to a random seed
|
||||
this._blob_modifier = Math.floor(Math.random() * 256);
|
||||
}
|
||||
|
||||
this.undo_stack = [];
|
||||
this.pending_undo = [];
|
||||
@ -322,15 +332,62 @@ export class Level {
|
||||
if (tile.type.on_ready) {
|
||||
tile.type.on_ready(tile, this);
|
||||
}
|
||||
if (cell === this.player.cell && tile.type.is_hint) {
|
||||
this.hint_shown = tile.specific_hint ?? this.stored_level.hint;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
player_awaiting_input() {
|
||||
return this.player.movement_cooldown === 0 && (this.player.slide_mode === null || (this.player.slide_mode === 'force' && this.player.last_move_was_force))
|
||||
}
|
||||
|
||||
// Lynx PRNG, used unchanged in CC2
|
||||
prng() {
|
||||
// TODO what if we just saved this stuff, as well as the RFF direction, at the beginning of
|
||||
// each tic?
|
||||
let rng1 = this._rng1;
|
||||
let rng2 = this._rng2;
|
||||
this.pending_undo.push(() => {
|
||||
this._rng1 = rng1;
|
||||
this._rng2 = rng2;
|
||||
});
|
||||
|
||||
let n = (this._rng1 >> 2) - this._rng1;
|
||||
if (!(this._rng1 & 0x02)) --n;
|
||||
this._rng1 = (this._rng1 >> 1) | (this._rng2 & 0x80);
|
||||
this._rng2 = (this._rng2 << 1) | (n & 0x01);
|
||||
let ret = (this._rng1 ^ this._rng2) & 0xff;
|
||||
return ret;
|
||||
}
|
||||
|
||||
// Weird thing done by CC2 to make blobs... more... random
|
||||
get_blob_modifier() {
|
||||
let mod = this._blob_modifier;
|
||||
this.pending_undo.push(() => this._blob_modifier = mod);
|
||||
|
||||
if (this.stored_level.blob_behavior === 1) {
|
||||
// "4 patterns" just increments by 1 every time (but /after/ returning)
|
||||
//this._blob_modifier = (this._blob_modifier + 1) % 4;
|
||||
mod = (mod + 1) % 4;
|
||||
this._blob_modifier = mod;
|
||||
}
|
||||
else {
|
||||
// Other modes do this curious operation
|
||||
mod *= 2;
|
||||
if (mod < 255) {
|
||||
mod ^= 0x1d;
|
||||
}
|
||||
mod &= 0xff;
|
||||
this._blob_modifier = mod;
|
||||
}
|
||||
|
||||
return mod;
|
||||
}
|
||||
|
||||
// Move the game state forwards by one tic
|
||||
// split into two parts for turn-based mode: first part is the consequences of the previous tick, second part depends on the player's input
|
||||
advance_tic(p1_primary_direction, p1_secondary_direction, pass) {
|
||||
@ -649,11 +706,9 @@ export class Level {
|
||||
direction_preference = [actor.direction, d.opposite];
|
||||
}
|
||||
else if (actor.type.movement_mode === 'bounce-random') {
|
||||
// walker behavior: preserve current direction; if that
|
||||
// doesn't work, pick a random direction, even the one we
|
||||
// failed to move in
|
||||
// TODO unclear if this is right in cc2 as well. definitely not in ms, which chooses a legal move
|
||||
direction_preference = [actor.direction, ['north', 'south', 'east', 'west'][Math.floor(Math.random() * 4)]];
|
||||
// walker behavior: preserve current direction; if that doesn't work, pick a random
|
||||
// direction, even the one we failed to move in (but ONLY then)
|
||||
direction_preference = [actor.direction, 'WALKER'];
|
||||
}
|
||||
else if (actor.type.movement_mode === 'pursue') {
|
||||
// teeth behavior: always move towards the player
|
||||
@ -692,14 +747,27 @@ export class Level {
|
||||
}
|
||||
else if (actor.type.movement_mode === 'random') {
|
||||
// blob behavior: move completely at random
|
||||
// TODO cc2 has twiddles for how this works per-level, as well as the initial seed for demo playback
|
||||
direction_preference = [['north', 'south', 'east', 'west'][Math.floor(Math.random() * 4)]];
|
||||
let modifier = this.get_blob_modifier();
|
||||
direction_preference = [['north', 'east', 'south', 'west'][(this.prng() + modifier) % 4]];
|
||||
}
|
||||
|
||||
// Check which of those directions we *can*, probably, move in
|
||||
// TODO i think player on force floor will still have some issues here
|
||||
// FIXME probably bail earlier for stuck actors so the prng isn't advanced? what is the
|
||||
// lynx behavior? also i hear something about blobs on cloners??
|
||||
if (direction_preference && ! actor.stuck) {
|
||||
let fallback_direction;
|
||||
for (let direction of direction_preference) {
|
||||
if (direction === 'WALKER') {
|
||||
// Walkers roll a random direction ONLY if their first attempt was blocked
|
||||
direction = actor.direction;
|
||||
let num_turns = this.prng() % 4;
|
||||
for (let i = 0; i < num_turns; i++) {
|
||||
direction = DIRECTIONS[direction].right;
|
||||
}
|
||||
}
|
||||
fallback_direction = direction;
|
||||
|
||||
let dest_cell = this.get_neighboring_cell(actor.cell, direction);
|
||||
if (! dest_cell)
|
||||
continue;
|
||||
@ -712,6 +780,12 @@ export class Level {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If all the decisions are blocked, actors still try the last one (and might even
|
||||
// be able to move that way by the time their turn comes around!)
|
||||
if (actor.decision === null) {
|
||||
actor.decision = fallback_direction;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -917,14 +991,15 @@ export class Level {
|
||||
continue;
|
||||
|
||||
// TODO some actors can pick up some items...
|
||||
if (actor.type.is_player && tile.type.is_item && this.give_actor(actor, tile.type.name)) {
|
||||
if (actor.type.is_player && tile.type.is_item &&
|
||||
this.attempt_take(actor, tile))
|
||||
{
|
||||
if (tile.type.is_key) {
|
||||
this.sfx.play_once('get-key', cell);
|
||||
}
|
||||
else {
|
||||
this.sfx.play_once('get-tool', cell);
|
||||
}
|
||||
this.remove_tile(tile);
|
||||
}
|
||||
else if (tile.type.teleport_dest_order) {
|
||||
teleporter = tile;
|
||||
@ -935,25 +1010,55 @@ 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?
|
||||
// FIXME something funny happening here, your input isn't ignored while walking out of it?
|
||||
if (teleporter) {
|
||||
for (let dest of teleporter.type.teleport_dest_order(teleporter, this)) {
|
||||
let original_direction = actor.direction;
|
||||
let success = false;
|
||||
for (let dest of teleporter.type.teleport_dest_order(teleporter, this, actor)) {
|
||||
// Teleporters already containing an actor are blocked and unusable
|
||||
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 lynx treats this as a slide and does it in a pass in the main loop
|
||||
// XXX not especially undo-efficient
|
||||
this.remove_tile(actor);
|
||||
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; position isn't updated til next turn
|
||||
this.sfx.play_once('teleport', dest.cell);
|
||||
|
||||
// Red and green teleporters attempt to spit you out in every direction before
|
||||
// giving up on a destination (but not if you return to the original).
|
||||
// Note that we use actor.direction here (rather than original_direction) because
|
||||
// green teleporters modify it in teleport_dest_order, to randomize the exit
|
||||
// direction
|
||||
let direction = actor.direction;
|
||||
let num_directions = 1;
|
||||
if (teleporter.type.teleport_try_all_directions && dest !== teleporter) {
|
||||
num_directions = 4;
|
||||
}
|
||||
for (let i = 0; i < num_directions; i++) {
|
||||
if (this.attempt_step(actor, direction)) {
|
||||
success = true;
|
||||
// Sound plays from the origin cell simply because that's where the sfx player
|
||||
// thinks the player is currently; position isn't updated til next turn
|
||||
this.sfx.play_once('teleport', teleporter.cell);
|
||||
break;
|
||||
}
|
||||
else {
|
||||
direction = DIRECTIONS[direction].right;
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
break;
|
||||
}
|
||||
else if (num_directions === 4) {
|
||||
// Restore our original facing before continuing
|
||||
// (For red teleports, we try every possible destination in our original
|
||||
// movement direction, so this is correct. For green teleports, we only try one
|
||||
// destination and then fall back to walking through the source in our original
|
||||
// movement direction, so this is still correct.)
|
||||
this.set_actor_direction(actor, original_direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1297,6 +1402,18 @@ export class Level {
|
||||
}
|
||||
}
|
||||
|
||||
// Have an actor try to pick up a particular tile; it's prevented if there's a no sign, and the
|
||||
// tile is removed if successful
|
||||
attempt_take(actor, tile) {
|
||||
if (! tile.cell.some(t => t.type.disables_pickup) &&
|
||||
this.give_actor(actor, tile.type.name))
|
||||
{
|
||||
this.remove_tile(tile);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@ -679,8 +679,10 @@ class Player extends PrimaryView {
|
||||
|
||||
play_demo() {
|
||||
this.restart_level();
|
||||
this.demo_faucet = this.level.stored_level.demo[Symbol.iterator]();
|
||||
this.level.force_floor_direction = this.level.stored_level.demo.initial_force_floor_direction;
|
||||
let demo = this.level.stored_level.demo;
|
||||
this.demo_faucet = demo[Symbol.iterator]();
|
||||
this.level.force_floor_direction = demo.initial_force_floor_direction;
|
||||
this.level._blob_modifier = demo.blob_seed;
|
||||
// FIXME should probably start playback on first real input
|
||||
this.set_state('playing');
|
||||
}
|
||||
|
||||
@ -128,7 +128,9 @@ export class CanvasRenderer {
|
||||
// Draw one layer at a time, so animated objects aren't overdrawn by
|
||||
// neighboring terrain
|
||||
// XXX layer count hardcoded here
|
||||
for (let layer = 0; layer < 4; layer++) {
|
||||
// FIXME this is a bit inefficient when there are a lot of rarely-used layers; consider
|
||||
// instead drawing everything under actors, then actors, then everything above actors?
|
||||
for (let layer = 0; layer < 5; layer++) {
|
||||
for (let x = xf0; x <= x1; x++) {
|
||||
for (let y = yf0; y <= y1; y++) {
|
||||
for (let tile of this.level.cells[y][x]) {
|
||||
|
||||
@ -98,7 +98,7 @@ export const CC2_TILESET_LAYOUT = {
|
||||
popdown_wall: [12, 5],
|
||||
popdown_floor: [12, 5],
|
||||
popdown_floor_visible: [13, 5],
|
||||
forbidden: [14, 5],
|
||||
no_sign: [14, 5],
|
||||
// TODO arrows overlay at [3, 10]
|
||||
directional_block: [15, 5],
|
||||
|
||||
|
||||
@ -4,8 +4,9 @@ import { random_choice } from './util.js';
|
||||
// Draw layers
|
||||
const LAYER_TERRAIN = 0;
|
||||
const LAYER_ITEM = 1;
|
||||
const LAYER_ACTOR = 2;
|
||||
const LAYER_OVERLAY = 3;
|
||||
const LAYER_NO_SIGN = 2;
|
||||
const LAYER_ACTOR = 3;
|
||||
const LAYER_OVERLAY = 4;
|
||||
// TODO cc2 order is: swivel, thinwalls, canopy (and yes you can have them all in the same tile)
|
||||
|
||||
function player_visual_state(me) {
|
||||
@ -543,8 +544,19 @@ const TILE_TYPES = {
|
||||
}
|
||||
},
|
||||
},
|
||||
forbidden: {
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
no_sign: {
|
||||
draw_layer: LAYER_NO_SIGN,
|
||||
disables_pickup: true,
|
||||
blocks(me, level, other) {
|
||||
let item;
|
||||
for (let tile of me.cell) {
|
||||
if (tile.type.is_item) {
|
||||
item = tile.type.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return item && other.has_item(item);
|
||||
},
|
||||
},
|
||||
|
||||
// Mechanisms
|
||||
@ -806,30 +818,42 @@ const TILE_TYPES = {
|
||||
},
|
||||
teleport_blue: {
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
teleport_dest_order(me, level) {
|
||||
teleport_dest_order(me, level, other) {
|
||||
return level.iter_tiles_in_reading_order(me.cell, 'teleport_blue', true);
|
||||
},
|
||||
},
|
||||
teleport_red: {
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
teleport_dest_order(me, level) {
|
||||
// FIXME you can control your exit direction from red teleporters
|
||||
teleport_try_all_directions: true,
|
||||
teleport_allow_override: true,
|
||||
teleport_dest_order(me, level, other) {
|
||||
return level.iter_tiles_in_reading_order(me.cell, 'teleport_red');
|
||||
},
|
||||
},
|
||||
teleport_green: {
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
teleport_dest_order(me, level) {
|
||||
// FIXME exit direction is random; unclear if it's any direction or only unblocked ones
|
||||
teleport_try_all_directions: true,
|
||||
teleport_dest_order(me, level, other) {
|
||||
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];
|
||||
if (all.length <= 1) {
|
||||
// If this is the only teleporter, just walk out the other side — and, crucially, do
|
||||
// NOT advance the PRNG
|
||||
return [me];
|
||||
}
|
||||
// Note the iterator starts on the /next/ teleporter, so there's an implicit +1 here.
|
||||
// The -1 is to avoid spitting us back out of the same teleporter, which will be last in
|
||||
// the list
|
||||
let target = all[level.prng() % (all.length - 1)];
|
||||
// Also set the actor's (initial) exit direction
|
||||
level.set_actor_direction(other, ['north', 'east', 'south', 'west'][level.prng() % 4]);
|
||||
return [target, me];
|
||||
},
|
||||
},
|
||||
teleport_yellow: {
|
||||
draw_layer: LAYER_TERRAIN,
|
||||
teleport_dest_order(me, level) {
|
||||
// FIXME special pickup behavior
|
||||
teleport_allow_override: true,
|
||||
teleport_dest_order(me, level, other) {
|
||||
// FIXME special pickup behavior; NOT an item though, does not combine with no sign
|
||||
return level.iter_tiles_in_reading_order(me.cell, 'teleport_yellow', true);
|
||||
},
|
||||
},
|
||||
@ -1207,9 +1231,7 @@ const TILE_TYPES = {
|
||||
// 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);
|
||||
}
|
||||
level.attempt_take(other, me);
|
||||
}
|
||||
},
|
||||
},
|
||||
@ -1507,7 +1529,7 @@ for (let [name, type] of Object.entries(TILE_TYPES)) {
|
||||
|
||||
if (type.draw_layer === undefined ||
|
||||
type.draw_layer !== Math.floor(type.draw_layer) ||
|
||||
type.draw_layer >= 4)
|
||||
type.draw_layer >= 5)
|
||||
{
|
||||
console.error(`Tile type ${name} has a bad draw layer`);
|
||||
}
|
||||
|
||||
Binary file not shown.
Loading…
Reference in New Issue
Block a user