Merge remote-tracking branch 'upstream/master'

This commit is contained in:
Timothy Stiles 2020-10-25 14:31:32 +11:00
commit 509b3ca3b7
9 changed files with 190 additions and 41 deletions

View File

@ -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!

View File

@ -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;

View File

@ -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') {

View File

@ -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
// 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', dest.cell);
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)

View File

@ -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');
}

View File

@ -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]) {

View File

@ -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],

View File

@ -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.