Skip to content

Commit

Permalink
UI/UX updates for usability (#24)
Browse files Browse the repository at this point in the history
The main goal here is to make it visually apparent what actions can be
taken on each tile without having to first select the tile. A secondary
goal is to make the UI easier to use on large screens.
  • Loading branch information
kflorence authored Sep 25, 2024
1 parent 73acd02 commit 3bd1dca
Show file tree
Hide file tree
Showing 41 changed files with 1,671 additions and 965 deletions.
1,637 changes: 1,126 additions & 511 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"test-lint": "standard"
},
"devDependencies": {
"chromedriver": "^125.0.3",
"chromedriver": "^128.0.3",
"mocha": "^10.4.0",
"parcel": "^2.12.0",
"selenium-webdriver": "^4.21.0",
Expand Down
25 changes: 25 additions & 0 deletions src/components/icons.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Symbol } from './symbol'

export class Icons {
static Immutable = new Symbol('icon-immutable', 'block')
static Lock = new Symbol('icon-lock', 'lock')
static Move = new Symbol('icon-move', 'drag_pan')
static RotateLeft = new Symbol('icon-rotate-left', 'rotate_left')
static RotateRight = new Symbol('icon-rotate-right', 'rotate_right')
static Swap = new Symbol('icon-swap', 'swap_horiz')
static ToggleOff = new Symbol('icon-toggle-off', 'toggle_off')
static ToggleOn = new Symbol('icon-toggle-on', 'toggle_on')

static All = Object.freeze([
Icons.Immutable,
Icons.Lock,
Icons.Move,
Icons.RotateLeft,
Icons.RotateRight,
Icons.Swap,
Icons.ToggleOff,
Icons.ToggleOn
])

static ByName = Object.freeze(Object.fromEntries(Icons.All.map((icon) => [icon.name, icon])))
}
8 changes: 2 additions & 6 deletions src/components/item.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { capitalize } from './util'
import { capitalize, uniqueId } from './util'
import { CompoundPath, Group } from 'paper'
import { Stateful } from './stateful'

Expand All @@ -16,7 +16,7 @@ export class Item extends Stateful {

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

super(state)

Expand Down Expand Up @@ -101,8 +101,4 @@ export class Item extends Stateful {
'tile',
'wall'
].map((type) => [type, capitalize(type)])))

