diff --git a/index.html b/index.html index 6cb393b..d66045e 100644 --- a/index.html +++ b/index.html @@ -123,7 +123,7 @@
Play time: A tics, B moves, C seconds
+ + @@ -152,6 +169,7 @@
Speed: 6× (0.5 frame), 3× (1 frame), 2× (???), 1.5× (2 frames), 1× (3 frames), ½× (6 frames), ⅓× (9 frames), ¼× (12 frames)
Viewport: 9/10, 12, 16, 24, 32, map size
+Show actor info
Show actor bounding boxes
diff --git a/js/game.js b/js/game.js index eee78a8..6ebc822 100644 --- a/js/game.js +++ b/js/game.js @@ -451,7 +451,7 @@ export class Level { // For turn-based mode, this is split into two parts: advance_tic_finish_movement completes any // ongoing movement started in the previous tic, and advance_tic_act allows actors to make new // decisions. The player makes decisions between these two parts. - advance_tic(p1_primary_direction, p1_secondary_direction, pass) { + advance_tic(p1_actions, pass) { if (this.state !== 'playing') { console.warn(`Level.advance_tic() called when state is ${this.state}`); return; @@ -460,10 +460,10 @@ export class Level { // TODO rip out this try/catch, it's not how the game actually works try { if (pass == 1) { - this.advance_tic_finish_movement(p1_primary_direction, p1_secondary_direction); + this.advance_tic_finish_movement(p1_actions); } else if (pass == 2) { - this.advance_tic_act(p1_primary_direction, p1_secondary_direction); + this.advance_tic_act(p1_actions); } else { console.warn(`What pass is this?`); @@ -484,7 +484,7 @@ export class Level { } } - advance_tic_finish_movement(p1_primary_direction, p1_secondary_direction) { + advance_tic_finish_movement(p1_actions) { // Store some current level state in the undo entry. (These will often not be modified, but // they only take a few bytes each so that's fine.) for (let key of [ @@ -498,7 +498,12 @@ export class Level { // Player's secondary direction is set immediately; it applies on arrival to cells even if // it wasn't held the last time the player started moving - this._set_tile_prop(this.player, 'secondary_direction', p1_secondary_direction); + if (p1_actions.secondary === this.player.direction) { + this._set_tile_prop(this.player, 'secondary_direction', p1_actions.primary); + } + else { + this._set_tile_prop(this.player, 'secondary_direction', p1_actions.secondary); + } // Used to check for a monster chomping the player's tail this.player_leaving_cell = this.player.cell; @@ -570,7 +575,7 @@ export class Level { } } - advance_tic_act(p1_primary_direction, p1_secondary_direction) { + advance_tic_act(p1_actions) { // Second pass: actors decide their upcoming movement simultaneously for (let i = this.actors.length - 1; i >= 0; i--) { let actor = this.actors[i]; @@ -607,43 +612,36 @@ export class Level { actor.decision = actor.direction; continue; } - else if (actor.slide_mode === 'force') { - // Only the player can make voluntary moves on a force floor, - // and only if their previous move was an /involuntary/ move on - // a force floor. If they do, it overrides the forced move - // XXX this in particular has some subtleties in lynx (e.g. you - // can override forwards??) and DEFINITELY all kinds of stuff - // in ms - // XXX unclear what impact this has on doppelgangers - if (actor === this.player && - p1_primary_direction && - actor.last_move_was_force) - { - actor.decision = p1_primary_direction; - this._set_tile_prop(actor, 'last_move_was_force', false); - } - else { - actor.decision = actor.direction; - if (actor === this.player) { - this._set_tile_prop(actor, 'last_move_was_force', true); - } - } - continue; - } else 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 = p1_primary_direction; - } - else { - this.player2_move = p1_primary_direction; + // Only the player can make voluntary moves on a force floor, and only if their + // previous move was an /involuntary/ move on a force floor. If they do, it + // overrides the forced move + // XXX this in particular has some subtleties in lynx (e.g. you can override + // forwards??) and DEFINITELY all kinds of stuff in ms + // XXX unclear what impact this has on doppelgangers + if (actor.slide_mode === 'force' && ! ( + p1_actions.primary && actor.last_move_was_force)) + { + // We're forced! + actor.decision = actor.direction; + this._set_tile_prop(actor, 'last_move_was_force', true); + continue; } - if (p1_primary_direction) { - actor.decision = p1_primary_direction; + if (p1_actions.primary) { + direction_preference = [p1_actions.primary]; + if (p1_actions.secondary) { + direction_preference.push(p1_actions.secondary); + } this._set_tile_prop(actor, 'last_move_was_force', false); } + else { + continue; + } + } + else if (actor.slide_mode === 'force') { + // Anything not an active player can't override force floors + actor.decision = actor.direction; continue; } else if (actor.cell.some(tile => tile.type.traps && tile.type.traps(tile, actor))) { @@ -753,7 +751,7 @@ export class Level { // TODO i think player on force floor will still have some issues here if (direction_preference) { let fallback_direction; - for (let direction of direction_preference) { + 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; @@ -766,6 +764,13 @@ export class Level { direction = actor.cell.redirect_exit(actor, direction); + // If every other preference be blocked, actors unconditionally try the last one + // (and might even be able to move that way by the time their turn comes!) + if (i === direction_preference.length - 1) { + actor.decision = direction; + break; + } + let dest_cell = this.get_neighboring_cell(actor.cell, direction); if (! dest_cell) continue; @@ -778,16 +783,23 @@ export class Level { break; } } + } - // If all the decisions are blocked, actors still try the last one (and might even - // be able to move that way by the time their turn comes around!) - if (actor.decision === null) { - actor.decision = fallback_direction; + // 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 + let swap_player1 = false; for (let i = this.actors.length - 1; i >= 0; i--) { let actor = this.actors[i]; if (! actor.cell) @@ -798,6 +810,19 @@ export class Level { if (actor.movement_cooldown > 0) continue; + // Check for special player actions + if (actor === this.player) { + if (p1_actions.cycle) { + this.cycle_inventory(this.player); + } + if (p1_actions.drop) { + this.drop_item(this.player); + } + if (p1_actions.swap) { + swap_player1 = true; + } + } + if (! actor.decision) continue; @@ -805,7 +830,7 @@ export class Level { let success = this.attempt_step(actor, actor.decision); // Track whether the player is blocked, for visual effect - if (actor === this.player && p1_primary_direction && ! success) { + if (actor === this.player && actor.decision && ! success) { this.sfx.play_once('blocked'); actor.is_blocked = true; } @@ -862,6 +887,13 @@ export class Level { } this.actors.length = p; + // Possibly switch players + if (swap_player1) { + this.player_index += 1; + this.player_index %= this.players.length; + this.player = this.players[this.player_index]; + } + // Advance the clock let tic_counter = this.tic_counter; this.tic_counter += 1; @@ -1154,6 +1186,29 @@ export class Level { } } + cycle_inventory(actor) { + if (actor.movement_cooldown > 0) + return; + + // Cycle leftwards, i.e., the oldest item moves to the end of the list + if (actor.toolbelt) { + actor.toolbelt.push(actor.toolbelt.shift()); + this.pending_undo.push(() => actor.toolbelt.unshift(actor.toolbelt.pop())); + } + } + + drop_item(actor) { + if (actor.movement_cooldown > 0) + return; + + // Drop the oldest item, i.e. the first one + if (actor.toolbelt && ! actor.cell.get_item()) { + let name = actor.toolbelt.shift(); + this.pending_undo.push(() => actor.toolbelt.unshift(name)); + this.add_tile(new Tile(TILE_TYPES[name]), actor.cell); + } + } + // Update the state of all wired tiles in the game. // XXX need to be clear on the order of events here. say everything starts out unpowered. // then: diff --git a/js/main.js b/js/main.js index 6a2af5b..9756d78 100644 --- a/js/main.js +++ b/js/main.js @@ -375,6 +375,20 @@ class Player extends PrimaryView { this.set_state('rewinding'); } }); + // Game actions + // TODO do these need buttons?? feel like they're not discoverable otherwise + this.drop_button = this.root.querySelector('.actions .action-drop'); + this.drop_button.addEventListener('click', ev => { + this.current_keys.add('q'); + }); + this.cycle_button = this.root.querySelector('.actions .action-cycle'); + this.cycle_button.addEventListener('click', ev => { + this.current_keys.add('e'); + }); + this.swap_button = this.root.querySelector('.actions .action-swap'); + this.swap_button.addEventListener('click', ev => { + this.current_keys.add('c'); + }); // Demo playback this.root.querySelector('.demo-controls .demo-play').addEventListener('click', ev => { if (this.state === 'playing' || this.state === 'paused' || this.state === 'rewinding') { @@ -386,6 +400,8 @@ class Player extends PrimaryView { this.play_demo(); } }); + // FIXME consolidate these into debug controls + /* this.root.querySelector('.demo-controls .demo-step-1').addEventListener('click', ev => { this.advance_by(1); this._redraw(); @@ -394,6 +410,7 @@ class Player extends PrimaryView { this.advance_by(4); this._redraw(); }); + */ this.renderer = new CanvasRenderer(this.conductor.tileset); this.level_el.append(this.renderer.canvas); @@ -753,6 +770,9 @@ class Player extends PrimaryView { // newly pressed secondary action; remember, there can't be two opposing keys held, // because we already checked for that above, so this is only necessary if there's // not already a secondary action + if (this.secondary_action && ! input.has(this.secondary_action)) { + this.secondary_action = null; + } if (! this.secondary_action) { for (let action of ['down', 'left', 'right', 'up']) { if (action !== this.primary_action && @@ -795,27 +815,32 @@ class Player extends PrimaryView { } } + let player_actions = { + primary: this.primary_action ? ACTION_DIRECTIONS[this.primary_action] : null, + secondary: this.secondary_action ? ACTION_DIRECTIONS[this.secondary_action] : null, + cycle: input.has('cycle') && ! this.previous_input.has('cycle'), + drop: input.has('drop') && ! this.previous_input.has('drop'), + swap: input.has('swap') && ! this.previous_input.has('swap'), + } + this.previous_input = input; this.sfx_player.advance_tic(); - let primary_dir = this.primary_action ? ACTION_DIRECTIONS[this.primary_action] : null; - let secondary_dir = this.secondary_action ? ACTION_DIRECTIONS[this.secondary_action] : null; - // Turn-based mode is considered assistance, but only if the game actually attempts to // progress while it's enabled if (this.turn_mode > 0) { this.level.aid = Math.max(1, this.level.aid); } - let has_input = primary_dir !== null || input.has('wait'); + let has_input = Object.values(player_actions).some(x => x); // Turn-based mode complicates this slightly; it aligns us to the middle of a tic if (this.turn_mode === 2) { if (has_input) { // Finish the current tic, then continue as usual. This means the end of the // tic doesn't count against the number of tics to advance -- because it already // did, the first time we tried it - this.level.advance_tic(primary_dir, secondary_dir, 2); + this.level.advance_tic(player_actions, 2); this.turn_mode = 1; } else { @@ -824,14 +849,14 @@ class Player extends PrimaryView { } // We should now be at the start of a tic - this.level.advance_tic(primary_dir, secondary_dir, 1); + this.level.advance_tic(player_actions, 1); if (this.turn_mode > 0 && this.level.can_accept_input() && ! has_input) { // If we're in turn-based mode and could provide input here, but don't have any, // then wait until we do this.turn_mode = 2; } else { - this.level.advance_tic(primary_dir, secondary_dir, 2); + this.level.advance_tic(player_actions, 2); } if (this.level.state !== 'playing') { @@ -931,11 +956,15 @@ class Player extends PrimaryView { } update_ui() { - this.pause_button.disabled = !(this.state === 'playing' || this.state === 'paused' || this.state === 'rewinding'); + this.pause_button.disabled = ! (this.state === 'playing' || this.state === 'paused' || this.state === 'rewinding'); this.restart_button.disabled = (this.state === 'waiting'); this.undo_button.disabled = ! this.level.has_undo(); this.rewind_button.disabled = ! (this.level.has_undo() || this.state === 'rewinding'); + this.drop_button.disabled = ! (this.state === 'playing' && this.level.player.toolbelt && this.level.player.toolbelt.length > 0); + this.cycle_button.disabled = ! (this.state === 'playing' && this.level.player.toolbelt && this.level.player.toolbelt.length > 1); + this.swap_button.disabled = ! (this.state === 'playing' && this.level.players.length > 1); + // TODO can we do this only if they actually changed? this.chips_el.textContent = this.level.chips_remaining; if (this.level.chips_remaining === 0) { diff --git a/js/tiletypes.js b/js/tiletypes.js index 273894f..dd798b8 100644 --- a/js/tiletypes.js +++ b/js/tiletypes.js @@ -1718,6 +1718,8 @@ const TILE_TYPES = { is_player: true, is_real_player: true, collision_mask: COLLISION.player1, + // FIXME does this make us block the doppelgangers?? + blocks_collision: COLLISION.player, has_inventory: true, can_reveal_walls: true, movement_speed: 4, @@ -1737,6 +1739,7 @@ const TILE_TYPES = { is_player: true, is_real_player: true, collision_mask: COLLISION.player2, + blocks_collision: COLLISION.players, has_inventory: true, can_reveal_walls: true, movement_speed: 4, diff --git a/style.css b/style.css index 692d465..d0e2be6 100644 --- a/style.css +++ b/style.css @@ -128,6 +128,7 @@ svg.svg-icon { stroke: none; fill: currentColor; + fill-rule: evenodd; } /* Overlay styling */ @@ -536,6 +537,7 @@ button.level-pack-button p { "level bonus" min-content "level message" 1fr "level inventory" min-content + "level actions" min-content /* Need explicit min-content to force the hint to wrap */ / min-content min-content ; @@ -743,6 +745,15 @@ dl.score-chart .-sum { background: #0009; color: white; } +#player .actions { + grid-area: actions; + display: flex; + gap: 0.5em; +} +#player .actions button { + flex: 1; + white-space: nowrap; +} #player-music { grid-area: music; @@ -766,7 +777,30 @@ dl.score-chart .-sum { #player .controls { grid-area: controls; display: flex; + gap: 0.25em; + justify-content: space-between; } +#player button { + position: relative; +} +#player button .keyhint { + position: absolute; + left: 0; + right: 0; + top: -2em; + width: 1em; + margin: auto; + color: #404040; + border: 1px solid #202020; + border-radius: 0.25em; + box-shadow: 0 2px 0 #202020; + text-align: center; + text-transform: uppercase; +} +#player button:disabled .keyhint { + display: none; +} + .play-controls, .demo-controls { display: flex; @@ -778,8 +812,6 @@ dl.score-chart .-sum { } .demo-controls { display: none; - flex: 1; - justify-content: flex-end; } main.--has-demo .demo-controls { display: flex; @@ -831,13 +863,21 @@ main.--has-demo .demo-controls { /* The play area isn't necessarily the biggest thing any more, and it's ugly when stretched */ align-items: center; } + #player .controls { + flex-direction: column; + } + main.--has-demo .demo-controls { + /* TODO need a better place for this! */ + display: none; + } #player > .-main-area { /* Rearrange the grid to be vertical */ grid: - "level level" - "chips inventory" calc((var(--tile-height) * var(--scale) * 2 - 1em) / 3) - "time inventory" calc((var(--tile-height) * var(--scale) * 2 - 1em) / 3) - "bonus inventory" calc((var(--tile-height) * var(--scale) * 2 - 1em) / 3) + "level level" + "chips inventory" calc((var(--tile-height) * var(--scale) * 2 - 1em) / 3) + "time inventory" calc((var(--tile-height) * var(--scale) * 2 - 1em) / 3) + "bonus inventory" calc((var(--tile-height) * var(--scale) * 2 - 1em) / 3) + "actions actions" min-content /* FIXME ideally the first column would be 1fr so the hearts/time have space, but that * allows hints to grow to the entire width of the window, which incredibly sucks. i * don't know how to get around this except by giving the grid a fixed width, which i @@ -860,7 +900,7 @@ main.--has-demo .demo-controls { z-index: 1; font-size: calc(var(--tile-height) * var(--scale) / 2.5); } - #player .controls .keyhint { + #player .keyhint { /* Hide key hints, they take up surprisingly a lot of space */ display: none; }