Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add puzzle 012 #19

Merged
merged 7 commits into from
Feb 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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