Skip to content

Commit

Permalink
Add puzzle 012 (#19)
Browse files Browse the repository at this point in the history
This puzzle further strengthens the rules introduced in previous portal
puzzles. It is also the most difficult portal puzzle to this point.

This PR also includes a number of other bug fixes and improvements:
- Beam step-specific state (which is ultimately stored in the terminal
opening state configuration, which is the basis for beam state
configuration) has been refactored so that it is attached to specific
steps and will be automatically added and removed when the steps are
added and removed. This state is currently only used by portals, but
could be used by other things in the future.
- Items can now specify 'immutable' in the state configuration, which
should basically act the same as setting each individual modifier key to
false (e.g. movable, toggleable, rotatable, etc.). Beam and wall items
will have immutable set by default.
- Item IDs are now retained in cache, making them stable IDs.
Previously, item IDs were re-generated when the page was refreshed,
which meant that if an item had been moved its ID could change. The
generation and storage of item IDs on the loading of a puzzle is not
stored as a delta, so it won't show up as a move and won't be undo-able.
- Swap now requires tiles to contain movable items, instead of any
items.
- Masks are now re-evaluated when dequeued, with the potential to cancel
the mask. This is because state can change in between when the mask is
queued and when it gets processed. For example, if multiple portals are
reached at the same time, the resolution of one of the portals may cause
a subsequent portal mask to no longer be valid.
- The tile indicator has been refactored to apply an "is not immutable"
filter. The primary purpose of the indicator is to show the user they
can interact with something in that tile, and immutable modifiers are
not interactable, so it just creates noise. The lock and immutable
modifiers mostly apply when moving something anyways.
- Fixed getting color elements for a newly selected tile, if it
contained merge beams it was throwing an error using because it was
using `step.color` instead of `step.colors` (which is an array).
- Updated the portal collision logic in `beam.onCollision` was not
handling every case.
- Fixed state index not being updated correctly if `keepDelta` was false
on state update.
- Fixed state actions like undo and redo not being properly prevented
even if they were disabled visually. The reset action is also now being
disabled in cases where it should be.
- Fixed the puzzle mask queue not being emptied when the puzzle was
reloaded, which caused some strange undo/redo behaviors.
  • Loading branch information
kflorence authored Feb 24, 2024
1 parent f1d664c commit d50916f
Show file tree
Hide file tree
Showing 18 changed files with 723 additions and 149 deletions.
19 changes: 17 additions & 2 deletions src/components/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,28 @@ export class Item extends Stateful {
center
data
group
id = Item.uniqueId++
id
immutable
// Whether the item can be clicked on
locked
parent
sortOrder = 100
type

constructor (parent, state, configuration) {
// Retain ID from state if it exists, otherwise generate a new one
state.id ??= Item.uniqueId++

super(state)

this.type = state?.type || configuration?.type
this.id = state.id
this.immutable ??= state?.immutable ?? false
this.type = state?.type ?? configuration?.type
if (this.type === undefined) {
console.debug(`[Item:${this.id}]`, state)
throw new Error('Item must have type defined')
}

this.data = Object.assign({ id: this.id, type: this.type }, configuration?.data || {})
this.locked = configuration?.locked !== false

Expand Down Expand Up @@ -75,6 +86,10 @@ export class Item extends Stateful {

update () {}

static immutable (item) {
return item.immutable
}

static Types = Object.freeze(Object.fromEntries([
'beam',
'collision',
Expand Down
18 changes: 9 additions & 9 deletions src/components/itemFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,28 @@ import { Reflector } from './items/reflector'
import { Wall } from './items/wall'
import { Item } from './item'

export function itemFactory (parent, configuration) {
export function itemFactory (parent, state, configuration) {
let item

switch (configuration.type) {
switch (state.type) {
case Item.Types.filter:
item = new Filter(parent, configuration)
item = new Filter(...arguments)
break
case Item.Types.portal:
item = new Portal(parent, configuration)
item = new Portal(...arguments)
break
case Item.Types.terminus:
item = new Terminus(parent, configuration)
item = new Terminus(...arguments)
break
case Item.Types.reflector:
item = new Reflector(parent, configuration)
item = new Reflector(...arguments)
break
case Item.Types.wall:
item = new Wall(parent, configuration)
item = new Wall(...arguments)
break
default:
console.error('Ignoring item with unknown type:', configuration.type)
break
console.debug('itemFactory', state)
throw new Error(`Cannot create item with unknown type: ${state.type}`)
}

if (item) {
Expand Down
21 changes: 11 additions & 10 deletions src/components/items/beam.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ export class Beam extends Item {
#steps = []

constructor (terminus, state, configuration) {
// Exclude from modification
state.immutable = true

super(...arguments)

this.group = null
Expand Down Expand Up @@ -125,7 +128,7 @@ export class Beam extends Item {
getColorElements (tile) {
// Show color elements for merged beams
const step = this.getSteps(tile).find((step) => step.state.has(StepState.MergeWith))
return step ? getColorElements(step.color) : []
return step ? getColorElements(step.colors) : []
}

getCompoundPath () {
Expand Down Expand Up @@ -284,16 +287,14 @@ export class Beam extends Item {
}
}

const isSameDirection = step.direction === nextStep.direction
if (currentStep.state.get(StepState.Portal)?.exitPortal && !isSameDirection) {
console.debug(
this.toString(),
'ignoring collision with beam using same portal with different exit direction',
beam.toString()
)
// Check for a portal on either beam
const portal = currentStep.state.get(StepState.Portal) ?? step.state.get(StepState.Portal)
if (portal) {
console.debug(this.toString(), 'ignoring collision with beam using same portal', beam.toString())
return
}

const isSameDirection = step.direction === nextStep.direction
if (!isSameDirection || isSelf) {
// Beams are traveling in different directions (collision), or a beam is trying to merge into itself
console.debug(beam.toString(), 'has collided with', (isSelf ? 'self' : this.toString()), collision)
Expand Down Expand Up @@ -348,8 +349,6 @@ export class Beam extends Item {
if (!this.isOn()) {
if (this.#steps.length) {
console.debug(this.toString(), 'beam has been toggled off')
// Also reset any state changes from user move decisions
this.updateState((state) => { delete state.moves })
this.remove()
}
return
Expand Down Expand Up @@ -558,6 +557,8 @@ export class Beam extends Item {
updateStep (stepIndex, settings) {
const step = this.getStep(stepIndex)
if (step) {
// Update is essentially: remove, update, add
step.onRemove(step)
const updatedStep = this.#getUpdatedStep(step, settings)
this.#steps[stepIndex] = updatedStep
updatedStep.onAdd(updatedStep)
Expand Down
156 changes: 98 additions & 58 deletions src/components/items/portal.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export class Portal extends movable(rotatable(Item)) {

children.push(ring)

if (this.rotatable) {
if (this.direction !== undefined) {
const pointer = new Path({
closed: true,
opacity: 0.25,
Expand All @@ -63,7 +63,7 @@ export class Portal extends movable(rotatable(Item)) {

this.group.addChildren(children)

if (this.rotatable) {
if (this.direction !== undefined) {
// Properly align items with hexagonal rotation
this.rotateGroup(1)
}
Expand All @@ -76,10 +76,9 @@ export class Portal extends movable(rotatable(Item)) {
onCollision ({ beam, currentStep, nextStep, puzzle }) {
const portalState = currentStep.state.get(StepState.Portal)
if (!portalState) {
const stepIndex = nextStep.index
const entryDirection = getOppositeDirection(nextStep.direction)
const existing = coalesce(this.get(entryDirection), { stepIndex })
if (existing.stepIndex < stepIndex) {
const existing = coalesce(this.get(entryDirection), nextStep)
if (existing.index < nextStep.index) {
// Checking stepIndex to exclude cases where we are doing a re-evaluation of history.
console.debug(
this.toString(),
Expand All @@ -92,7 +91,7 @@ export class Portal extends movable(rotatable(Item)) {
// Handle entry collision
return nextStep.copy({
insertAbove: this,
onAdd: () => this.update(entryDirection, { stepIndex }),
onAdd: (step) => this.update(entryDirection, step),
onRemove: () => this.update(entryDirection),
state: nextStep.state.copy(new StepState.Portal(this))
})
Expand All @@ -101,90 +100,131 @@ export class Portal extends movable(rotatable(Item)) {
return nextStep.copy({ insertAbove: this })
}

// Check for destination in beam state (matches on item ID and step index)
const stateId = [this.id, nextStep.index].join(':')
const destinationId = beam.getState().moves?.[stateId]
const exitPortals = this.#getExitPortals(puzzle, beam, nextStep)

// Find all valid destination portals
const destinations = puzzle.getItems().filter((item) =>
item.type === Item.Types.portal &&
!item.equals(this) &&
// Portal must not already have a beam occupying the desired direction
!item.get(Portal.getExitDirection(nextStep, portalState.entryPortal, item)) &&
(destinationId === undefined || item.id === destinationId) &&
(
// Entry portals without defined direction can exit from any other portal.
this.getDirection() === undefined ||
// Exit portals without defined direction can be used by any entry portal.
item.getDirection() === undefined ||
// Exit portals with a defined direction can only be used by entry portals with the same defined direction.
item.getDirection() === this.getDirection()
)
)

if (destinations.length === 0) {
console.debug(this.toString(), 'no valid destinations found')
if (exitPortals.length === 0) {
console.debug(this.toString(), 'no valid exit portals found')
// This will cause the beam to stop
return currentStep
}

if (destinations.length === 1) {
// A single matching destination
return this.#getStep(beam, destinations[0], nextStep, portalState)
} else if (exitPortals.length === 1) {
const exitPortal = exitPortals[0]
console.debug(this.toString(), 'single exit portal matched:', exitPortal)
return this.#getStep(beam, nextStep, exitPortal)
} else {
// Multiple matching destinations. User will need to pick one manually.
const destinationTiles = destinations.map((portal) => portal.parent)
console.debug(this.toString(), 'found multiple valid exit portals:', exitPortals)
// Cache exit portals for use in mask
const data = { exitPortals }
const mask = new Puzzle.Mask(
{
beam,
id: stateId,
id: this.id,
onMask: () => currentStep.tile.beforeModify(),
onTap: (puzzle, tile) => {
const destination = destinations.find((portal) => portal.parent === tile)
if (destination) {
beam.addStep(this.#getStep(beam, destination, nextStep, portalState))
beam.updateState((state) => {
if (!state.moves) {
state.moves = {}
}
// Store this decision in beam state
state.moves[stateId] = destination.id
})
const exitPortal = data.exitPortals.find((portal) => portal.parent === tile)
if (exitPortal) {
beam.addStep(this.#getStep(beam, nextStep, exitPortal))
puzzle.unmask()
}
},
onUnmask: () => currentStep.tile.afterModify(),
onUpdate: () => {
// State may have changed, fetch portals again
const exitPortals = this.#getExitPortals(puzzle, beam, nextStep)
if (exitPortals.length === 0) {
console.debug(this.toString(), 'mask onUpdate: no valid exit portals found')
// Cancel the mask
// This will also cause the beam to stop
return false
} else if (exitPortals.length === 1) {
const exitPortal = exitPortals[0]
console.debug(this.toString(), 'mask onUpdate: single portal matched:', exitPortal)
beam.addStep(this.#getStep(beam, nextStep, exitPortal))
// Cancel the mask
return false
} else {
console.debug(this.toString(), 'mask onUpdate: exit portals:', exitPortals)
data.exitPortals = exitPortals
}
},
tileFilter: (tile) => {
// Include the portal tile and tiles which contain a matching destination
return !(this.parent === tile || destinationTiles.some((destinationTile) => destinationTile === tile))
// Mask any invalid tiles. Exclude the entry portal tile
return !(tile.equals(this.parent) ||
data.exitPortals.map((portal) => portal.parent).some((validTile) => validTile.equals(tile)))
}
}
)

puzzle.updateSelectedTile(currentStep.tile)
puzzle.updateSelectedTile(null)
puzzle.mask(mask)

// This will cause the beam to stop
return currentStep
}
}

onMove () {
super.onMove()

// Invalidate directions cache
this.#directions = {}
}

update (direction, data) {
this.#directions[direction] = data
}

#getStep (beam, portal, nextStep, portalState) {
const direction = Portal.getExitDirection(nextStep, portalState.entryPortal, portal)
const stepIndex = nextStep.index
#getExitPortals (puzzle, beam, nextStep) {
const exitPortals = puzzle.getItems().filter((item) =>
// Is a portal
item.type === Item.Types.portal &&
// But not the entry portal
!item.equals(this) &&
// There is no other beam occupying the portal at the exit direction
!item.get(Portal.getExitDirection(nextStep, this, item)) && (
// Entry portals without defined direction can exit from any other portal.
this.getDirection() === undefined ||
// Exit portals without defined direction can be used by any entry portal.
item.getDirection() === undefined ||
// Exit portals with a defined direction can only be used by entry portals with the same defined direction.
item.getDirection() === this.getDirection()
)
)

if (exitPortals.length > 1) {
// Check for existing exitPortalId in beam state for this step
const exitPortalId = beam.getState().steps?.[nextStep.index]?.[this.id]
if (exitPortalId !== undefined) {
console.debug(this.toString(), `found exitPortalId ${exitPortalId} in beam step ${nextStep.index} state`)
const existing = exitPortals.find((item) => item.id === exitPortalId)
if (existing) {
return [existing]
}
}
}

return exitPortals
}

#getStep (beam, nextStep, exitPortal) {
const direction = Portal.getExitDirection(nextStep, this, exitPortal)
return nextStep.copy({
connected: false,
direction,
insertAbove: portal,
onAdd: () => portal.update(direction, { stepIndex }),
onRemove: () => portal.update(direction),
point: portal.parent.center,
state: nextStep.state.copy(new StepState.Portal(portalState.entryPortal, portal)),
tile: portal.parent
insertAbove: exitPortal,
onAdd: (step) => {
exitPortal.update(direction, step)
// Store this decision in beam state and generate a matching delta
beam.updateState((state) => ((state.steps ??= {})[step.index] = { [this.id]: exitPortal.id }))
},
onRemove: (step) => {
// Remove any associated beam state, but don't generate a delta.
// If the step is being removed, a delta for that action was most likely created elsewhere already.
beam.updateState((state) => { delete state.steps[step.index] }, false)
exitPortal.update(direction)
},
point: exitPortal.parent.center,
state: nextStep.state.copy(new StepState.Portal(this, exitPortal)),
tile: exitPortal.parent
})
}

Expand Down
12 changes: 6 additions & 6 deletions src/components/items/terminus.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ export class Terminus extends movable(rotatable(toggleable(Item))) {
colors.length ? colors : (Array.isArray(state.color) ? state.color : [state.color])
).hex()

const openings = state.openings.map((state, direction) =>
state
const openings = state.openings.map((opening, direction) =>
opening
? new Terminus.#Opening(
state.color || color,
opening.color ?? color,
direction,
state.connected,
state.on
opening.connected,
opening.on ?? state.on
)
: state
: opening
).filter((opening) => opening)

this.#ui = Terminus.ui(tile, color, openings)
Expand Down
3 changes: 2 additions & 1 deletion src/components/items/tile.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,8 @@ export class Tile extends Item {

update () {
super.update()
this.#ui.indicator.opacity = this.modifiers.length ? 1 : 0
// Display the indicator if the tile contains non-immutable modifiers
this.#ui.indicator.opacity = this.modifiers.filter((modifier) => !modifier.immutable).length ? 1 : 0
}

static parameters (height) {
Expand Down
Loading

0 comments on commit d50916f

Please sign in to comment.