static uniqueId () {
return crypto.randomUUID().split('-')[0]
}
}
23 changes: 13 additions & 10 deletions src/components/items/beam.js
Original file line number Diff line number Diff line change
Expand Up @@ -299,16 +299,19 @@ export class Beam extends Item {
// 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)

if (!isSelf) {
// Update beam at point of impact
this.update(stepIndex, {
done: true,
state: step.state.copy(new StepState.Collision(collision.mirror()))
})
} else if (!isSameDirection) {
// For a collision with self, the update at point of impact will occur on the next update loop. This results in
// a better visualization of the collision which will result in an infinite looping animation.
this.update(stepIndex, puzzle.getBeamsUpdateDelay())
if (!step.state.get(StepState.Collision)?.point.equals(collision.point)) {
// No need to update if there is already an existing collision at this same point
if (!isSelf) {
// Update beam at point of impact
this.update(stepIndex, {
done: true,
state: step.state.copy(new StepState.Collision(collision.mirror()))
})
} else if (!isSameDirection) {
// For a collision with self, the update at point of impact will occur on the next update loop. This results in
// a better visualization of the collision which will result in an infinite looping animation.
this.update(stepIndex, puzzle.getBeamsUpdateDelay())
}
}

return collisionStep.copy({
Expand Down
7 changes: 4 additions & 3 deletions src/components/items/portal.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ export class Portal extends movable(rotatable(Item)) {
} else if (exitPortals.length === 1) {
const exitPortal = exitPortals[0]
console.debug(this.toString(), 'single exit portal matched:', exitPortal)
return this.#getStep(beam, nextStep, exitPortal)
// Since no user choice was made, don't store this decision as a delta (move)
return this.#getStep(beam, nextStep, exitPortal, false)
} else {
// Multiple matching destinations. User will need to pick one manually.
console.debug(this.toString(), 'found multiple valid exit portals:', exitPortals)
Expand Down Expand Up @@ -205,7 +206,7 @@ export class Portal extends movable(rotatable(Item)) {
return exitPortals
}

#getStep (beam, nextStep, exitPortal) {
#getStep (beam, nextStep, exitPortal, keepDelta = true) {
const direction = Portal.getExitDirection(nextStep, this, exitPortal)
return nextStep.copy({
connected: false,
Expand All @@ -214,7 +215,7 @@ export class Portal extends movable(rotatable(Item)) {
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 }))
beam.updateState((state) => ((state.steps ??= {})[step.index] = { [this.id]: exitPortal.id }), keepDelta)
},
onRemove: (step) => {
// Remove any associated beam state, but don't generate a delta.
Expand Down
25 changes: 13 additions & 12 deletions src/components/items/reflector.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,18 @@ export class Reflector extends movable(rotatable(Item)) {

if (directionTo === directionFrom) {
console.debug(beam.toString(), 'stopping due to reflection back at self')
if (collisions.some((collision) => collision.item.type === Item.Types.beam)) {
// If there is also a beam collision in the list of collisions for this step, let that one resolve it
return
} else {
// Instead of using collisionStep, just add a collision to nextStep. This will ensure any beams that hit the
// same side of the reflector will collide with this beam.
return nextStep.copy({
done: true,
state: nextStep.state.copy(new StepState.Collision(collision.copy({ points: [nextStep.point] })))
})
}
// Unsure why this rule was here, but it was causing a bug when re-evaluating history
// if (collisions.some((collision) => collision.item.type === Item.Types.beam)) {
// // If there is also a beam collision in the list of collisions for this step, let that one resolve it
// return
// } else {
// Instead of using collisionStep, just add a collision to nextStep. This will ensure any beams that hit the
// same side of the reflector will collide with this beam.
return nextStep.copy({
done: true,
state: nextStep.state.copy(new StepState.Collision(collision.copy({ points: [nextStep.point] })))
})
// }
}

// The beam will collide with a reflector twice, on entry and exit, so ignore the first one, but track in state
Expand All @@ -68,8 +69,8 @@ export class Reflector extends movable(rotatable(Item)) {
}

static item (tile, color) {
const length = tile.parameters.circumradius
const width = tile.parameters.circumradius / 12
const length = tile.parameters.circumradius - (width * 2)
const topLeft = tile.center.subtract(new Point(width / 2, length / 2))
const size = new Size(width, length)

Expand Down
1 change: 1 addition & 0 deletions src/components/items/terminus.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export class Terminus extends movable(rotatable(toggleable(Item))) {
this.color = color
this.openings = openings
this.radius = this.#ui.radius
this.toggled = openings.some((opening) => opening.on)

// Needs to be last since it references 'this'
this.beams = openings.map((opening) => new Beam(this, state.openings[opening.direction], opening))
Expand Down
39 changes: 20 additions & 19 deletions src/components/items/tile.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { emitEvent, getPointBetween } from '../util'
import { modifierFactory } from '../modifierFactory'

export class Tile extends Item {
icons = []
selected = false

#ui
Expand All @@ -20,7 +21,7 @@ export class Tile extends Item {
this.parameters = parameters
this.styles = this.#ui.styles

this.group.addChildren([this.#ui.hexagon, this.#ui.indicator])
this.group.addChildren([this.#ui.hexagon])

// These need to be last, since they reference this
this.items = (state.items || [])
Expand All @@ -35,12 +36,12 @@ export class Tile extends Item {
}

addItem (item) {
this.items.unshift(item)
this.items.push(item)
this.update()
}

addModifier (modifier) {
this.modifiers.unshift(modifier)
this.modifiers.push(modifier)
this.update()
}

Expand Down Expand Up @@ -69,29 +70,28 @@ export class Tile extends Item {
state.modifiers = modifiers
}

// noinspection JSValidateTypes
return state
}

onTap (event) {
console.debug(this.coordinates.offset.toString(), this)
this.items.forEach((item) => item.onTap(event))
}

onDeselected (selectedTile) {
this.selected = false
this.#ui.hexagon.style = this.styles.default
this.items.forEach((item) => item.onDeselected())
this.modifiers.forEach((modifier) => modifier.detach())

emitEvent(Tile.Events.Deselected, { selectedTile, deselectedTile: this })
}

onSelected (deselectedTile) {
console.debug(this.toString(), 'selected')
this.selected = true
this.group.bringToFront()
this.#ui.hexagon.style = this.styles.selected
this.items.forEach((item) => item.onSelected())
this.modifiers.forEach((modifier) => modifier.attach())
}

removeItem (item) {
Expand Down Expand Up @@ -119,13 +119,21 @@ export class Tile extends Item {
}

toString () {
return this.coordinates.offset.toString()
return `[${this.type}:${this.coordinates.offset.toString()}]`
}

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

// Update tile modifier icons
this.icons = this.modifiers.map((modifier, index) => {
const position = getPointBetween(this.#ui.hexagon.segments[index].point, this.center, (length) => length / 3)
return modifier.getSymbol().place(position, { fillColor: modifier.immutable ? '#ccc' : '#333' })
})

// Everything after child 0 (hexagon) is an icon
this.group.removeChildren(1)
this.group.addChildren(this.icons)
}

static parameters (height) {
Expand Down Expand Up @@ -169,23 +177,16 @@ export class Tile extends Item {
style: styles.default
})

const indicator = new Path.RegularPolygon({
center: getPointBetween(hexagon.segments[1].point, center, (length) => length / 3),
data: { collidable: false },
opacity: 0,
radius: parameters.circumradius / 16,
sides: 6,
style: { fillColor: '#ccc' }
})

return { center, hexagon, indicator, styles }
return { center, hexagon, styles }
}

static Events = Object.freeze({
Deselected: 'tile-deselected',
Selected: 'tile-selected'
})

static MaxModifiers = 6

static Styles = Object.freeze({
// Need to use new Color here explicitly due to:
// https://github.com/paperjs/paper.js/issues/2049
Expand Down
28 changes: 25 additions & 3 deletions src/components/layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { OffsetCoordinates } from './coordinates/offset'
import { Tile } from './items/tile'
import { getConvertedDirection } from './util'
import { Stateful } from './stateful'
import { modifierFactory } from './modifierFactory'

export class Layout extends Stateful {
#tilesByAxial = []
#tilesByOffset = []

items = []
layers = {}
modifiers = []
tiles = []
tileSize = 120

Expand All @@ -27,9 +29,15 @@ export class Layout extends Stateful {
const height = tiles.length * parameters.width
const startingOffsetY = center.y - (height / 2)

// noinspection JSValidateTypes
this.layers.tiles = new Layer()
// noinspection JSValidateTypes
this.layers.items = new Layer()

this.modifiers = (state.modifiers || [])
.map((state) => modifierFactory(null, state))
.filter((modifier) => modifier !== undefined)

// Find the widest row
const widestRow = tiles.reduce((current, row, index) => {
const length = row.length
Expand Down Expand Up @@ -97,17 +105,31 @@ export class Layout extends Stateful {
}

getState () {
const state = { type: this.type }

// Tiles are defined by offset in the puzzle state
return Object.assign(super.getState(), {
tiles: this.#tilesByOffset.map((row) => row.map((tile) => tile?.getState() || null))
})
state.tiles = this.#tilesByOffset.map((row) => row.map((tile) => tile?.getState() || null))
const modifiers = this.modifiers.map((modifier) => modifier.getState())
if (modifiers.length) {
state.modifiers = modifiers
}

return state
}

getNeighboringTile (axial, direction) {
return this.getTileByAxial(CubeCoordinates.neighbor(axial, getConvertedDirection(direction)))
}

removeModifier (modifier) {
const index = this.modifiers.indexOf(modifier)
if (index >= 0) {
this.modifiers.splice(index, 1)
}
}

teardown () {
this.modifiers.forEach((modifier) => modifier.detach())
Object.values(this.layers).forEach((layer) => layer.removeChildren())
}

Expand Down
Loading

0 comments on commit 3bd1dca

Please sign in to comment.