Add timid teeth; move movement decisions onto tile types; improve doppelganger behavior

This commit is contained in:
Eevee (Evelyn Woods) 2020-12-06 16:16:04 -07:00
parent d981a0a4be
commit 54381370c0
5 changed files with 206 additions and 158 deletions

View File

@ -420,7 +420,6 @@ const TILE_ENCODING = {
name: 'teeth_timid',
has_next: true,
extra_args: [arg_direction],
error: "Timid chomper is not yet implemented, sorry!",
},
0x58: {
// TODO??? unused in main levels -- name: '',

View File

@ -175,6 +175,10 @@ export class Cell extends Array {
return null;
}
has(name) {
return this.some(tile => tile.name === name);
}
blocks_leaving(actor, direction) {
for (let tile of this) {
if (tile === actor)
@ -594,21 +598,20 @@ export class Level {
if (actor.movement_cooldown > 0)
continue;
// Teeth can only move the first 4 of every 8 tics, though "first"
// can be adjusted
if (actor.slide_mode === null &&
actor.type.uses_teeth_hesitation &&
(this.tic_counter + this.step_parity) % 8 >= 4)
// Teeth can only move the first 4 of every 8 tics, and mimics only the first 4 of every
// 16, though "first" can be adjusted
if (actor.slide_mode === null && actor.type.movement_parity &&
(this.tic_counter + this.step_parity) % (actor.type.movement_parity * 4) >= 4)
{
continue;
}
let direction_preference;
if (this.compat.sliding_tanks_ignore_button &&
actor.slide_mode && actor.pending_reverse)
{
this._set_tile_prop(actor, 'pending_reverse', false);
}
if (actor.pending_push) {
// Blocks that were pushed while sliding will move in the push direction as soon as
// they stop sliding, regardless of what they landed on
@ -616,6 +619,8 @@ export class Level {
this._set_tile_prop(actor, 'pending_push', null);
continue;
}
let direction_preference;
if (actor.slide_mode === 'ice') {
// Actors can't make voluntary moves on ice; they just slide
actor.decision = actor.direction;
@ -637,7 +642,14 @@ export class Level {
continue;
}
// FIXME this isn't right; if primary is blocked, they move secondary, but they also
// ignore railroad redirection until next tic
this.remember_player_move(p1_actions.primary);
if (p1_actions.primary) {
// FIXME something is wrong with direction preferences! if you hold both keys
// in a corner, no matter which you pressed first, cc2 always tries vert first
// and horiz last (so you're pushing horizontally)!
direction_preference = [p1_actions.primary];
if (p1_actions.secondary) {
direction_preference.push(p1_actions.secondary);
@ -660,116 +672,18 @@ export class Level {
// go back into the trap. this is consistent with CC2 but not ms/lynx
continue;
}
// FIXME seems like all this could be moved into a tile type function since it's all
// only used basically one time each
else if (actor.type.decide_movement) {
direction_preference = actor.type.decide_movement(actor, this);
}
else if (actor.type.movement_mode === 'forward') {
// 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_tile_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;
}
continue;
}
else if (actor.type.movement_mode === 'follow-left') {
// bug behavior: always try turning as left as possible, and
// fall back to less-left turns when that fails
let d = DIRECTIONS[actor.direction];
direction_preference = [d.left, actor.direction, d.right, d.opposite];
}
else if (actor.type.movement_mode === 'follow-right') {
// paramecium behavior: always try turning as right as
// possible, and fall back to less-right turns when that fails
let d = DIRECTIONS[actor.direction];
direction_preference = [d.right, actor.direction, d.left, d.opposite];
}
else if (actor.type.movement_mode === 'turn-left') {
// glider behavior: preserve current direction; if that doesn't
// work, turn left, then right, then back the way we came
let d = DIRECTIONS[actor.direction];
direction_preference = [actor.direction, d.left, d.right, d.opposite];
}
else if (actor.type.movement_mode === 'turn-right') {
// fireball behavior: preserve current direction; if that doesn't
// work, turn right, then left, then back the way we came
let d = DIRECTIONS[actor.direction];
direction_preference = [actor.direction, d.right, d.left, d.opposite];
}
else if (actor.type.movement_mode === 'bounce') {
// bouncy ball behavior: preserve current direction; if that
// doesn't work, bounce back the way we came
let d = DIRECTIONS[actor.direction];
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 (but ONLY then)
direction_preference = [actor.direction, 'WALKER'];
}
else if (actor.type.movement_mode === 'pursue') {
// teeth behavior: always move towards the player
let target_cell = this.player.cell;
// CC2 behavior (not Lynx (TODO compat?)): pursue the cell the player is leaving, if
// they're still mostly in it
if (this.player.previous_cell && this.player.animation_speed &&
this.player.animation_progress <= this.player.animation_speed / 2)
{
target_cell = this.player.previous_cell;
}
let dx = actor.cell.x - target_cell.x;
let dy = actor.cell.y - target_cell.y;
let preferred_horizontal, preferred_vertical;
if (dx > 0) {
preferred_horizontal = 'west';
}
else if (dx < 0) {
preferred_horizontal = 'east';
}
if (dy > 0) {
preferred_vertical = 'north';
}
else if (dy < 0) {
preferred_vertical = 'south';
}
// Chooses the furthest direction, vertical wins ties
if (Math.abs(dx) > Math.abs(dy)) {
// Horizontal first
direction_preference = [preferred_horizontal, preferred_vertical].filter(x => x);
}
else {
// Vertical first
direction_preference = [preferred_vertical, preferred_horizontal].filter(x => x);
}
}
else if (actor.type.movement_mode === 'random') {
// blob behavior: move completely at random
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
if (direction_preference) {
let fallback_direction;
for (let [i, direction] of direction_preference.entries()) {
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;
}
if (typeof direction === 'function') {
// Lazy direction calculation (used for walkers)
direction = direction();
}
fallback_direction = direction;
direction = actor.cell.redirect_exit(actor, direction);
@ -793,18 +707,6 @@ export class Level {
}
}
}
// Do some cleanup for the player
if (actor === this.player) {
// Sorry for the confusion; "p1" and "p2" in the direction args refer to physical
// human players, NOT to the two types of player tiles!
if (this.player.type.name === 'player') {
this.player1_move = actor.decision;
}
else {
this.player2_move = actor.decision;
}
}
}
// Third pass: everyone actually moves
@ -866,16 +768,11 @@ export class Level {
}
}
// In the event that the player is sliding (and thus not deliberately moving), remember
// their current movement direction.
// In the event that the player is sliding (and thus not deliberately moving) or has
// stopped, remember their current movement direction here, too.
// This is hokey, and doing it here is even hokier, but it seems to match CC2 behavior.
if (this.player.movement_cooldown > 0) {
if (this.player.type.name === 'player') {
this.player1_move = this.player.direction;
}
else {
this.player2_move = this.player.direction;
}
this.remember_player_move(this.player.direction);
}
// Strip out any destroyed actors from the acting order
@ -1208,6 +1105,15 @@ export class Level {
}
}
remember_player_move(direction) {
if (this.player.type.name === 'player') {
this.player1_move = direction;
}
else {
this.player2_move = direction;
}
}
cycle_inventory(actor) {
if (this.stored_level.use_cc1_boots)
return;

View File

@ -852,8 +852,6 @@ const EDITOR_PALETTE = [{
}, {
title: "Creatures",
tiles: [
'doppelganger1',
'doppelganger2',
'tank_blue',
'tank_yellow',
'ball',
@ -862,8 +860,15 @@ const EDITOR_PALETTE = [{
'glider',
'bug',
'paramecium',
'blob',
'doppelganger1',
'doppelganger2',
'teeth',
'teeth_timid',
'floor_mimic',
'ghost',
'rover',
'blob',
],
}, {
title: "Mechanisms",
@ -1146,6 +1151,7 @@ export class Editor extends PrimaryView {
_make_empty_level(number, size_x, size_y) {
let stored_level = new format_base.StoredLevel(number);
stored_level.title = "untitled level";
stored_level.size_x = size_x;
stored_level.size_y = size_y;
stored_level.viewport_size = 10;
@ -1206,6 +1212,7 @@ export class Editor extends PrimaryView {
let stored_level = this._make_empty_level(1, 32, 32);
let stored_pack = new format_base.StoredPack(null);
stored_pack.title = "scratch pack";
stored_pack.level_metadata.push({
stored_level: stored_level,
});

View File

@ -284,12 +284,11 @@ export const CC2_TILESET_LAYOUT = {
// TODO only animates while moving
teeth: {
// NOTE: CC2 inexplicably dropped north teeth and just uses the south
// sprites instead
north: [[0, 11], [1, 11], [2, 11]],
east: [[3, 11], [4, 11], [5, 11]],
south: [[0, 11], [1, 11], [2, 11]],
west: [[6, 11], [7, 11], [8, 11]],
// NOTE: CC2 inexplicably dropped north teeth and just uses the south sprites instead
north: [[1, 11], [0, 11], [1, 11], [2, 11]],
east: [[4, 11], [3, 11], [4, 11], [5, 11]],
south: [[1, 11], [0, 11], [1, 11], [2, 11]],
west: [[7, 11], [6, 11], [7, 11], [8, 11]],
},
swivel_sw: [9, 11],
swivel_nw: [10, 11],
@ -329,6 +328,14 @@ export const CC2_TILESET_LAYOUT = {
// TODO [15, 16] some kinda yellow/black outline
// timid teeth
teeth_timid: {
// NOTE: CC2 inexplicably dropped north teeth and just uses the south sprites instead
// NOTE: it also skimped on timid teeth frames
north: [[1, 17], [0, 17]],
east: [[3, 17], [2, 17]],
south: [[1, 17], [0, 17]],
west: [[5, 17], [4, 17]],
},
bowling_ball: [6, 17], // TODO also +18 when rolling
tank_yellow: {
north: [[8, 17], [9, 17]],
@ -763,8 +770,14 @@ export const TILE_WORLD_TILESET_LAYOUT = {
export const LL_TILESET_LAYOUT = Object.assign({}, CC2_TILESET_LAYOUT, {
// Completed teeth sprites
teeth: Object.assign({}, CC2_TILESET_LAYOUT.teeth, {
north: [[0, 32], [1, 32], [2, 32], [1, 32]],
north: [[1, 32], [0, 32], [1, 32], [2, 32]],
}),
teeth_timid: {
north: [[7, 32], [6, 32], [7, 32], [8, 32]],
east: [[4, 32], [2, 17], [4, 32], [3, 17]],
south: [[3, 32], [0, 17], [3, 32], [1, 17]],
west: [[5, 32], [4, 17], [5, 32], [5, 17]],
},
// Extra player sprites
player: Object.assign({}, CC2_TILESET_LAYOUT.player, {

View File

@ -83,6 +83,44 @@ function player_visual_state(me) {
}
}
// Logic for chasing after the player (or running away); shared by both teeth and mimics
function pursue_player(me, level) {
let player = level.player;
let target_cell = player.cell;
// CC2 behavior (not Lynx (TODO compat?)): pursue the cell the player is leaving, if
// they're still mostly in it
if (player.previous_cell && player.animation_speed &&
player.animation_progress <= player.animation_speed / 2)
{
target_cell = player.previous_cell;
}
let dx = me.cell.x - target_cell.x;
let dy = me.cell.y - target_cell.y;
let preferred_horizontal, preferred_vertical;
if (dx > 0) {
preferred_horizontal = 'west';
}
else if (dx < 0) {
preferred_horizontal = 'east';
}
if (dy > 0) {
preferred_vertical = 'north';
}
else if (dy < 0) {
preferred_vertical = 'south';
}
// Chooses the furthest direction, vertical wins ties
if (Math.abs(dx) > Math.abs(dy)) {
// Horizontal first
return [preferred_horizontal, preferred_vertical].filter(x => x);
}
else {
// Vertical first
return [preferred_vertical, preferred_horizontal].filter(x => x);
}
}
const TILE_TYPES = {
// Floors and walls
floor: {
@ -996,7 +1034,8 @@ const TILE_TYPES = {
_mogrifications: {
player: 'player2',
player2: 'player',
// TODO mirror players too
doppelganger1: 'doppelganger2',
doppelganger2: 'doppelganger1',
dirt_block: 'ice_block',
ice_block: 'dirt_block',
@ -1012,9 +1051,10 @@ const TILE_TYPES = {
tank_blue: 'tank_yellow',
tank_yellow: 'tank_blue',
// TODO teeth, timid teeth
teeth: 'teeth_timid',
teeth_timid: 'teeth',
},
_blob_mogrifications: ['ball', 'walker', 'fireball', 'glider', 'paramecium', 'bug', 'tank_blue', 'teeth', /* TODO 'timid_teeth' */ ],
_blob_mogrifications: ['ball', 'walker', 'fireball', 'glider', 'paramecium', 'bug', 'tank_blue', 'teeth', 'teeth_timid'],
// TODO can be wired, in which case only works when powered; other minor concerns, see wiki
on_arrive(me, level, other) {
let name = other.type.name;
@ -1484,8 +1524,12 @@ const TILE_TYPES = {
is_monster: true,
collision_mask: COLLISION.bug,
blocks_collision: COLLISION.all_but_player,
movement_mode: 'follow-left',
movement_speed: 4,
decide_movement(me, level) {
// always try turning as left as possible, and fall back to less-left turns
let d = DIRECTIONS[me.direction];
return [d.left, me.direction, d.right, d.opposite];
},
},
paramecium: {
draw_layer: DRAW_LAYERS.actor,
@ -1493,8 +1537,12 @@ const TILE_TYPES = {
is_monster: true,
collision_mask: COLLISION.monster_generic,
blocks_collision: COLLISION.all_but_player,
movement_mode: 'follow-right',
movement_speed: 4,
decide_movement(me, level) {
// always try turning as right as possible, and fall back to less-right turns
let d = DIRECTIONS[me.direction];
return [d.right, me.direction, d.left, d.opposite];
},
},
ball: {
draw_layer: DRAW_LAYERS.actor,
@ -1502,8 +1550,12 @@ const TILE_TYPES = {
is_monster: true,
collision_mask: COLLISION.monster_generic,
blocks_collision: COLLISION.all_but_player,
movement_mode: 'bounce',
movement_speed: 4,
decide_movement(me, level) {
// preserve current direction; if that doesn't work, bounce back the way we came
let d = DIRECTIONS[me.direction];
return [me.direction, d.opposite];
},
},
walker: {
draw_layer: DRAW_LAYERS.actor,
@ -1511,8 +1563,22 @@ const TILE_TYPES = {
is_monster: true,
collision_mask: COLLISION.monster_generic,
blocks_collision: COLLISION.all_but_player,
movement_mode: 'bounce-random',
movement_speed: 4,
decide_movement(me, level) {
// preserve current direction; if that doesn't work, pick a random direction, even the
// one we failed to move in (but ONLY then; important for RNG sync)
return [
me.direction,
() => {
let direction = me.direction;
let num_turns = level.prng() % 4;
for (let i = 0; i < num_turns; i++) {
direction = DIRECTIONS[direction].right;
}
return direction;
},
];
},
},
tank_blue: {
draw_layer: DRAW_LAYERS.actor,
@ -1520,8 +1586,21 @@ const TILE_TYPES = {
is_monster: true,
collision_mask: COLLISION.monster_generic,
blocks_collision: COLLISION.all_but_player,
movement_mode: 'forward',
movement_speed: 4,
decide_movement(me, level) {
// always keep moving forward, but reverse if the flag is set
let direction = me.direction;
if (me.pending_reverse) {
direction = DIRECTIONS[me.direction].opposite;
level._set_tile_prop(me, 'pending_reverse', false);
}
if (me.cell.has('cloner')) {
// Tanks on cloners should definitely ignore the flag, but we clear it first
// TODO feels clumsy
return null;
}
return [direction];
}
},
tank_yellow: {
draw_layer: DRAW_LAYERS.actor,
@ -1542,8 +1621,12 @@ const TILE_TYPES = {
is_monster: true,
collision_mask: COLLISION.monster_generic,
blocks_collision: COLLISION.all_but_player,
movement_mode: 'random',
movement_speed: 8,
decide_movement(me, level) {
// move completely at random
let modifier = level.get_blob_modifier();
return [['north', 'east', 'south', 'west'][(level.prng() + modifier) % 4]];
},
},
teeth: {
draw_layer: DRAW_LAYERS.actor,
@ -1551,9 +1634,37 @@ const TILE_TYPES = {
is_monster: true,
collision_mask: COLLISION.monster_generic,
blocks_collision: COLLISION.all_but_player,
movement_mode: 'pursue',
movement_speed: 4,
uses_teeth_hesitation: true,
movement_parity: 2,
decide_movement(me, level) {
let preference = pursue_player(me, level);
if (level.player.type.name === 'player2') {
// Run away from Cerise
for (let [i, direction] of preference.entries()) {
preference[i] = DIRECTIONS[direction].opposite;
}
}
return preference;
},
},
teeth_timid: {
draw_layer: DRAW_LAYERS.actor,
is_actor: true,
is_monster: true,
collision_mask: COLLISION.monster_generic,
blocks_collision: COLLISION.all_but_player,
movement_speed: 4,
movement_parity: 2,
decide_movement(me, level) {
let preference = pursue_player(me, level);
if (level.player.type.name === 'player') {
// Run away from Lexy
for (let [i, direction] of preference.entries()) {
preference[i] = DIRECTIONS[direction].opposite;
}
}
return preference;
},
},
fireball: {
draw_layer: DRAW_LAYERS.actor,
@ -1561,9 +1672,14 @@ const TILE_TYPES = {
is_monster: true,
collision_mask: COLLISION.fireball,
blocks_collision: COLLISION.all_but_player,
movement_mode: 'turn-right',
movement_speed: 4,
ignores: new Set(['fire', 'flame_jet_on']),
decide_movement(me, level) {
// turn right: preserve current direction; if that doesn't work, turn right, then left,
// then back the way we came
let d = DIRECTIONS[me.direction];
return [me.direction, d.right, d.left, d.opposite];
},
},
glider: {
draw_layer: DRAW_LAYERS.actor,
@ -1571,9 +1687,14 @@ const TILE_TYPES = {
is_monster: true,
collision_mask: COLLISION.monster_generic,
blocks_collision: COLLISION.all_but_player,
movement_mode: 'turn-left',
movement_speed: 4,
ignores: new Set(['water']),
decide_movement(me, level) {
// turn left: preserve current direction; if that doesn't work, turn left, then right,
// then back the way we came
let d = DIRECTIONS[me.direction];
return [me.direction, d.left, d.right, d.opposite];
},
},
ghost: {
draw_layer: DRAW_LAYERS.actor,
@ -1582,9 +1703,15 @@ const TILE_TYPES = {
collision_mask: COLLISION.ghost,
blocks_collision: COLLISION.all_but_player,
has_inventory: true,
movement_mode: 'turn-right',
movement_speed: 4,
// TODO ignores /most/ walls. collision is basically completely different. has a regular inventory, except red key. good grief
decide_movement(me, level) {
// turn right: preserve current direction; if that doesn't work, turn right, then left,
// then back the way we came
// (same as fireball)
let d = DIRECTIONS[me.direction];
return [me.direction, d.right, d.left, d.opposite];
},
},
floor_mimic: {
draw_layer: DRAW_LAYERS.actor,
@ -1592,10 +1719,9 @@ const TILE_TYPES = {
is_monster: true,
collision_mask: COLLISION.monster_generic,
blocks_collision: COLLISION.all_but_player,
// TODO not like teeth; always pursues
// TODO takes 3 turns off!
movement_mode: 'pursue',
movement_speed: 4,
movement_parity: 4,
decide_movement: pursue_player,
},
rover: {
// TODO this guy is a nightmare
@ -1606,7 +1732,6 @@ const TILE_TYPES = {
collision_mask: COLLISION.rover,
blocks_collision: COLLISION.all_but_player,
can_reveal_walls: true,
movement_mode: 'random',
movement_speed: 4,
},
@ -1813,7 +1938,6 @@ const TILE_TYPES = {
has_inventory: true,
can_reveal_walls: true, // XXX i think?
movement_speed: 4,
movement_mode: 'copy1',
pushes: {
dirt_block: true,
ice_block: true,
@ -1842,7 +1966,6 @@ const TILE_TYPES = {
has_inventory: true,
can_reveal_walls: true, // XXX i think?
movement_speed: 4,
movement_mode: 'copy2',
ignores: new Set(['ice', 'ice_nw', 'ice_ne', 'ice_sw', 'ice_se']),
pushes: {
dirt_block: true,