import { find_terrain_linear } from './algorithms.js'; import { COLLISION, DIRECTIONS, DIRECTION_ORDER, LAYERS, TICS_PER_SECOND, PICKUP_PRIORITIES } from './defs.js'; // TODO factor out some repeated stuff: common monster bits, common item bits, repeated collision // masks function activate_me(me, level) { me.type.activate(me, level); } function blocks_leaving_thin_walls(me, level, actor, direction) { return me.type.thin_walls.has(direction) && actor.type.name !== 'ghost'; } function _define_door(key) { return { layer: LAYERS.terrain, // Doors can be opened by ice blocks, but not dirt blocks or monsters blocks_collision: COLLISION.block_cc1 | COLLISION.monster_typical, blocks(me, level, other) { if (other.type.name === 'ghost') return false; return ! ((other.has_item(key) || other.has_item('skeleton_key'))); }, on_arrive(me, level, other) { if (level.take_key_from_actor(other, key) || level.take_tool_from_actor(other, 'skeleton_key')) { level.sfx.play_once('door', me.cell); level.spawn_animation(me.cell, 'puff'); level.transmute_tile(me, 'floor'); } }, }; } function _define_gate(key) { return { layer: LAYERS.canopy, // Unlike doors, anything with the key (or a ghost) can step on them blocks(me, level, other) { if (other.type.name === 'ghost') return false; return ! ((other.has_item(key) || other.has_item('skeleton_key'))); }, on_arrive(me, level, other) { if (level.take_key_from_actor(other, key) || level.take_tool_from_actor(other, 'skeleton_key')) { level.sfx.play_once('door', me.cell); level.spawn_animation(me.cell, 'puff'); level.remove_tile(me); } }, }; } function _define_force_floor(direction, opposite_type) { return { layer: LAYERS.terrain, slide_mode: 'force', speed_factor: 2, allow_player_override: true, // Used by Lynx to prevent backwards overriding force_floor_direction: direction, on_stand(me, level, other) { level.schedule_actor_slide(other, direction); }, activate(me, level) { level.transmute_tile(me, opposite_type); }, on_gray_button: activate_me, on_power: activate_me, }; } function update_wireable(me, level) { if (me.is_wired === undefined) { //start of the level/first time, then me.is_wired = level.is_tile_wired(me, false); me.is_active = !me.is_wired; } else { let new_is_wired = level.is_tile_wired(me, false); if (new_is_wired && !me.is_wired) { //connected level._set_tile_prop(me, 'is_wired', true); //TODO: it'll always get on_power called later if it's wired to something already given power, right? level._set_tile_prop(me, 'is_active', false); } else if (!new_is_wired && me.is_wired) { //disconnected level._set_tile_prop(me, 'is_wired', false); level._set_tile_prop(me, 'is_active', true); } } } function player_visual_state(me) { if (! me) { return 'normal'; } // FIXME fail reason gets attached to the wrong player if there's a swap at the same time as a // player gets hit if (me.fail_reason === 'drowned') { return 'drowned'; } if (me.fail_reason === 'burned') { return 'burned'; } if (me.fail_reason === 'exploded') { return 'exploded'; } if (me.fail_reason === 'slimed') { return 'slimed'; } if (me.fail_reason === 'electrocuted') { return 'burned'; //same gfx for now } if (me.fail_reason === 'fell') { return 'fell'; } if (me.fail_reason) { return 'failed'; } if (me.exited) { return 'exited'; } // This is slightly complicated. We should show a swimming pose while still in water, or moving // away from water (as CC2 does), but NOT when stepping off a lilypad (which will already have // been turned into water), and NOT without flippers (which can happen if we start on water) if (me.cell && (me.previous_cell || me.cell).has('water') && ! me.not_swimming && me.has_item('flippers')) { return 'swimming'; } if (me.is_sliding || me.is_pending_slide) { let terrain = me.cell.get_terrain(); if (terrain.type.slide_mode === 'ice') { return 'skating'; } else if (terrain.type.slide_mode === 'force') { return 'forced'; } } if (me.is_blocked) { return 'blocked'; } if (me.is_pushing) { return 'pushing'; } if (me.movement_speed) { return 'moving'; } return 'normal'; } function button_visual_state(me) { if (me && me.cell) { let actor = me.cell.get_actor(); if (actor && ! actor.movement_cooldown) { return 'pressed'; } } return 'released'; } // Logic for chasing after the player (or running away); shared by both teeth and mimics function pursue_player(me, level) { // 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 ((level.tic_counter + level.step_parity) % (me.type.movement_parity * 4) >= 4) return null; let player = level.player; let px, py; if (level.compat.teeth_target_internal_position) { // Lynx: pursue the player's current cell px = player.cell.x; py = player.cell.y; } else { // CC2: pursue the player's apparent position, not just the cell they're in [px, py] = player.visual_position(); } let dx = me.cell.x - px; let dy = me.cell.y - py; 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); } } // Ugh. Check for can_reveal_walls, but special-casing it for the pgchip ice blocks, where the // static property is different. This sux!! function can_actually_reveal_walls(tile, level) { if (level.compat.use_pgchip_ice_blocks && tile.type.name == 'ice_block') return false; return tile.type.can_reveal_walls; } // Chunks of properties that are shared among bunches of tiles const COMMON_MONSTER = { layer: LAYERS.actor, is_actor: true, is_monster: true, collision_mask: COLLISION.monster_generic, blocks_collision: COLLISION.all, // Despite the name, this means we only pick up items that are always picked up item_pickup_priority: PICKUP_PRIORITIES.always, movement_speed: 4, }; const COMMON_TOOL = { layer: LAYERS.item, is_item: true, is_tool: true, blocks_collision: COLLISION.block_cc1 | COLLISION.monster_typical, item_priority: PICKUP_PRIORITIES.normal, }; const COMMON_PUSHES = { dirt_block: true, ice_block: true, frame_block: true, circuit_block: true, boulder: true, glass_block: true, sokoban_block: true, }; const TILE_TYPES = { // Floors and walls floor: { layer: LAYERS.terrain, contains_wire: true, wire_propagation_mode: 'autocross', on_approach(me, level, other) { if (other.type.name === 'blob' || other.type.name === 'boulder') { // Blobs spread slime onto floor if (other.previous_cell && other.previous_cell.has('slime')) { level.transmute_tile(me, 'slime'); level.sfx.play_once('splash-slime', me.cell); } } }, }, floor_letter: { layer: LAYERS.terrain, populate_defaults(me) { me.overlaid_glyph = "?"; }, }, // TODO possibly this should be a single tile floor_custom_green: { layer: LAYERS.terrain, blocks_collision: COLLISION.ghost, blocks(me, level, other) { return (other.type.name === 'sokoban_block' && other.color !== 'green'); }, }, floor_custom_pink: { layer: LAYERS.terrain, blocks_collision: COLLISION.ghost, blocks(me, level, other) { return (other.type.name === 'sokoban_block' && other.color !== 'red'); }, }, floor_custom_yellow: { layer: LAYERS.terrain, blocks_collision: COLLISION.ghost, blocks(me, level, other) { return (other.type.name === 'sokoban_block' && other.color !== 'yellow'); }, }, floor_custom_blue: { layer: LAYERS.terrain, blocks_collision: COLLISION.ghost, blocks(me, level, other) { return (other.type.name === 'sokoban_block' && other.color !== 'blue'); }, }, wall: { layer: LAYERS.terrain, blocks_collision: COLLISION.all_but_ghost, on_bumped(me, level, other) { if (other.has_item('foil')) { level.transmute_tile(me, 'steel'); } }, }, wall_custom_green: { layer: LAYERS.terrain, blocks_collision: COLLISION.all, }, wall_custom_pink: { layer: LAYERS.terrain, blocks_collision: COLLISION.all, }, wall_custom_yellow: { layer: LAYERS.terrain, blocks_collision: COLLISION.all, }, wall_custom_blue: { layer: LAYERS.terrain, blocks_collision: COLLISION.all, }, wall_invisible: { layer: LAYERS.terrain, blocks_collision: COLLISION.all_but_ghost, on_bumped(me, level, other) { if (can_actually_reveal_walls(other, level)) { level.spawn_animation(me.cell, 'wall_invisible_revealed'); } }, }, wall_appearing: { layer: LAYERS.terrain, blocks_collision: COLLISION.all_but_ghost, on_bumped(me, level, other) { if (can_actually_reveal_walls(other, level)) { level.transmute_tile(me, 'wall'); } }, }, popwall: { layer: LAYERS.terrain, blocks_collision: COLLISION.block_cc1 | COLLISION.monster_typical, on_ready(me, level) { if (! level.compat.no_auto_convert_ccl_popwalls && level.stored_level.format === 'ccl' && me.cell.get_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) { // CC2 quirk: nothing happens if there's still an actor on us (i.e. dynamite) // FIXME does this imply on_depart isn't called at all if we walk off dynamite? if (me.cell.has('dynamite_lit')) return; level.spawn_animation(me.cell, 'puff'); level.transmute_tile(me, 'wall'); if (other === level.player) { level.sfx.play_once('popwall', me.cell); } }, }, // LL specific tile that can only be stepped on /twice/, originally used to repair differences // with popwall behavior between Lynx and Steam popwall2: { layer: LAYERS.terrain, blocks_collision: COLLISION.block_cc1 | COLLISION.monster_typical, on_depart(me, level, other) { level.spawn_animation(me.cell, 'puff'); level.transmute_tile(me, 'popwall'); }, }, // FIXME in a cc1 tileset, these tiles are opaque >:S thin_walls: { layer: LAYERS.thin_wall, blocks(me, level, actor, direction) { if (actor.type.name === 'ghost') return false; return (me.edges & DIRECTIONS[direction].opposite_bit) !== 0; }, blocks_leaving(me, level, actor, direction) { if (actor.type.name === 'ghost') return false; return (me.edges & DIRECTIONS[direction].bit) !== 0; }, populate_defaults(me) { me.edges = 0; // bitmask of directions }, }, // These only support one-way into the tile, so they're pretty much thin walls that can only // stop something from leaving one_way_walls: { layer: LAYERS.thin_wall, blocks_leaving(me, level, actor, direction) { if (actor.type.name === 'ghost') return false; return (me.edges & DIRECTIONS[direction].bit) !== 0; }, populate_defaults(me) { me.edges = 0; // bitmask of directions }, }, fake_wall: { layer: LAYERS.terrain, blocks_collision: COLLISION.all_but_ghost, on_ready(me, level) { if (! level.compat.no_auto_convert_ccl_blue_walls && level.stored_level.format === 'ccl' && me.cell.get_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 // TODO this also works with invis walls apparently. maybe only for blocks? me.type = TILE_TYPES['popwall']; } }, on_bumped(me, level, other) { if (can_actually_reveal_walls(other, level)) { level.transmute_tile(me, 'wall'); } }, }, fake_floor: { layer: LAYERS.terrain, blocks_collision: COLLISION.block_cc1 | COLLISION.monster_typical, reveal(me, level, other) { level.spawn_animation(me.cell, 'puff'); level.transmute_tile(me, 'floor'); if (other === level.player) { level.sfx.play_once('fake-floor', me.cell); } }, on_bumped(me, level, other) { if (can_actually_reveal_walls(other, level) && ! level.compat.blue_floors_vanish_on_arrive) { this.reveal(me, level, other); } }, on_arrive(me, level, other) { // In Lynx, these disappear only when you step on them if (level.compat.blue_floors_vanish_on_arrive) { this.reveal(me, level, other); } }, }, popdown_wall: { layer: LAYERS.terrain, blocks_collision: COLLISION.all_but_ghost, }, popdown_floor: { layer: LAYERS.terrain, blocks_collision: COLLISION.block_cc1 | COLLISION.block_cc2, visual_state(me) { if (me && me.cell && (me.cell.get_item() || me.cell.get_actor())) { return 'depressed'; } return 'normal'; }, }, no_player1_sign: { layer: LAYERS.terrain, blocks_collision: COLLISION.playerlike1, }, no_player2_sign: { layer: LAYERS.terrain, blocks_collision: COLLISION.playerlike2, }, steel: { layer: LAYERS.terrain, blocks_collision: COLLISION.all, contains_wire: true, wire_propagation_mode: 'autocross', }, canopy: { layer: LAYERS.canopy, blocks_collision: COLLISION.bug | COLLISION.rover, blocks(me, level, other, direction) { // Blobs will specifically not move from one canopy to another if (other.type.name === 'blob' && other.cell.has('canopy')) return true; }, }, // Swivel doors swivel_floor: { layer: LAYERS.terrain, }, swivel_ne: { layer: LAYERS.swivel, thin_walls: new Set(['north', 'east']), on_depart(me, level, other) { if (other.direction === 'north') { level.transmute_tile(me, 'swivel_se'); } else if (other.direction === 'east') { level.transmute_tile(me, 'swivel_nw'); } }, activate(me, level) { level.transmute_tile(me, 'swivel_se'); }, on_gray_button: activate_me, on_power: activate_me, }, swivel_se: { layer: LAYERS.swivel, thin_walls: new Set(['south', 'east']), on_depart(me, level, other) { if (other.direction === 'south') { level.transmute_tile(me, 'swivel_ne'); } else if (other.direction === 'east') { level.transmute_tile(me, 'swivel_sw'); } }, activate(me, level) { level.transmute_tile(me, 'swivel_sw'); }, on_gray_button: activate_me, on_power: activate_me, }, swivel_sw: { layer: LAYERS.swivel, thin_walls: new Set(['south', 'west']), on_depart(me, level, other) { if (other.direction === 'south') { level.transmute_tile(me, 'swivel_nw'); } else if (other.direction === 'west') { level.transmute_tile(me, 'swivel_se'); } }, activate(me, level) { level.transmute_tile(me, 'swivel_nw'); }, on_gray_button: activate_me, on_power: activate_me, }, swivel_nw: { layer: LAYERS.swivel, thin_walls: new Set(['north', 'west']), on_depart(me, level, other) { if (other.direction === 'north') { level.transmute_tile(me, 'swivel_sw'); } else if (other.direction === 'west') { level.transmute_tile(me, 'swivel_ne'); } }, activate(me, level) { level.transmute_tile(me, 'swivel_ne'); }, on_gray_button: activate_me, on_power: activate_me, }, // Railroad railroad: { layer: LAYERS.terrain, track_order: [ ['north', 'east'], ['south', 'east'], ['south', 'west'], ['north', 'west'], ['east', 'west'], ['north', 'south'], ], populate_defaults(me) { me.tracks = 0; // bitmask of bits 0-5, corresponding to track order above // FIXME it's possible to have a switch but no tracks... me.track_switch = null; // null, or 0-5 indicating the active switched track // If there's already an actor on us, it's treated as though it entered the tile moving // in this direction me.entered_direction = 'north'; }, // TODO feel like "ignores" was the wrong idea and there should just be some magic flags for // particular objects that can be immune to. or maybe those objects should have their own // implementations of immunity _is_affected(me, other) { if (other.type.name === 'ghost') return false; if (other.has_item('railroad_sign')) return false; return true; }, *_iter_tracks(me) { let order = me.type.track_order; if (me.track_switch !== null) { // FIXME what happens if the "top" track is not actually a valid track??? yield order[me.track_switch]; } else { for (let [i, track] of order.entries()) { if (me.tracks & (1 << i)) { yield track; } } } }, _switch_track(me, level) { if (me.track_switch !== null) { let current = me.track_switch; for (let i = 0, l = me.type.track_order.length; i < l; i++) { current = (current + 1) % l; if (me.tracks & (1 << current)) break; } level._set_tile_prop(me, 'track_switch', current); } }, has_opening(me, direction) { for (let track of me.type._iter_tracks(me)) { if (track.indexOf(direction) >= 0) { return true; } } return false; }, blocks(me, level, other, direction) { return me.type._is_affected(me, other) && ! me.type.has_opening(me, DIRECTIONS[direction].opposite); }, blocks_leaving(me, level, other, direction) { // FIXME needs the same logic as redirect_exit, so that an illegal entrance can't leave // at all return me.type._is_affected(me, other) && ! me.type.has_opening(me, direction); }, on_arrive(me, level, other) { level._set_tile_prop(me, 'entered_direction', other.direction); }, on_depart(me, level, other) { if (other.type.name === 'ghost') // Ghosts do not switch tracks return; if (! level.is_tile_wired(me, false)) { // Only switch if both the entering and the leaving are CURRENTLY valid directions // (which has some quirky implications for the railroad sign) if (me.track_switch === null) return; let track = this.track_order[me.track_switch]; if (track.indexOf(DIRECTIONS[me.entered_direction].opposite) >= 0 && track.indexOf(other.direction) >= 0) { me.type._switch_track(me, level); } } }, on_power(me, level) { me.type._switch_track(me, level); }, on_gray_button(me, level) { me.type._switch_track(me, level); }, redirect_exit(me, other, direction) { if (! me.type._is_affected(me, other)) return direction; let legal_exits = new Set; let entered_from = DIRECTIONS[me.entered_direction].opposite; if (other.type.can_reverse_on_railroad) { legal_exits.add(entered_from); } for (let track of me.type._iter_tracks(me)) { if (track[0] === entered_from) { legal_exits.add(track[1]); } else if (track[1] === entered_from) { legal_exits.add(track[0]); } } if (legal_exits.has(direction)) { return direction; } if (legal_exits.has(DIRECTIONS[direction].right)) { return DIRECTIONS[direction].right; } if (legal_exits.has(DIRECTIONS[direction].left)) { return DIRECTIONS[direction].left; } if (legal_exits.has(DIRECTIONS[direction].opposite)) { return DIRECTIONS[direction].opposite; } // FIXME i think in this case the actor gets stuck, but, facing which way? return direction; }, }, // Locked doors door_red: _define_door('key_red'), door_blue: _define_door('key_blue'), door_yellow: _define_door('key_yellow'), door_green: _define_door('key_green'), gate_red: _define_gate('key_red'), gate_blue: _define_gate('key_blue'), gate_yellow: _define_gate('key_yellow'), gate_green: _define_gate('key_green'), // Terrain dirt: { layer: LAYERS.terrain, blocks_collision: COLLISION.block_cc1 | COLLISION.monster_typical, blocks(me, level, other) { return ((other.type.name === 'player2' || other.type.name === 'doppelganger2') && ! other.has_item('hiking_boots')); }, on_arrive(me, level, other) { // Bizarre interaction if (other.type.name === 'ghost' && ! other.has_item('hiking_boots')) return; level.transmute_tile(me, 'floor'); if (other === level.player) { level.sfx.play_once('step-gravel', me.cell); } }, }, gravel: { layer: LAYERS.terrain, blocks_collision: COLLISION.monster_typical, blocks(me, level, other) { return ((other.type.name === 'player2' || other.type.name === 'doppelganger2') && ! other.has_item('hiking_boots')); }, }, sand: { layer: LAYERS.terrain, blocks_collision: COLLISION.block_cc1 | COLLISION.block_cc2, speed_factor: 0.5, }, grass: { // TODO should bugs leave if they have no other option...? that seems real hard. // TODO teeth move at full speed? that also seems hard. layer: LAYERS.terrain, blocks_collision: COLLISION.block_cc1 | COLLISION.block_cc2, blocks(me, level, other) { // These guys with big wheels can't do grass return ( other.type.name === 'tank_blue' || other.type.name === 'tank_yellow' || other.type.name === 'rover'); }, blocks_leaving(me, level, other, direction) { // Both kinds of bugs prefer to stay in the grass if (other.type.name === 'bug' || other.type.name === 'paramecium') { let neighbor = level.get_neighboring_cell(me.cell, direction); if (neighbor && neighbor.get_terrain().type.name !== 'grass') return true; } }, on_arrive(me, level, other) { if (other.type.name === 'fireball') { level.transmute_tile(me, 'fire'); level.spawn_animation(me.cell, 'puff'); } }, }, dash_floor: { layer: LAYERS.terrain, speed_factor: 2, }, spikes: { layer: LAYERS.terrain, blocks(me, level, other) { return !(!other.type.is_player || other.has_item('hiking_boots')); }, on_arrive(me, level, other) { if (other.type.name === 'glass_block') { // FIXME need a glass shatter vfx level.kill_actor(other, me, 'explosion'); } }, }, turntable_cw: { layer: LAYERS.terrain, contains_wire: true, wire_propagation_mode: 'all', slide_mode: 'turntable', on_arrive(me, level, other) { level._set_tile_prop(other, 'is_pending_slide', true); level.set_actor_direction(other, DIRECTIONS[other.direction].right); if (other.type.on_rotate) { other.type.on_rotate(other, level, 'right'); } }, on_stand(me, level, other) { level.schedule_actor_slide(other); }, activate(me, level) { level.transmute_tile(me, 'turntable_ccw'); }, on_gray_button: activate_me, on_power: activate_me, }, turntable_ccw: { layer: LAYERS.terrain, contains_wire: true, wire_propagation_mode: 'all', slide_mode: 'turntable', on_arrive(me, level, other) { level._set_tile_prop(other, 'is_pending_slide', true); level.set_actor_direction(other, DIRECTIONS[other.direction].left); if (other.type.on_rotate) { other.type.on_rotate(other, level, 'left'); } }, on_stand(me, level, other) { level.schedule_actor_slide(other); }, activate(me, level) { level.transmute_tile(me, 'turntable_cw'); }, on_gray_button: activate_me, on_power: activate_me, }, // Hazards fire: { layer: LAYERS.terrain, // Fire blocks most monsters, except in MS where they walk right in and get roasted blocks(me, level, other) { if (other.type.collision_mask & (COLLISION.fireball | COLLISION.yellow_tank | COLLISION.ghost)) return false; if (other.type.collision_mask & COLLISION.monster_any) { if (level.compat.fire_allows_most_monsters && other.type.name !== 'bug' && other.type.name !== 'walker') return false; return true; } return false; }, on_arrive(me, level, other) { if (other.type.name === 'ghost') { // Ghosts with fire boots erase fire, otherwise are unaffected if (other.has_item('fire_boots')) { level.transmute_tile(me, 'floor'); } } else if (other.has_item('fire_boots')) { return; } else if (other.type.name === 'ice_block') { level.transmute_tile(other, 'splash'); level.transmute_tile(me, 'water'); level.sfx.play_once('splash', me.cell); } else { level.kill_actor(other, me, 'explosion', 'bomb', 'burned'); } }, }, water: { layer: LAYERS.terrain, blocks(me, level, other) { // Water blocks ghosts... unless they have flippers if (other.type.name === 'ghost' && ! other.has_item('flippers')) return true; }, on_arrive(me, level, other) { // TODO cc1 allows items under water, i think; water was on the upper layer level.sfx.play_once('splash', me.cell); if (other.type.name === 'dirt_block') { level.transmute_tile(other, 'splash'); level.transmute_tile(me, 'dirt'); } else if (other.type.name === 'frame_block') { level.transmute_tile(other, 'splash'); level.transmute_tile(me, 'floor'); } else if (other.type.name === 'glass_block') { level.transmute_tile(other, 'splash'); level.transmute_tile(me, 'floor'); } else if (other.type.name === 'ice_block') { level.transmute_tile(other, 'splash'); level.transmute_tile(me, 'ice'); } else if (other.type.name === 'boulder') { level.transmute_tile(other, 'splash'); level.transmute_tile(me, 'gravel'); } else if (other.type.name === 'circuit_block') { level.transmute_tile(me, 'floor'); level._set_tile_prop(me, 'wire_directions', other.wire_directions); level.transmute_tile(other, 'splash'); level.recalculate_circuitry_next_wire_phase = true; } else if (other.type.name === 'sokoban_block') { level.transmute_tile(me, ({ red: 'floor_custom_pink', blue: 'floor_custom_blue', yellow: 'floor_custom_yellow', green: 'floor_custom_green', })[other.color]); level.transmute_tile(other, 'splash'); } else { level.kill_actor(other, me, 'splash', null, 'drowned'); } }, }, turtle: { layer: LAYERS.terrain, blocks_collision: COLLISION.ghost | COLLISION.fireball, on_depart(me, level, other) { // CC2 quirk: nothing happens if there's still an actor on us (i.e. dynamite) if (me.cell.has('dynamite_lit')) return; level.transmute_tile(me, 'water'); level.spawn_animation(me.cell, 'splash'); level.sfx.play_once('splash', me.cell); // Visual property, so the actor knows it's stepping off a lilypad, not swimming out of // the water we just turned into level._set_tile_prop(other, 'not_swimming', true); }, }, cracked_ice: { layer: LAYERS.terrain, slide_mode: 'ice', speed_factor: 2, on_arrive(me, level, other) { level.schedule_actor_slide(other); }, on_depart(me, level, other) { level.transmute_tile(me, 'water'); level.spawn_animation(me.cell, 'splash'); level.sfx.play_once('splash', me.cell); }, }, ice: { layer: LAYERS.terrain, slide_mode: 'ice', speed_factor: 2, on_arrive(me, level, other) { level.schedule_actor_slide(other); }, }, ice_sw: { layer: LAYERS.terrain, thin_walls: new Set(['south', 'west']), slide_mode: 'ice', speed_factor: 2, blocks_leaving: blocks_leaving_thin_walls, on_arrive(me, level, other) { let direction = { north: 'north', south: 'east', east: 'east', west: 'north', }[other.direction]; level.schedule_actor_slide(other, direction); }, }, ice_nw: { layer: LAYERS.terrain, thin_walls: new Set(['north', 'west']), slide_mode: 'ice', speed_factor: 2, blocks_leaving: blocks_leaving_thin_walls, on_arrive(me, level, other) { let direction = { north: 'east', south: 'south', east: 'east', west: 'south', }[other.direction]; level.schedule_actor_slide(other, direction); }, }, ice_ne: { layer: LAYERS.terrain, thin_walls: new Set(['north', 'east']), slide_mode: 'ice', speed_factor: 2, blocks_leaving: blocks_leaving_thin_walls, on_arrive(me, level, other) { let direction = { north: 'west', south: 'south', east: 'south', west: 'west', }[other.direction]; level.schedule_actor_slide(other, direction); }, }, ice_se: { layer: LAYERS.terrain, thin_walls: new Set(['south', 'east']), slide_mode: 'ice', speed_factor: 2, blocks_leaving: blocks_leaving_thin_walls, on_arrive(me, level, other) { let direction = { north: 'north', south: 'west', east: 'north', west: 'west', }[other.direction]; level.schedule_actor_slide(other, direction); }, }, force_floor_n: _define_force_floor('north', 'force_floor_s'), force_floor_s: _define_force_floor('south', 'force_floor_n'), force_floor_e: _define_force_floor('east', 'force_floor_w'), force_floor_w: _define_force_floor('west', 'force_floor_e'), force_floor_all: { layer: LAYERS.terrain, slide_mode: 'force', speed_factor: 2, allow_player_override: true, blocks(me, level, other) { return (level.compat.rff_blocks_monsters && (other.type.collision_mask & COLLISION.monster_typical)); }, on_stand(me, level, other) { if (! other.is_pending_slide && ! level.compat.force_floors_on_arrive) { level.schedule_actor_slide(other, level.get_force_floor_direction()); } }, }, slime: { layer: LAYERS.terrain, on_arrive(me, level, other) { if (other.type.name === 'ghost' || other.type.name === 'blob' || other.type.name === 'boulder') { // No effect return; } level.sfx.play_once('splash-slime', me.cell); if (other.type.name === 'dirt_block' || other.type.name === 'ice_block') { level.transmute_tile(me, 'floor'); } else { level.kill_actor(other, me, 'splash_slime', null, 'slimed'); } }, }, bomb: { layer: LAYERS.item, on_arrive(me, level, other) { if (level.compat.bombs_detonate_on_arrive) { me.type._detonate(me, level, other); } }, on_stand(me, level, other) { // Lynx: Bombs detonate on arrival, not on idle // Steam: Bombs detonate when stood on, even if a player starts the level on one. This // is useless in CC2 design and breaks some CC1 levels, so it's off by default if (! level.compat.bombs_detonate_on_arrive && ! (level.compat.bombs_immediately_detonate_under_players && level.tic_counter === 0 && other.type.is_real_player)) { me.type._detonate(me, level, other); } }, _detonate(me, level, other) { level.remove_tile(me); level.kill_actor(other, me, 'explosion', 'bomb', 'exploded'); }, }, hole: { layer: LAYERS.terrain, on_ready(me, level) { let one_north = level.cell(me.cell.x, me.cell.y - 1); if (one_north === null || one_north.get_terrain().type.name !== 'hole') { level._set_tile_prop(me, 'visual_state', 'north'); } else { level._set_tile_prop(me, 'visual_state', 'open'); } }, on_arrive(me, level, other) { level.kill_actor(other, me, 'fall', null, 'fell'); }, visual_state(me) { // Default to the version of the tile that actually shows something; otherwise this // looks like an error or gap in the editor return (me && me.visual_state) ?? 'north'; }, }, cracked_floor: { layer: LAYERS.terrain, on_depart(me, level, other) { level.spawn_animation(me.cell, 'puff'); if (other === level.player) { level.sfx.play_once('popwall', me.cell); } level.transmute_tile(me, 'hole'); // Update hole visual state (note that me.type is hole now) me.type.on_ready(me, level); let one_south = level.cell(me.cell.x, me.cell.y + 1); if (one_south && one_south.get_terrain().type.name === 'hole') { me.type.on_ready(one_south.get_terrain(), level); } }, }, thief_tools: { layer: LAYERS.terrain, blocks_collision: COLLISION.block_cc1 | COLLISION.monster_typical, on_arrive(me, level, other) { if (level.take_tool_from_actor(other, 'bribe')) { if (other === level.player) { level.sfx.play_once('thief-bribe', me.cell); } return; } if (! other.type.is_real_player) return; let lost = level.take_all_tools_from_actor(other); if (level.bonus_points > 0) { lost = true; } level.adjust_bonus(0, 0.5); if (lost && other === level.player) { level.sfx.play_once('thief', me.cell); } }, }, thief_keys: { layer: LAYERS.terrain, blocks_collision: COLLISION.block_cc1 | COLLISION.monster_typical, on_arrive(me, level, other) { if (level.take_tool_from_actor(other, 'bribe')) { if (other === level.player) { level.sfx.play_once('thief-bribe', me.cell); } return; } if (! other.type.is_real_player) return; let lost = level.take_all_keys_from_actor(other); if (level.bonus_points > 0) { lost = true; } level.adjust_bonus(0, 0.5); if (lost && other === level.player) { level.sfx.play_once('thief', me.cell); } }, }, // Item modifiers no_sign: { layer: LAYERS.item_mod, item_modifier: 'ignore', blocks(me, level, other) { let item = me.cell.get_item(); return item && other.has_item(item.type.name); }, }, gift_bow: { layer: LAYERS.item_mod, item_modifier: 'pickup', }, // Mechanisms dirt_block: { layer: LAYERS.actor, collision_mask: COLLISION.block_cc1, blocks_collision: COLLISION.all, item_pickup_priority: PICKUP_PRIORITIES.always, is_actor: true, is_block: true, ignores: new Set(['fire', 'flame_jet_on', 'electrified_floor']), can_reverse_on_railroad: true, movement_speed: 4, }, ice_block: { layer: LAYERS.actor, collision_mask: COLLISION.block_cc2, blocked_by(me, level, other) { // pgchip's ice blocks followed dirt block collision rules, except for being able to go // on dirt if (level.compat.use_pgchip_ice_blocks && other.type.name !== 'dirt' && (other.type.blocks_collision & COLLISION.block_cc1)) return true; }, blocks_collision: COLLISION.all, item_pickup_priority: PICKUP_PRIORITIES.never, is_actor: true, is_block: true, can_reveal_walls: true, can_reverse_on_railroad: true, movement_speed: 4, pushes: { // Ice blocks specifically cannot push dirt blocks ...COMMON_PUSHES, dirt_block: false, }, on_after_bumped(me, level, other) { // Fireballs melt ice blocks on regular floor FIXME and water! // XXX what if i'm in motion? if (other.type.name === 'fireball' && ! level.compat.use_pgchip_ice_blocks) { let terrain = me.cell.get_terrain(); if (terrain.type.name === 'floor') { level.transmute_tile(me, 'splash'); level.transmute_tile(terrain, 'water'); level.sfx.play_once('splash', me.cell); } } }, }, frame_block: { layer: LAYERS.actor, collision_mask: COLLISION.block_cc2, blocks_collision: COLLISION.all, item_pickup_priority: PICKUP_PRIORITIES.never, is_actor: true, is_block: true, can_reveal_walls: true, can_reverse_on_railroad: true, movement_speed: 4, allows_push(me, direction) { return me.arrows && me.arrows.has(direction); }, pushes: COMMON_PUSHES, on_clone(me, original) { me.arrows = new Set(original.arrows); }, on_rotate(me, level, turn) { // We rotate when turned on railroads let new_arrows = new Set; for (let arrow of me.arrows) { new_arrows.add(DIRECTIONS[arrow][turn]); } level._set_tile_prop(me, 'arrows', new_arrows); }, }, boulder: { layer: LAYERS.actor, collision_mask: COLLISION.block_cc2, blocks_collision: COLLISION.all, // XXX? item_pickup_priority: PICKUP_PRIORITIES.never, is_actor: true, is_block: true, can_reveal_walls: true, // Boulders don't directly push each other; instead on_bumped will propagate the momentum // XXX can they not push other kinds of blocks? pushes: { ice_block: true, frame_block: true, }, ignores: new Set(['fire', 'flame_jet_on', 'electrified_floor']), can_reverse_on_railroad: true, movement_speed: 4, decide_movement(me, level) { if (me.rolling) { level._set_tile_prop(me, 'rolling', false); return [me.direction, null]; } else { return null; } }, on_bumped(me, level, other, direction) { if (other.type.name === 'boulder') { level._set_tile_prop(me, 'rolling', true); level._set_tile_prop(me, 'direction', direction); level._set_tile_prop(other, 'rolling', false); } }, on_starting_move(me, level) { if (!me.rolling) { level._set_tile_prop(me, 'rolling', true); } }, }, glass_block: { layer: LAYERS.actor, collision_mask: COLLISION.block_cc2, blocks_collision: COLLISION.all, item_pickup_priority: PICKUP_PRIORITIES.never, is_actor: true, is_block: true, can_reveal_walls: true, can_reverse_on_railroad: true, movement_speed: 4, try_pickup_item(me, level) { // Suck up any item that could be picked up off of the floor, and put it in our // encased_item slot (which is, somewhat confusingly, distinct from our inventory -- we // cannot actually make use of our encased item) if (me.encased_item === null) { let item = me.cell.get_item(); let mod = me.cell.get_item_mod(); if (mod && mod.type.item_modifier === 'ignore') { return; } //hmm, actually chips seem to work OK. Alright, why not then? if (item /*&& !item.type.is_chip*/ && item.type.item_priority !== undefined) { level._set_tile_prop(me, 'encased_item', item.type.name); level.remove_tile(item); } } }, on_ready(me, level) { level._set_tile_prop(me, 'encased_item', null); this.try_pickup_item(me, level); }, on_clone(me, original) { me.encased_item = original.encased_item; }, on_finishing_move(me, level) { this.try_pickup_item(me, level); }, blocked_by(me, level, other) { return other.cell.get_item() !== null && me.encased_item !== null; }, on_death(me, level) { //needs to be called by transmute_tile to ttl and by dynamite_lit before remove_tile if (me.encased_item !== null) { level._place_dropped_item(me.encased_item, me.cell ?? me.previous_cell, me); level._set_tile_prop(me, 'encased_item', null); } } }, green_floor: { layer: LAYERS.terrain, green_toggle_counterpart: 'green_wall', blocks(me, level, other) { // Toggle walls don't toggle until the end of the frame, but the collision takes into // account whether a toggle is coming return ( level.pending_green_toggle && (other.type.collision_mask & COLLISION.all_but_ghost)); }, on_gray_button(me, level) { level.transmute_tile(me, 'green_wall'); }, on_power(me, level) { me.type.on_gray_button(me, level); }, }, green_wall: { layer: LAYERS.terrain, green_toggle_counterpart: 'green_floor', blocks(me, level, other) { // Same as above return ( ! level.pending_green_toggle && (other.type.collision_mask & COLLISION.all_but_ghost)); }, on_gray_button(me, level) { level.transmute_tile(me, 'green_floor'); }, on_power(me, level) { me.type.on_gray_button(me, level); }, }, green_chip: { layer: LAYERS.item, is_chip: true, is_required_chip: true, green_toggle_counterpart: 'green_bomb', blocks_collision: COLLISION.block_cc1 | COLLISION.monster_typical, item_priority: PICKUP_PRIORITIES.real_player, on_pickup(me, level, other) { level.collect_chip(me); return true; }, // Not affected by gray buttons }, green_bomb: { layer: LAYERS.item, is_required_chip: true, green_toggle_counterpart: 'green_chip', on_arrive(me, level, other) { // Unlike regular bombs, these only seem to respond to being stepped on, not stood on level.remove_tile(me); level.kill_actor(other, me, 'explosion', 'bomb', 'exploded'); }, // Not affected by gray buttons }, purple_floor: { layer: LAYERS.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: { layer: LAYERS.terrain, blocks_collision: COLLISION.all_but_ghost, 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); }, }, // Sokoban blocks, buttons, and walls -- they each come in four colors, the buttons can be // pressed by anything EXCEPT a sokoban block of the WRONG color, and the walls become floors // only when ALL the buttons of the corresponding color are pressed sokoban_block: { layer: LAYERS.actor, collision_mask: COLLISION.block_cc1, blocks_collision: COLLISION.all, item_pickup_priority: PICKUP_PRIORITIES.always, is_actor: true, is_block: true, can_reverse_on_railroad: true, movement_speed: 4, populate_defaults(me) { me.color = 'red'; }, on_clone(me, original) { me.color = original.color; }, visual_state(me) { let color = me.color ?? 'red'; if (me && me.cell && ! me.movement_cooldown) { let terrain = me.cell.get_terrain(); if (terrain.type.name === 'sokoban_button' && terrain.pressed) { return color + '_matched'; } } return color; }, }, sokoban_button: { layer: LAYERS.terrain, populate_defaults(me) { me.color = 'red'; me.pressed = false; }, on_ready(me, level) { let actor = me.cell.get_actor(); if (actor && ! (actor.type.name === 'sokoban_block' && actor.color !== me.color)) { // Already held down, make sure the level knows me.pressed = true; level.sokoban_buttons_unpressed[me.color] -= 1; if (level.sokoban_buttons_unpressed[me.color] === 0) { for (let cell of level.linear_cells) { let terrain = cell.get_terrain(); if (terrain.type.name === 'sokoban_wall' && terrain.color === me.color) { terrain.type = TILE_TYPES['sokoban_floor']; } } } } }, on_arrive(me, level, other) { if (other.type.name === 'sokoban_block' && me.color !== other.color) return; level._set_tile_prop(me, 'pressed', true); level.sfx.play_once('button-press', me.cell); level.sokoban_buttons_unpressed[me.color] -= 1; level._push_pending_undo(() => { level.sokoban_buttons_unpressed[me.color] += 1; }); if (level.sokoban_buttons_unpressed[me.color] === 0) { for (let cell of level.linear_cells) { let terrain = cell.get_terrain(); if (terrain.type.name === 'sokoban_wall' && terrain.color === me.color) { level.transmute_tile(terrain, 'sokoban_floor'); } } } }, on_depart(me, level, other) { level._set_tile_prop(me, 'pressed', false); if (other.type.name === 'sokoban_block' && me.color !== other.color) return; level.sfx.play_once('button-release', me.cell); level.sokoban_buttons_unpressed[me.color] += 1; level._push_pending_undo(() => { level.sokoban_buttons_unpressed[me.color] -= 1; }); if (level.sokoban_buttons_unpressed[me.color] === 1) { for (let cell of level.linear_cells) { let terrain = cell.get_terrain(); if (terrain.type.name === 'sokoban_floor' && terrain.color === me.color) { level.transmute_tile(terrain, 'sokoban_wall'); } } } }, visual_state(me) { return (me.color ?? 'red') + '_' + (me.pressed ? 'pressed' : 'released'); }, }, sokoban_wall: { layer: LAYERS.terrain, blocks_collision: COLLISION.all_but_ghost, populate_defaults(me) { me.color = 'red'; }, visual_state(me) { return me.color ?? 'red'; }, }, sokoban_floor: { layer: LAYERS.terrain, populate_defaults(me) { me.color = 'red'; }, visual_state(me) { return me.color ?? 'red'; }, }, // ------------------------------------------------------------------------------------------------ // Floor mechanisms cloner: { layer: LAYERS.terrain, blocks_collision: COLLISION.real_player | COLLISION.block_cc1 | COLLISION.monster_typical, populate_defaults(me) { me.arrows = 0; // bitmask of glowing arrows (visual, no gameplay impact) }, on_ready(me, level) { me.arrows = me.arrows ?? 0; }, traps(me, level, other) { return ! other._clone_release; }, activate(me, level, aggressive = false) { let actor = me.cell.get_actor(); if (! actor) return; // Copy this stuff in case the movement changes it // TODO should anything else be preserved? let type = actor.type; let direction = actor.direction; // Unstick and try to move the actor; if it's blocked, abort the clone. // This temporary flag tells us to let it leave; it doesn't need to be undoable, since // it doesn't persist for more than a tic actor._clone_release = true; let cloned = false; // Wire activation allows the cloner to try every direction, searching clockwise for (let i = 0; i < (aggressive ? 4 : 1); i++) { // If the actor successfully moves, replace it with a new clone. As a special case, // bowling balls that immediately destroy something are also considered to have // successfully exited let success = level.attempt_out_of_turn_step(actor, direction); if (! success && actor.type.ttl && ! level.compat.cloned_bowling_balls_can_be_lost) { success = true; if (actor.type.layer === LAYERS.actor) { level.transmute_tile(actor, 'explosion_nb', true); } } if (success) { // FIXME add this underneath, just above the cloner, so the new actor is on top let new_template = new actor.constructor(type, direction); if (type.on_clone) { type.on_clone(new_template, actor); } level.add_tile(new_template, me.cell); level.add_actor(new_template); cloned = true; break; } direction = DIRECTIONS[direction].right; } if (aggressive && ! cloned) { // Restore original facing level.set_actor_direction(actor, direction); } delete actor._clone_release; }, // Also clones on rising pulse or gray button on_power(me, level) { me.type.activate(me, level, true); }, on_gray_button(me, level) { me.type.activate(me, level); }, }, trap: { layer: LAYERS.terrain, on_ready(me, level) { // This may run before or after any pressed buttons, but, that's fine if (me.presses === undefined) { level._set_tile_prop(me, 'presses', 0); } }, on_stand(me, level, other, just_arrived) { // Lynx: open traps eject their contents at the end of each tic if (level.compat.traps_like_lynx && ! just_arrived) { level.attempt_out_of_turn_step(other, other.direction); } }, add_press_ready(me, level, other) { // Same as below, but without ejection level._set_tile_prop(me, 'presses', (me.presses ?? 0) + 1); }, add_press(me, level, is_wire = false) { level._set_tile_prop(me, 'presses', me.presses + 1); if (me.presses === 1 && ! is_wire) { // Free any actor on us, if we went from 0 to 1 presses (i.e. closed to open) let actor = me.cell.get_actor(); if (actor) { // Forcibly move anything released from a trap, which keeps it in sync with // whatever pushed the button level.attempt_out_of_turn_step(actor, actor.direction); } } }, remove_press(me, level) { level._set_tile_prop(me, 'presses', Math.max(0, me.presses - 1)); if (me._initially_open) { level._set_tile_prop(me, '_initially_open', false); } }, // FIXME also doesn't trap ghosts, is that a special case??? traps(me, level, other) { if (level.compat.traps_like_lynx) { // Lynx traps don't actually track open vs closed; actors are just ejected by force // by a separate pass at the end of the tic that checks what's on a brown button. // That means a trap held open by a button at level start won't effectively be open // if whatever's on the button moves within the first tic, a quirk that CCLXP2 #17 // Double Trouble critically relies on! // To fix this, assume that a trap can never be released on the first turn. // FIXME that's not right since a block or immobile mob might be on a button... if (level.tic_counter === 0) return true; } return ! me.presses && ! me._initially_open && other.type.name !== 'ghost'; }, on_power(me, level) { // Treat being powered or not as an extra kind of brown button press me.type.add_press(me, level, true); }, on_depower(me, level) { me.type.remove_press(me, level); }, visual_state(me) { if (me && (me.presses || me._initially_open)) { return 'open'; } else { return 'closed'; } }, }, transmogrifier: { layer: LAYERS.terrain, // C2M technically supports wires in transmogrifiers, but they don't do anything wire_propagation_mode: 'none', _mogrifications: { player: 'player2', player2: 'player', doppelganger1: 'doppelganger2', doppelganger2: 'doppelganger1', dirt_block: 'ice_block', ice_block: 'dirt_block', ball: 'walker', walker: 'ball', fireball: 'bug', bug: 'glider', glider: 'paramecium', paramecium: 'fireball', tank_blue: 'tank_yellow', tank_yellow: 'tank_blue', teeth: 'teeth_timid', teeth_timid: 'teeth', // Items are only mogrified when inside a glass block key_red: 'key_blue', key_blue: 'key_red', key_yellow: 'key_green', key_green: 'key_yellow', flippers: 'fire_boots', fire_boots: 'flippers', cleats: 'suction_boots', suction_boots: 'cleats', hiking_boots: 'speed_boots', speed_boots: 'hiking_boots', lightning_bolt: 'railroad_sign', railroad_sign: 'lightning_bolt', helmet: 'xray_eye', xray_eye: 'helmet', hook: 'foil', foil: 'hook', bowling_ball: 'dynamite', dynamite: 'bowling_ball', bribe: 'skeleton_key', skeleton_key: 'bribe', stopwatch_bonus: 'stopwatch_penalty', stopwatch_penalty: 'stopwatch_bonus', green_chip: 'green_bomb', green_bomb: 'green_chip', chip: 'bomb', bomb: 'chip', // TODO // boulder: 'log', // log: 'boulder', // ankh: 'phantom_ring', // phantom_ring: 'ankh', }, _blob_mogrifications: ['glider', 'paramecium', 'fireball', 'bug', 'walker', 'ball', 'teeth', 'tank_blue', 'teeth_timid'], on_begin(me, level) { update_wireable(me, level); }, on_arrive(me, level, other) { // Note: Transmogrifiers technically contain wires the way teleports do, and CC2 uses // the presence and poweredness of those wires to determine whether the transmogrifier // should appear to be on or off, but the /functionality/ is controlled entirely by // whether an adjoining cell carries current to our edge, like a railroad or cloner if (! me.is_active) return; let name = other.type.name; if (me.type._mogrifications[name]) { level.transmute_tile(other, me.type._mogrifications[name]); } else if (name === 'blob') { let options = me.type._blob_mogrifications; level.transmute_tile(other, options[level.prng() % options.length]); } else if (name === 'glass_block' && other.encased_item) { let new_item = me.type._mogrifications[other.encased_item]; if (new_item) { level._set_tile_prop(other, 'encased_item', new_item); } else { return; } } else if (name === 'sokoban_block') { level._set_tile_prop(other, 'color', ({ red: 'blue', blue: 'red', yellow: 'green', green: 'yellow', })[other.color]); } else { return; } level.spawn_animation(me.cell, 'transmogrify_flash'); level.sfx.play_once('transmogrify', me.cell); }, on_power(me, level) { if (me.is_wired) { level._set_tile_prop(me, 'is_active', true); } }, on_depower(me, level) { if (me.is_wired) { level._set_tile_prop(me, 'is_active', false); } }, visual_state(me) { return me && me.is_active === false ? 'inactive' : 'active'; }, }, teleport_blue: { layer: LAYERS.terrain, slide_mode: 'teleport', contains_wire: true, wire_propagation_mode: 'all', *teleport_dest_order(me, level, other) { let exit_direction = other.direction; // Note that unlike other tiles that care about whether they're wired, a blue teleporter // considers itself part of a network if it contains any wires at all, regardless of // whether they connect to anything if (! me.wire_directions) { // TODO cc2 has a bug where, once it wraps around to the bottom right, it seems to // forget that it was ever looking for an unwired teleport and will just grab the // first one it sees for (let [dest, cell] of find_terrain_linear( level, me.cell, new Set(['teleport_blue', 'teleport_blue_exit']), true)) { if (! dest.wire_directions) { yield [dest, exit_direction]; } } return; } // Wired blue teleports form an isolated network, so we have to walk the circuit we're // on, collect a list of all possible blue teleports, and then sort them so we can try // them in the right order. // Complicating this somewhat, logic gates act as diodes: we can walk through a logic // gate if we're connected to one of its inputs AND its output is enabled, but we can't // walk "backwards" through it. // (In CC2, this is even worse; if the game searches the circuit and ONLY finds a logic // gate, it seems to recurse from there, breaking the expected order. Worse, if it then // can't find a destination teleporter, it teleports the actor "INTO" the logic gate // itself; if a destination later presents itself, the actor will immediately appear, // but if not, it might linger THROUGH A RESTART OR EVEN EDIT OF THE LEVEL, possibly // appearing on a later playthrough or possibly crashing the game. Suffice to say, this // behavior is not and will never be emulated. No level in CC2 or even CC2LP1 uses blue // teleporters wired into logic gates, so even the ordering is not interesting imo.) // Anyway, let's do a breadth-first search for teleporters. let walked_circuits = new Set; let candidate_teleporters = new Set; let circuits = me.circuits; for (let i = 0; i < circuits.length; i++) { let circuit = circuits[i]; if (! circuit || walked_circuits.has(circuit)) continue; walked_circuits.add(circuit); for (let [tile, edges] of circuit.tiles.entries()) { if (tile.type === me.type || tile.type.name === 'teleport_blue_exit') { candidate_teleporters.add(tile); } else if (tile.type.name === 'logic_gate' && ! circuit.inputs.get(tile)) { // This logic gate is functioning as an output, so walk through it and also // trace any circuits that treat it as an input (as long as those circuits // are currently powered) for (let subcircuit of tile.circuits) { if (subcircuit && subcircuit.is_powered && subcircuit.inputs.get(tile)) { circuits.push(subcircuit); } } } } } // Now that we have a set of candidate destinations, sort it in reverse reading order, // starting from ourselves. Easiest way to do this is to make a map of cell indices, // shifted so that we're at zero, then sort in reverse let dest_indices = new Map; let our_index = me.cell.x + me.cell.y * level.size_x; let level_size = level.size_x * level.size_y; for (let dest of candidate_teleporters) { dest_indices.set(dest, ( (dest.cell.x + dest.cell.y * level.size_x) - our_index + level_size ) % level_size); } let found = Array.from(candidate_teleporters); found.sort((a, b) => dest_indices.get(b) - dest_indices.get(a)); for (let dest of found) { yield [dest, exit_direction]; } }, }, teleport_blue_exit: { layer: LAYERS.terrain, contains_wire: true, wire_propagation_mode: 'all', }, teleport_red: { layer: LAYERS.terrain, slide_mode: 'teleport', contains_wire: true, wire_propagation_mode: 'none', allow_player_override: true, on_begin(me, level) { // FIXME must be connected to something that can convey current: a wire, a switch, a // blue teleporter, etc; NOT nothing, a wall, a transmogrifier, a force floor, etc. // this is also how blue teleporters, transmogrifiers, and railroads work! update_wireable(me, level); }, *teleport_dest_order(me, level, other) { // Wired red teleporters can be turned off, which disconnects them from every other red // teleporter (but they still teleport to themselves). // A red teleporter is considered wired only if it has wires itself. However, CC2 also // has the bizarre behavior of NOT considering a red teleporter wired if none of its // wires are directly connected to another neighboring wire. let iterable; if (me.is_active) { iterable = find_terrain_linear(level, me.cell, new Set(['teleport_red'])); } else { iterable = [[me, me.cell]]; } let exit_direction = other.direction; for (let [tile, cell] of iterable) { // Red teleporters allow exiting in any direction, searching clockwise, except for // the teleporter you entered if (tile === me) { yield [tile, exit_direction]; } else if (tile.is_active) { yield [tile, exit_direction]; yield [tile, DIRECTIONS[exit_direction].right]; yield [tile, DIRECTIONS[exit_direction].opposite]; yield [tile, DIRECTIONS[exit_direction].left]; } } }, on_power(me, level) { if (me.is_wired) { level._set_tile_prop(me, 'is_active', true); } }, on_depower(me, level) { if (me.is_wired) { level._set_tile_prop(me, 'is_active', false); } }, visual_state(me) { return me && me.is_active === false ? 'inactive' : 'active'; }, }, teleport_green: { layer: LAYERS.terrain, slide_mode: 'teleport', *teleport_dest_order(me, level, other) { // The CC2 green teleporter scheme is: // 1. Use the PRNG to pick another green teleporter // 2. Use the PRNG to pick an exit direction // 3. Search the selected exit teleporter for a viable exit direction // 4. If that doesn't work, continue searching green teleporters in reading order // 5. When we reach the entry teleporter, stop and give up // This means that completely blocked green teleporters are skipped, BUT if the only // available teleporters are between the entry and chosen exit, they'll never be tried. // Also, due to what appears to be a bug, CC2 picks an index from among all other // teleporters, but then only iterates over "unclogged" ones (those without actors on // them) to find that particular teleporter. Since the list of unclogged ones includes // the source, this means some rolls will fail to teleport entirely, even if the next // teleporter in RRO is open. // This is clearly buggy as hell, so it's squirrelled away behind a compat option. // This iterator starts on the /next/ teleporter, so we appear last, and we can index // from zero to the second-to-last element. let all = Array.from(find_terrain_linear(level, me.cell, new Set(['teleport_green']))); if (all.length <= 1) { // If this is the only teleporter, just walk out the other side — and, crucially, do // NOT advance the PRNG yield [me, other.direction]; return; } let start_index = level.prng() % (all.length - 1); // Also pick the initial exit direction let exit_direction = DIRECTION_ORDER[level.prng() % 4]; let candidates; all = all.map(([tile, cell]) => tile); if (level.compat.green_teleports_can_fail) { // CC2 bug emulation: only look through "unclogged" exits candidates = all.filter(tile => tile === me || ! tile.cell.get_actor()); start_index %= candidates.length; } else { candidates = all; } for (let i = 0; i < candidates.length; i++) { let index = start_index + i; if (index === candidates.length - 1) // This is us, skip for now continue; if (level.compat.green_teleports_can_fail) { // CC2: Only check from the selected teleporter to the entrance if (index >= candidates.length) break; } else { // Lexy: Try them all, only stopping once we loop back to our first choice index %= candidates.length; } let target = candidates[index]; // Green teleporters allow exiting in any direction, similar to red yield [target, exit_direction]; yield [target, DIRECTIONS[exit_direction].right]; yield [target, DIRECTIONS[exit_direction].opposite]; yield [target, DIRECTIONS[exit_direction].left]; } // We've circled back around to our entry teleporter; give up yield [me, other.direction]; return; }, }, teleport_yellow: { layer: LAYERS.terrain, item_priority: PICKUP_PRIORITIES.always, slide_mode: 'teleport', allow_player_override: true, *teleport_dest_order(me, level, other) { let exit_direction = other.direction; for (let [dest, cell] of find_terrain_linear(level, me.cell, new Set(['teleport_yellow']), true)) { yield [dest, exit_direction]; } }, }, // Flame jet rules: // - State toggles /while/ an orange button is held or wire current is received // - Multiple such inputs cancel each other out // - Gray button toggles it permanently flame_jet_off: { layer: LAYERS.terrain, activate(me, level) { level.transmute_tile(me, 'flame_jet_on'); // Do NOT immediately nuke anything on us, or it'd be impossible to push a block off an // adjacent orange button; this is probably why flame jets kill on tics }, on_gray_button(me, level) { me.type.activate(me, level); }, on_power(me, level) { me.type.activate(me, level); }, }, flame_jet_on: { layer: LAYERS.terrain, 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); }, on_stand(me, level, other) { // Note that (dirt?) blocks, fireballs, and anything with fire boots are immune // TODO would be neat if this understood "ignores anything with fire immunity" but that // might be a bit too high-level for this game level.kill_actor(other, me, 'explosion', 'bomb', 'burned'); }, }, electrified_floor: { layer: LAYERS.terrain, wire_propagation_mode: 'all', on_begin(me, level) { level._set_tile_prop(me, 'is_active', false); level._set_tile_prop(me, 'wire_directions', 15); level.recalculate_circuitry_next_wire_phase = true; }, on_stand(me, level, other) { if (! me.is_active) return; level.kill_actor(other, me, 'explosion', 'bomb', 'electrocuted'); }, on_power(me, level) { level._set_tile_prop(me, 'is_active', true); }, on_depower(me, level) { level._set_tile_prop(me, 'is_active', false); }, on_death(me, level) { //needs to be called by transmute_tile to ttl and by dynamite_lit before remove_tile //need to remove our wires since they're an implementation detail level._set_tile_prop(me, 'wire_directions', 0); level.recalculate_circuitry_next_wire_phase = true; }, visual_state(me) { return me && me.is_active ? 'active' : 'inactive'; }, }, // ------------------------------------------------------------------------------------------------ // Buttons button_blue: { layer: LAYERS.terrain, do_button(level) { // Flip direction of all blue tanks for (let actor of level.actors) { // TODO generify somehow?? if (actor.type.name !== 'tank_blue') continue; if (! level.compat.tanks_always_obey_button && (actor.slide_mode || actor.cell.has('cloner'))) { continue; } level._set_tile_prop(actor, 'pending_reverse', ! actor.pending_reverse); } }, on_arrive(me, level, other) { level.sfx.play_once('button-press', me.cell); me.type.do_button(level); }, on_depart(me, level, other) { level.sfx.play_once('button-release', me.cell); }, visual_state: button_visual_state, }, button_yellow: { layer: LAYERS.terrain, on_arrive(me, level, other) { level.sfx.play_once('button-press', me.cell); for (let actor of level.actors) { if (actor.type.name === 'tank_yellow') { level._set_tile_prop(actor, 'pending_decision', other.direction); } } }, on_depart(me, level, other) { level.sfx.play_once('button-release', me.cell); }, }, button_green: { layer: LAYERS.terrain, do_button(level) { level.pending_green_toggle = ! level.pending_green_toggle; }, on_arrive(me, level, other) { level.sfx.play_once('button-press', me.cell); this.do_button(level); }, on_depart(me, level, other) { level.sfx.play_once('button-release', me.cell); }, visual_state: button_visual_state, }, button_brown: { layer: LAYERS.terrain, connects_to: new Set(['trap']), connect_order: 'forward', 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.cell.get_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.name === 'trap') { trap.type.add_press(trap, level); } }, on_depart(me, level, other) { level.sfx.play_once('button-release', me.cell); let trap = me.connection; if (trap && trap.cell && trap.type.name === 'trap') { trap.type.remove_press(trap, level); } }, visual_state: button_visual_state, }, button_red: { layer: LAYERS.terrain, connects_to: new Set(['cloner']), connect_order: 'forward', on_arrive(me, level, other) { level.sfx.play_once('button-press', me.cell); let cloner = me.connection; if (cloner && cloner.cell && cloner.type.name === 'cloner') { cloner.type.activate(cloner, level); } }, on_depart(me, level, other) { level.sfx.play_once('button-release', me.cell); }, visual_state: button_visual_state, }, button_orange: { layer: LAYERS.terrain, connects_to: new Set(['flame_jet_off', 'flame_jet_on']), connect_order: 'diamond', // Both stepping on and leaving the button have the same effect: toggle the state of the // connected flame jet _toggle_flame_jet(me, level, other) { let jet = me.connection; if (jet && jet.cell && ( jet.type.name === 'flame_jet_off' || jet.type.name === 'flame_jet_on')) { jet.type.activate(jet, level); } }, on_arrive(me, level, other) { level.sfx.play_once('button-press', me.cell); me.type._toggle_flame_jet(me, level, other); }, on_depart(me, level, other) { level.sfx.play_once('button-release', me.cell); me.type._toggle_flame_jet(me, level, other); }, visual_state: button_visual_state, }, button_pink: { layer: LAYERS.terrain, contains_wire: true, is_power_source: true, wire_propagation_mode: 'none', get_emitting_edges(me, level) { // We emit current as long as there's an actor fully on us let actor = me.cell.get_actor(); if (actor && actor.movement_cooldown === 0) { return me.wire_directions; } else { return 0; } }, 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); }, visual_state: button_visual_state, }, button_black: { layer: LAYERS.terrain, contains_wire: true, is_power_source: true, wire_propagation_mode: 'cross', get_emitting_edges(me, level) { // TODO weird and inconsistent with pink buttons, but cc2 has a single-frame delay here! // We emit current as long as there's NOT an actor fully on us let actor = me.cell.get_actor(); let held = (actor && actor.movement_cooldown === 0); if (me.is_first_frame) { held = ! held; level._set_tile_prop(me, 'is_first_frame', false); } if (held) { return 0; } else { return me.wire_directions; } }, on_arrive(me, level, other) { level._set_tile_prop(me, 'is_first_frame', true); level.sfx.play_once('button-press', me.cell); }, on_depart(me, level, other) { level._set_tile_prop(me, 'is_first_frame', true); level.sfx.play_once('button-release', me.cell); }, visual_state: button_visual_state, }, button_gray: { layer: LAYERS.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.cell(x, y); // TODO wait is this right if (cell === me.cell) continue; for (let tile of cell) { if (tile && 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); }, visual_state: button_visual_state, }, // Logic gates, all consolidated into a single tile type logic_gate: { // gate_type: not, and, or, xor, nand, latch-cw, latch-ccw, counter, bogus _gate_types: { not: ['out0', null, 'in0', null], diode: ['out0', null, 'in0', null], and: ['out0', 'in0', null, 'in1'], or: ['out0', 'in0', null, 'in1'], xor: ['out0', 'in0', null, 'in1'], nand: ['out0', 'in0', null, 'in1'], // in0 is the trigger, in1 is the input 'latch-cw': ['out0', 'in0', null, 'in1'], // in0 is the input, in1 is the trigger 'latch-ccw': ['out0', 'in0', null, 'in1'], // inputs: inc, dec; outputs: overflow, underflow counter: ['out1', 'in0', 'in1', 'out0'], }, layer: LAYERS.terrain, is_power_source: true, on_ready(me, level) { me.gate_def = me.type._gate_types[me.gate_type]; if (me.gate_type === 'latch-cw' || me.gate_type === 'latch-ccw') { me.memory = false; } else if (me.gate_type === 'counter') { me.memory = me.memory ?? 0; me.incrementing = false; me.decrementing = false; me.underflowing = false; me.direction = 'north'; } }, // Returns [in0, in1, out0, out1] as directions get_wires(me) { let gate_def = me.type._gate_types[me.gate_type]; let dir = me.direction; let ret = [null, null, null, null]; for (let i = 0; i < 4; i++) { let cxn = gate_def[i]; let dirinfo = DIRECTIONS[dir]; if (cxn === 'in0') { ret[0] = dir; } else if (cxn === 'in1') { ret[1] = dir; } else if (cxn === 'out0') { ret[2] = dir; } else if (cxn === 'out1') { ret[3] = dir; } dir = dirinfo.right; } return ret; }, get_emitting_edges(me, level) { // Collect which of our edges are powered, in clockwise order starting from our // direction, matching _gate_types let input0 = false, input1 = false; let output0 = false, output1 = false; let outbit0 = 0, outbit1 = 0; let dir = me.direction; for (let i = 0; i < 4; i++) { let cxn = me.gate_def[i]; let dirinfo = DIRECTIONS[dir]; if (cxn === 'in0') { input0 = (me.powered_edges & dirinfo.bit) !== 0; } else if (cxn === 'in1') { input1 = (me.powered_edges & dirinfo.bit) !== 0; } else if (cxn === 'out0') { outbit0 = dirinfo.bit; } else if (cxn === 'out1') { outbit1 = dirinfo.bit; } dir = dirinfo.right; } if (me.gate_type === 'not') { output0 = ! input0; } else if (me.gate_type === 'diode') { output0 = input0; } else if (me.gate_type === 'and') { output0 = input0 && input1; } else if (me.gate_type === 'or') { output0 = input0 || input1; } else if (me.gate_type === 'xor') { output0 = input0 !== input1; } else if (me.gate_type === 'nand') { output0 = ! (input0 && input1); } else if (me.gate_type === 'latch-cw') { if (input0) { level._set_tile_prop(me, 'memory', input1); } output0 = me.memory; } else if (me.gate_type === 'latch-ccw') { if (input1) { level._set_tile_prop(me, 'memory', input0); } output0 = me.memory; } else if (me.gate_type === 'counter') { let inc = input0 && ! me.incrementing; let dec = input1 && ! me.decrementing; let mem = me.memory; if (inc || dec) { level._set_tile_prop(me, 'underflowing', false); } if (inc && ! dec) { mem++; if (mem > 9) { mem = 0; output0 = true; } } else if (dec && ! inc) { mem--; if (mem < 0) { mem = 9; // Underflow is persistent until the next pulse level._set_tile_prop(me, 'underflowing', true); } } output1 = me.underflowing; level._set_tile_prop(me, 'memory', mem); level._set_tile_prop(me, 'incrementing', input0); level._set_tile_prop(me, 'decrementing', input1); } return (output0 ? outbit0 : 0) | (output1 ? outbit1 : 0); }, visual_state(me) { return me.gate_type; }, }, // Light switches, kinda like the pink/black buttons but persistent light_switch_off: { layer: LAYERS.terrain, contains_wire: true, is_power_source: true, wire_propagation_mode: 'none', get_emitting_edges(me, level) { // TODO weird and inconsistent with pink buttons, but cc2 has a single-frame delay here! if (me.is_first_frame) { level._set_tile_prop(me, 'is_first_frame', false); return me.wire_directions; } return 0; }, on_arrive(me, level, other) { // TODO distinct sfx? more clicky? level.sfx.play_once('button-press', me.cell); level.transmute_tile(me, 'light_switch_on'); level._set_tile_prop(me, 'is_first_frame', true); }, }, light_switch_on: { layer: LAYERS.terrain, contains_wire: true, is_power_source: true, wire_propagation_mode: 'none', get_emitting_edges(me, level) { // TODO weird and inconsistent with pink buttons, but cc2 has a single-frame delay here! if (me.is_first_frame) { level._set_tile_prop(me, 'is_first_frame', false); return 0; } return me.wire_directions; }, on_arrive(me, level, other) { level.sfx.play_once('button-press', me.cell); level.transmute_tile(me, 'light_switch_off'); level._set_tile_prop(me, 'is_first_frame', true); }, }, // LL tile: circuit block, overrides the wiring on the floor below (if any) circuit_block: { layer: LAYERS.actor, collision_mask: COLLISION.block_cc2, blocks_collision: COLLISION.all, item_pickup_priority: PICKUP_PRIORITIES.never, is_actor: true, is_block: true, contains_wire: true, wire_propagation_mode: 'autocross', can_reverse_on_railroad: true, movement_speed: 4, on_clone(me, original) { me.wire_directions = original.wire_directions; }, on_starting_move(me, level) { level._set_tile_prop(me, 'powered_edges', 0); level.recalculate_circuitry_next_wire_phase = true; }, on_finishing_move(me, level) { level.recalculate_circuitry_next_wire_phase = true; }, }, // Time alteration stopwatch_bonus: { layer: LAYERS.item, blocks_collision: COLLISION.block_cc1 | COLLISION.monster_typical, item_priority: PICKUP_PRIORITIES.real_player, on_pickup(me, level, other) { level.sfx.play_once('get-stopwatch-bonus', me.cell); level.adjust_timer(+10); return true; }, }, stopwatch_penalty: { layer: LAYERS.item, blocks_collision: COLLISION.block_cc1 | COLLISION.monster_typical, item_priority: PICKUP_PRIORITIES.real_player, on_pickup(me, level, other) { level.sfx.play_once('get-stopwatch-penalty', me.cell); level.adjust_timer(-10); return true; }, }, stopwatch_toggle: { layer: LAYERS.item, blocks_collision: COLLISION.block_cc1 | COLLISION.monster_typical, item_priority: PICKUP_PRIORITIES.player, on_pickup(me, level, other) { level.sfx.play_once('get-stopwatch-toggle', me.cell); level.pause_timer(); return false; }, }, // ------------------------------------------------------------------------------------------------ // Critters bug: { ...COMMON_MONSTER, collision_mask: COLLISION.bug, 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: { ...COMMON_MONSTER, 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: { ...COMMON_MONSTER, 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: { ...COMMON_MONSTER, 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: { ...COMMON_MONSTER, 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: { ...COMMON_MONSTER, collision_mask: COLLISION.yellow_tank, pushes: COMMON_PUSHES, decide_movement(me, level) { if (me.pending_decision) { let decision = me.pending_decision; level._set_tile_prop(me, 'pending_decision', null); // Yellow tanks don't keep trying to move if blocked, but they DO turn regardless // XXX consider a compat flag; this is highly unintuitive to me level.set_actor_direction(me, decision); return [decision, null]; } else { return null; } }, }, blob: { ...COMMON_MONSTER, movement_speed: 8, skip_decision_time_collision_check: true, decide_movement(me, level) { // move completely at random let d; if (level.compat.blobs_use_tw_prng) { d = level.tw_prng_random4(); } else { let modifier = level.get_blob_modifier(); d = (level.prng() + modifier) % 4; } return [DIRECTION_ORDER[d]]; }, }, teeth: { ...COMMON_MONSTER, movement_parity: 2, decide_movement(me, level) { let preference = pursue_player(me, level); if (preference && 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: { ...COMMON_MONSTER, movement_parity: 2, decide_movement(me, level) { let preference = pursue_player(me, level); if (preference && level.player.type.name === 'player') { // Run away from Lexy for (let [i, direction] of preference.entries()) { preference[i] = DIRECTIONS[direction].opposite; } } return preference; }, }, fireball: { ...COMMON_MONSTER, collision_mask: COLLISION.fireball, 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: { ...COMMON_MONSTER, ignores: new Set(['water', 'turtle']), // doesn't cause turtles to disappear 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: { ...COMMON_MONSTER, collision_mask: COLLISION.ghost, item_pickup_priority: PICKUP_PRIORITIES.normal, ignores: new Set([ 'bomb', 'green_bomb', 'water', 'ice', 'ice_nw', 'ice_ne', 'ice_sw', 'ice_se', 'cracked_ice', 'force_floor_n', 'force_floor_s', 'force_floor_e', 'force_floor_w', 'force_floor_all', // Ghosts don't activate swivels or popwalls 'popwall', 'swivel_nw', 'swivel_ne', 'swivel_se', 'swivel_sw', 'hole', 'cracked_floor', ]), // TODO ignores /most/ walls. collision is basically completely different. has a regular inventory, except red key. good grief decide_movement(me, level) { // turn left: preserve current direction; if that doesn't work, turn left, then right, // then back the way we came (same as glider) // TODO weird cc2 quirk: ghosts can't turn on ice, and FIXME they stop if they have cleats if (me.cell.get_terrain().type.slide_mode === 'ice') { return [me.direction]; } let d = DIRECTIONS[me.direction]; return [me.direction, d.left, d.right, d.opposite]; }, }, floor_mimic: { ...COMMON_MONSTER, movement_parity: 4, decide_movement: pursue_player, }, rover: { ...COMMON_MONSTER, collision_mask: COLLISION.rover, item_pickup_priority: PICKUP_PRIORITIES.normal, can_reveal_walls: true, movement_speed: 8, movement_parity: 2, pushes: COMMON_PUSHES, on_ready(me, level) { me.current_emulatee = 0; me.attempted_moves = 0; }, on_clone(me, original) { me.current_emulatee = 0; me.attempted_moves = 0; }, _emulatees: ['teeth', 'glider', 'bug', 'ball', 'teeth_timid', 'fireball', 'paramecium', 'walker'], decide_movement(me, level) { level._set_tile_prop(me, 'attempted_moves', me.attempted_moves + 1); if (me.attempted_moves >= 32) { level._set_tile_prop(me, 'attempted_moves', 0); level._set_tile_prop(me, 'current_emulatee', (me.current_emulatee + 1) % me.type._emulatees.length); } let emulatee = me.type._emulatees[me.current_emulatee]; return TILE_TYPES[emulatee].decide_movement(me, level); }, visual_state(me) { if (me && me.current_emulatee !== undefined) { return me.type._emulatees[me.current_emulatee]; } else { return 'inert'; } }, }, // Keys, whose behavior varies key_red: { // Red key is only picked up by players and doppelgangers layer: LAYERS.item, item_priority: PICKUP_PRIORITIES.player, is_item: true, is_key: true, }, key_blue: { // Blue key is picked up by all actors except CC2 blocks layer: LAYERS.item, item_priority: PICKUP_PRIORITIES.always, is_item: true, is_key: true, }, key_yellow: { layer: LAYERS.item, is_item: true, is_key: true, // FIXME ok this is ghastly blocks_collision: COLLISION.block_cc1 | COLLISION.monster_typical, item_priority: PICKUP_PRIORITIES.normal, }, key_green: { layer: LAYERS.item, is_item: true, is_key: true, blocks_collision: COLLISION.block_cc1 | COLLISION.monster_typical, item_priority: PICKUP_PRIORITIES.normal, }, // Boots // TODO note: ms allows blocks to pass over tools cleats: { ...COMMON_TOOL, item_ignores: new Set(['ice', 'ice_nw', 'ice_ne', 'ice_sw', 'ice_se']), // XXX this is literally the only definition of slide_ignores item_slide_ignores: new Set(['cracked_ice']), }, suction_boots: { ...COMMON_TOOL, item_ignores: new Set([ 'force_floor_n', 'force_floor_s', 'force_floor_e', 'force_floor_w', 'force_floor_all', ]), }, fire_boots: { ...COMMON_TOOL, // Note that these do NOT ignore fire because of the ghost interaction // XXX starting to wonder if this is even useful really item_ignores: new Set(['flame_jet_on']), }, flippers: { ...COMMON_TOOL, item_ignores: new Set(['water']), }, hiking_boots: { ...COMMON_TOOL, item_ignores: new Set(['sand']), // FIXME uhh these "ignore" that dirt and gravel block us, but they don't ignore the on_arrive, so, uhhhh }, // Other tools dynamite: { ...COMMON_TOOL, on_depart(me, level, other) { if (other.type.is_real_player && ! me.cell.get_item_mod()) { // FIXME wiki just says about 4.3 seconds; more likely this is exactly 255 frames level._set_tile_prop(me, 'timer', 85); level.transmute_tile(me, 'dynamite_lit'); // Actors are expected to have this, so populate it level._set_tile_prop(me, 'movement_cooldown', 0); level.add_actor(me); // Dynamite inherits a copy of the player's inventory, which largely doesn't matter // except for suction boots, helmet, or lightning bolt; keys can't matter because // dynamite is blocked by doors if (other.toolbelt) { level._set_tile_prop(me, 'toolbelt', [...other.toolbelt]); } // Dynamite that lands on a force floor is moved by it, and dynamite that lands on a // button holds it down // TODO is there anything this should NOT activate? level.step_on_cell(me, me.cell); } }, }, dynamite_lit: { layer: LAYERS.actor, is_actor: true, is_monster: true, collision_mask: COLLISION.block_cc1, blocks_collision: COLLISION.all, item_pickup_priority: PICKUP_PRIORITIES.always, movement_speed: 4, // FIXME especially for buttons, destroyed actors should on_depart (behind compat flag) decide_movement(me, level) { level._set_tile_prop(me, 'timer', me.timer - 1); if (me.timer > 0) return null; // Kaboom! Blow up a 5x5 square level.sfx.play_once('bomb', me.cell); let x = me.cell.x, y = me.cell.y; for (let dx = -2; dx <= 2; dx++) { for (let dy = -2; dy <= 2; dy++) { // Exclude the far corners if (Math.abs(dx) + Math.abs(dy) >= 4) continue; let cell = level.cell(x + dx, y + dy); if (! cell) continue; let actor = cell.get_actor(); let terrain = cell.get_terrain(); let removed_anything; for (let layer = LAYERS.MAX - 1; layer >= 0; layer--) { let tile = cell[layer]; if (! tile) continue; // Terrain is transmuted afterwards; VFX are left alone; actors are killed // after the loop (which also allows the glass block to safely drop an item) if (tile.type.layer === LAYERS.terrain || tile.type.layer === LAYERS.actor || tile.type.layer === LAYERS.vfx) { continue; } // Anything else is destroyed level.remove_tile(tile); removed_anything = true; // Canopy protects everything else if (tile.type.name === 'canopy') { actor = null; terrain = null; break; } } if (actor) { // Actors protect terrain, but floor becomes fire if (terrain && terrain.type.name === 'floor') { if (actor.type.name === 'ice_block') { level.transmute_tile(terrain, 'water'); } else { level.transmute_tile(terrain, 'fire'); } } } else if (terrain) { // Anything other than these babies gets blown up and turned into floor if (terrain.type.name === 'steel' || terrain.type.name === 'socket' || terrain.type.name === 'logic_gate' || terrain.type.name === 'floor' || terrain.type.name === 'hole' || terrain.type.name === 'floor_ankh') { // do nothing } else if (terrain.type.name === 'cracked_floor') { level.transmute_tile(terrain, 'hole'); removed_anything = true; } else { // Super duper weird special case: both CC2 and LL precompute the wiring // layout, and as a fun side effect, a destroyed blue teleporter becomes // a floor tile that can genuinely conduct in all four directions. // (The red teleporter, similarly, inhibits current.) // This doesn't require any real logic changes, but for readability, // inform the renderer. if (terrain.type.wire_propagation_mode) { level._set_tile_prop(terrain, 'wire_propagation_mode', terrain.type.wire_propagation_mode); } level.transmute_tile(terrain, 'floor'); removed_anything = true; } } if (actor) { level.kill_actor(actor, me, 'explosion'); } else if (removed_anything && ! cell.get_actor()) { level.spawn_animation(cell, 'explosion'); } else { // Extension: Show the entire blast radius every time level.spawn_animation(cell, 'explosion_nb'); } } } return null; }, visual_state(me) { // 0 1 2 3 4 return Math.min(4, Math.max(0, Math.floor((me.timer ?? 0) / TICS_PER_SECOND))); }, }, bowling_ball: { ...COMMON_TOOL, on_drop(level) { return 'rolling_ball'; }, }, rolling_ball: { layer: LAYERS.actor, is_actor: true, is_monster: true, can_reveal_walls: true, collision_mask: COLLISION.bowling_ball, blocks_collision: COLLISION.bowling_ball, item_pickup_priority: PICKUP_PRIORITIES.normal, // FIXME do i start moving immediately when dropped, or next turn? movement_speed: 4, decide_movement(me, level) { return [me.direction]; }, on_approach(me, level, other) { // Blow up anything that runs into us level.kill_actor(other, me, 'explosion'); level.kill_actor(me, me, 'explosion', 'bomb'); }, on_blocked(me, level, direction, obstacle) { // Blow up anything we run into if (obstacle && obstacle.type.is_actor) { level.kill_actor(obstacle, me, 'explosion'); } else if (me.is_sliding || me._clone_release) { // Sliding bowling balls don't blow up if they hit a regular wall, and neither do // bowling balls in the process of being released from a cloner return; } level.sfx.play_once('bomb', me.cell); level.transmute_tile(me, 'explosion'); }, }, xray_eye: { ...COMMON_TOOL, }, helmet: { ...COMMON_TOOL, }, railroad_sign: { ...COMMON_TOOL, }, foil: { ...COMMON_TOOL, }, lightning_bolt: { ...COMMON_TOOL, item_ignores: new Set(['electrified_floor']), }, speed_boots: { ...COMMON_TOOL, }, bribe: { ...COMMON_TOOL, }, hook: { ...COMMON_TOOL, }, skeleton_key: { ...COMMON_TOOL, }, ankh: { ...COMMON_TOOL, on_depart(me, level, other) { let terrain = me.cell.get_terrain(); if (other.type.is_real_player && terrain && terrain.type.name === 'floor' && terrain.wire_directions === 0 && terrain.wire_tunnel_directions === 0) { if (level.ankh_tile) { level.transmute_tile(level.ankh_tile, 'floor'); level.spawn_animation(level.ankh_tile.cell, 'puff'); } let old_tile = level.ankh_tile; level.ankh_tile = terrain; level._push_pending_undo(() => { level.ankh_tile = old_tile; }); level.transmute_tile(terrain, 'floor_ankh'); // TODO some kinda vfx + sfx level.remove_tile(me); } }, }, floor_ankh: { layer: LAYERS.terrain, blocks_collision: COLLISION.all_but_real_player, }, // Progression player: { layer: LAYERS.actor, is_actor: true, is_player: true, is_real_player: true, collision_mask: COLLISION.real_player1, blocks_collision: COLLISION.all, item_pickup_priority: PICKUP_PRIORITIES.real_player, can_reveal_walls: true, movement_speed: 4, pushes: COMMON_PUSHES, infinite_items: { key_green: true, }, visual_state: player_visual_state, }, player2: { layer: LAYERS.actor, is_actor: true, is_player: true, is_real_player: true, collision_mask: COLLISION.real_player2, blocks_collision: COLLISION.all, item_pickup_priority: PICKUP_PRIORITIES.real_player, can_reveal_walls: true, movement_speed: 4, ignores: new Set(['ice', 'ice_nw', 'ice_ne', 'ice_sw', 'ice_se', 'cracked_ice', 'cracked_floor']), pushes: COMMON_PUSHES, infinite_items: { key_yellow: true, }, visual_state: player_visual_state, }, doppelganger1: { layer: LAYERS.actor, is_actor: true, is_player: true, is_monster: true, collision_mask: COLLISION.doppel1, blocks_collision: COLLISION.all, skip_decision_time_collision_check: true, item_pickup_priority: PICKUP_PRIORITIES.player, can_reveal_walls: true, // XXX i think? movement_speed: 4, pushes: COMMON_PUSHES, infinite_items: { key_green: true, }, decide_movement(me, level) { return level.player1_move ? [level.player1_move] : null; }, //visual_state: doppelganger_visual_state, }, doppelganger2: { layer: LAYERS.actor, is_actor: true, is_player: true, is_monster: true, collision_mask: COLLISION.doppel2, blocks_collision: COLLISION.all, skip_decision_time_collision_check: true, item_pickup_priority: PICKUP_PRIORITIES.player, can_reveal_walls: true, // XXX i think? movement_speed: 4, ignores: new Set(['ice', 'ice_nw', 'ice_ne', 'ice_sw', 'ice_se', 'cracked_ice', 'cracked_floor']), pushes: COMMON_PUSHES, infinite_items: { key_yellow: true, }, decide_movement(me, level) { return level.player2_move ? [level.player2_move] : null; }, //visual_state: doppelganger_visual_state, }, chip: { layer: LAYERS.item, is_chip: true, is_required_chip: true, blocks_collision: COLLISION.block_cc1 | COLLISION.monster_typical, item_priority: PICKUP_PRIORITIES.real_player, on_pickup(me, level, other) { level.collect_chip(me); return true; }, }, chip_extra: { layer: LAYERS.item, is_chip: true, blocks_collision: COLLISION.block_cc1 | COLLISION.monster_typical, item_priority: PICKUP_PRIORITIES.real_player, on_pickup(me, level, other) { level.collect_chip(me); return true; }, }, // Score bonuses; they're picked up as normal EXCEPT by ghosts, but only a real player can // actually add to the player's bonus score_10: { layer: LAYERS.item, blocks_collision: COLLISION.block_cc1 | COLLISION.monster_typical, item_priority: PICKUP_PRIORITIES.normal, on_pickup(me, level, other) { if (other.type.name === 'ghost') return false; if (other.type.is_real_player) { level.adjust_bonus(10); level.sfx.play_once('get-bonus', me.cell); } return true; }, }, score_100: { layer: LAYERS.item, blocks_collision: COLLISION.block_cc1 | COLLISION.monster_typical, item_priority: PICKUP_PRIORITIES.normal, on_pickup(me, level, other) { if (other.type.name === 'ghost') return false; if (other.type.is_real_player) { level.adjust_bonus(100); level.sfx.play_once('get-bonus', me.cell); } return true; }, }, score_1000: { layer: LAYERS.item, blocks_collision: COLLISION.block_cc1 | COLLISION.monster_typical, item_priority: PICKUP_PRIORITIES.normal, on_pickup(me, level, other) { if (other.type.name === 'ghost') return false; if (other.type.is_real_player) { level.adjust_bonus(1000); level.sfx.play_once('get-bonus', me.cell); } return true; }, }, score_2x: { layer: LAYERS.item, blocks_collision: COLLISION.block_cc1 | COLLISION.monster_typical, item_priority: PICKUP_PRIORITIES.normal, on_pickup(me, level, other) { if (other.type.name === 'ghost') return false; if (other.type.is_real_player) { level.adjust_bonus(0, 2); level.sfx.play_once('get-bonus2', me.cell); } return true; }, }, score_5x: { layer: LAYERS.item, blocks_collision: COLLISION.block_cc1 | COLLISION.monster_typical, item_priority: PICKUP_PRIORITIES.normal, on_pickup(me, level, other) { if (other.type.name === 'ghost') return false; if (other.type.is_real_player) { level.adjust_bonus(0, 5); level.sfx.play_once('get-bonus2', me.cell); } return true; }, }, hint: { layer: LAYERS.terrain, is_hint: true, blocks_collision: COLLISION.block_cc1 | COLLISION.monster_typical, populate_defaults(me) { me.hint_text = null; // optional, may use level's hint instead }, }, socket: { layer: LAYERS.terrain, blocks_collision: COLLISION.block_cc1 | COLLISION.monster_typical, blocks(me, level, other) { return ! (other.type.name === 'ghost' || level.chips_remaining <= 0); }, on_arrive(me, level, other) { if (level.chips_remaining === 0) { level.sfx.play_once('socket', me.cell); level.spawn_animation(me.cell, 'puff'); level.transmute_tile(me, 'floor'); } }, }, exit: { layer: LAYERS.terrain, blocks_collision: COLLISION.block_cc1 | COLLISION.monster_typical, on_arrive(me, level, other) { if (other.type.is_real_player) { level.remaining_players -= 1; if (level.remaining_players > 0) { if (other === level.player) { level.swap_player1 = true; } level.sfx.play_once('exit', me.cell); level.transmute_tile(other, other.type.name === 'player' ? 'player1_exit' : 'player2_exit'); } } }, }, // VFX splash: { layer: LAYERS.actor, is_actor: true, collision_mask: 0, blocks_collision: COLLISION.real_player, ttl: 16, // If anything else even begins to step on an animation, it's erased // FIXME possibly erased too fast; cc2 shows it briefly? could i get away with on_arrive here? on_approach(me, level, other) { level.remove_tile(me); }, }, explosion: { layer: LAYERS.actor, is_actor: true, collision_mask: 0, blocks_collision: COLLISION.real_player, ttl: 16, on_approach(me, level, other) { level.remove_tile(me); }, }, // Non-blocking explosion used for better handling edge cases with dynamite and bowling balls, // without changing gameplay explosion_nb: { layer: LAYERS.vfx, is_actor: true, collision_mask: 0, ttl: 16, }, // Used as an easy way to show an invisible wall when bumped wall_invisible_revealed: { layer: LAYERS.vfx, is_actor: true, collision_mask: 0, blocks_collision: 0, // determined experimentally ttl: 36, }, // Custom VFX (identical function, but different aesthetic) splash_slime: { layer: LAYERS.actor, is_actor: true, collision_mask: 0, blocks_collision: COLLISION.real_player, ttl: 16, on_approach(me, level, other) { level.remove_tile(me); }, }, // New VFX (not in CC2, so they don't block to avoid altering gameplay) // Note that these need to NOT have a duration that's an even number of tics, or the last frame // will be skipped when playing at 20fps; the cooldown will be 3, then decremented to 0, and the // tile will immediately be removed! player1_exit: { layer: LAYERS.vfx, is_actor: true, collision_mask: 0, ttl: 8 * 3 + 1, }, player2_exit: { layer: LAYERS.vfx, is_actor: true, collision_mask: 0, ttl: 8 * 3 + 1, }, teleport_flash: { layer: LAYERS.vfx, is_actor: true, collision_mask: 0, ttl: 8 * 3 + 1, }, transmogrify_flash: { layer: LAYERS.vfx, is_actor: true, collision_mask: 0, ttl: 4 * 3 + 1, }, puff: { layer: LAYERS.vfx, is_actor: true, collision_mask: 0, ttl: 4 * 3 + 1, }, fall: { layer: LAYERS.vfx, is_actor: true, collision_mask: 0, ttl: 4 * 3 + 1, }, resurrection: { layer: LAYERS.vfx, is_actor: true, collision_mask: 0, ttl: 4 * 3 + 1, }, // Invalid tiles that appear in some CCL levels because community level // designers love to make nonsense bogus_player_win: { layer: LAYERS.terrain, blocks_collision: COLLISION.all, }, bogus_player_swimming: { layer: LAYERS.terrain, blocks_collision: COLLISION.all, }, bogus_player_drowned: { layer: LAYERS.terrain, blocks_collision: COLLISION.all, }, bogus_player_burned_fire: { layer: LAYERS.terrain, blocks_collision: COLLISION.all, }, bogus_player_burned: { layer: LAYERS.terrain, blocks_collision: COLLISION.all, }, }; // Tell them all their own names for (let [name, type] of Object.entries(TILE_TYPES)) { type.name = name; if (type.layer === undefined || type.layer !== Math.floor(type.layer) || type.layer >= LAYERS.MAX) { console.error(`Tile type ${name} has a bad layer`); } if (type.is_actor) { if (type.collision_mask === undefined) console.error(`Tile type ${name} is an actor but has no collision mask`); if (type.ttl === undefined && type.item_pickup_priority === undefined) console.error(`Tile type ${name} is an actor but has no item pickup priority`); } if (type.is_item) { if (type.item_priority === undefined) console.error(`Tile type ${name} is an item but has no item priority`); } } export default TILE_TYPES;