@astronox/core 0.5.6 → 0.5.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/patterns/LightMazePuzzle/LightMazePuzzle.astro +210 -0
- package/src/patterns/LightMazePuzzle/LightMazePuzzle.css +189 -0
- package/src/patterns/LightMazePuzzle/contracts.ts +75 -0
- package/src/patterns/LightMazePuzzle/lightMazePuzzleController.ts +684 -0
- package/src/patterns/LightMazePuzzle/presentation.ts +55 -0
- package/src/patterns/LightMazePuzzle/simulation.ts +164 -0
- package/src/patterns/SlidingPuzzle/SlidingPuzzle.astro +7 -3
- package/src/patterns/SlidingPuzzle/SlidingPuzzle.css +18 -9
- package/src/patterns/SlidingPuzzle/contracts.ts +6 -1
- package/src/patterns/SlidingPuzzle/slidingPuzzleController.ts +134 -35
- package/src/patterns/index.ts +4 -0
- package/src/styles/style.css +1 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LightMazeMechanism,
|
|
3
|
+
LightMazePosition,
|
|
4
|
+
LightMazePuzzleLabels,
|
|
5
|
+
LightMazePuzzleModel,
|
|
6
|
+
} from './contracts';
|
|
7
|
+
|
|
8
|
+
export function presentLightMazeCell(
|
|
9
|
+
puzzle: LightMazePuzzleModel,
|
|
10
|
+
mechanism: LightMazeMechanism,
|
|
11
|
+
labels: LightMazePuzzleLabels,
|
|
12
|
+
position: LightMazePosition,
|
|
13
|
+
) {
|
|
14
|
+
const matches = (cell: LightMazePosition): boolean =>
|
|
15
|
+
cell.row === position.row && cell.column === position.column;
|
|
16
|
+
const activator =
|
|
17
|
+
puzzle.activators?.some((entry) => matches(entry.position)) ?? false;
|
|
18
|
+
const consumed = mechanism.activatedCells.some(matches);
|
|
19
|
+
const disabled = mechanism.blockedCells.some(matches);
|
|
20
|
+
const removableWall =
|
|
21
|
+
puzzle.activators?.some((entry) => entry.walls.some(matches)) ?? false;
|
|
22
|
+
const releasedWall = removableWall && !disabled;
|
|
23
|
+
const target = matches(puzzle.target);
|
|
24
|
+
const lit = mechanism.illuminatedCells.some(matches);
|
|
25
|
+
if ((activator || removableWall) && !labels.mechanism)
|
|
26
|
+
throw new Error('Light maze mechanisms require accessible labels.');
|
|
27
|
+
let activationLabel = '';
|
|
28
|
+
if (activator) {
|
|
29
|
+
activationLabel = consumed
|
|
30
|
+
? labels.mechanism!.activatorConsumed
|
|
31
|
+
: labels.mechanism!.activatorAvailable;
|
|
32
|
+
}
|
|
33
|
+
const label = disabled
|
|
34
|
+
? removableWall
|
|
35
|
+
? labels.mechanism!.removableWall
|
|
36
|
+
: labels.blockedCell
|
|
37
|
+
: [
|
|
38
|
+
target ? labels.target : '',
|
|
39
|
+
activationLabel,
|
|
40
|
+
releasedWall ? labels.mechanism!.releasedWall : '',
|
|
41
|
+
lit ? labels.litCell : labels.darkCell,
|
|
42
|
+
]
|
|
43
|
+
.filter(Boolean)
|
|
44
|
+
.join(' — ');
|
|
45
|
+
return {
|
|
46
|
+
disabled,
|
|
47
|
+
target,
|
|
48
|
+
lit,
|
|
49
|
+
activator,
|
|
50
|
+
consumed,
|
|
51
|
+
removableWall,
|
|
52
|
+
releasedWall,
|
|
53
|
+
label,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LightMazeAction,
|
|
3
|
+
LightMazeDirection,
|
|
4
|
+
LightMazeMovement,
|
|
5
|
+
LightMazeMechanism,
|
|
6
|
+
LightMazePosition,
|
|
7
|
+
LightMazePuzzleModel,
|
|
8
|
+
LightMazePuzzleState,
|
|
9
|
+
} from './contracts';
|
|
10
|
+
|
|
11
|
+
const deltas = {
|
|
12
|
+
north: [-1, 0],
|
|
13
|
+
east: [0, 1],
|
|
14
|
+
south: [1, 0],
|
|
15
|
+
west: [0, -1],
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
function same(a: LightMazePosition, b: LightMazePosition): boolean {
|
|
19
|
+
return a.row === b.row && a.column === b.column;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function free(
|
|
23
|
+
puzzle: LightMazePuzzleModel,
|
|
24
|
+
position: LightMazePosition,
|
|
25
|
+
blockedCells: readonly LightMazePosition[],
|
|
26
|
+
): boolean {
|
|
27
|
+
return (
|
|
28
|
+
position.row >= 0 &&
|
|
29
|
+
position.row < puzzle.rows &&
|
|
30
|
+
position.column >= 0 &&
|
|
31
|
+
position.column < puzzle.columns &&
|
|
32
|
+
!blockedCells.some((cell) => same(cell, position))
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createLightMazePuzzleState(
|
|
37
|
+
puzzle: LightMazePuzzleModel,
|
|
38
|
+
): LightMazePuzzleState {
|
|
39
|
+
return {
|
|
40
|
+
walker: { ...puzzle.start },
|
|
41
|
+
light: { ...puzzle.light },
|
|
42
|
+
activated: [],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function blockedCells(
|
|
47
|
+
puzzle: LightMazePuzzleModel,
|
|
48
|
+
state: LightMazePuzzleState,
|
|
49
|
+
): LightMazePosition[] {
|
|
50
|
+
return [
|
|
51
|
+
...puzzle.blockedCells,
|
|
52
|
+
...(puzzle.activators ?? []).flatMap((activator, index) =>
|
|
53
|
+
state.activated.includes(index) ? [] : activator.walls,
|
|
54
|
+
),
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isLightMazePuzzleComplete(
|
|
59
|
+
puzzle: LightMazePuzzleModel,
|
|
60
|
+
state: LightMazePuzzleState,
|
|
61
|
+
): boolean {
|
|
62
|
+
return same(state.walker, puzzle.target);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function getLightMazeIllumination(
|
|
66
|
+
puzzle: LightMazePuzzleModel,
|
|
67
|
+
state: LightMazePuzzleState,
|
|
68
|
+
): LightMazePosition[] {
|
|
69
|
+
const cells = [{ ...state.light }];
|
|
70
|
+
const blocked = blockedCells(puzzle, state);
|
|
71
|
+
for (const [dr, dc] of Object.values(deltas)) {
|
|
72
|
+
for (let distance = 1; distance <= 2; distance += 1) {
|
|
73
|
+
const cell = {
|
|
74
|
+
row: state.light.row + dr * distance,
|
|
75
|
+
column: state.light.column + dc * distance,
|
|
76
|
+
};
|
|
77
|
+
if (!free(puzzle, cell, blocked)) break;
|
|
78
|
+
cells.push(cell);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return cells;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function evaluateLightMazeMechanism(
|
|
85
|
+
puzzle: LightMazePuzzleModel,
|
|
86
|
+
state: LightMazePuzzleState,
|
|
87
|
+
): LightMazeMechanism {
|
|
88
|
+
const illuminatedCells = getLightMazeIllumination(puzzle, state);
|
|
89
|
+
return {
|
|
90
|
+
illuminatedCells,
|
|
91
|
+
blockedCells: blockedCells(puzzle, state),
|
|
92
|
+
activatedCells: (puzzle.activators ?? []).flatMap((activator, index) =>
|
|
93
|
+
state.activated.includes(index) ? [activator.position] : [],
|
|
94
|
+
),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function simulateLightMazeMove(
|
|
99
|
+
puzzle: LightMazePuzzleModel,
|
|
100
|
+
state: LightMazePuzzleState,
|
|
101
|
+
action: LightMazeAction,
|
|
102
|
+
): LightMazeMovement | null {
|
|
103
|
+
if (isLightMazePuzzleComplete(puzzle, state)) return null;
|
|
104
|
+
const mechanism = evaluateLightMazeMechanism(puzzle, state);
|
|
105
|
+
const traversable = (position: LightMazePosition): boolean =>
|
|
106
|
+
free(puzzle, position, mechanism.blockedCells);
|
|
107
|
+
const [dr, dc] = deltas[action.direction];
|
|
108
|
+
const from = state[action.piece];
|
|
109
|
+
let to = { ...from };
|
|
110
|
+
let stoppedByWalker = false;
|
|
111
|
+
const crossed: LightMazePosition[] = [];
|
|
112
|
+
if (action.piece === 'walker') {
|
|
113
|
+
to = { row: from.row + dr, column: from.column + dc };
|
|
114
|
+
if (
|
|
115
|
+
!traversable(to) ||
|
|
116
|
+
same(to, state.light) ||
|
|
117
|
+
puzzle.activators?.some((activator) => same(activator.position, to)) ||
|
|
118
|
+
!mechanism.illuminatedCells.some((cell) => same(cell, to))
|
|
119
|
+
)
|
|
120
|
+
return null;
|
|
121
|
+
} else {
|
|
122
|
+
while (true) {
|
|
123
|
+
const next = { row: to.row + dr, column: to.column + dc };
|
|
124
|
+
if (!traversable(next)) break;
|
|
125
|
+
if (same(next, state.walker)) {
|
|
126
|
+
stoppedByWalker = true;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
to = next;
|
|
130
|
+
crossed.push(next);
|
|
131
|
+
}
|
|
132
|
+
if (same(from, to)) return null;
|
|
133
|
+
}
|
|
134
|
+
const activated = (puzzle.activators ?? []).flatMap((activator, index) =>
|
|
135
|
+
state.activated.includes(index) ||
|
|
136
|
+
crossed.some((position) => same(activator.position, position))
|
|
137
|
+
? [index]
|
|
138
|
+
: [],
|
|
139
|
+
);
|
|
140
|
+
return {
|
|
141
|
+
...action,
|
|
142
|
+
from: { ...from },
|
|
143
|
+
to,
|
|
144
|
+
stoppedByWalker,
|
|
145
|
+
state: {
|
|
146
|
+
walker: { ...state.walker },
|
|
147
|
+
light: { ...state.light },
|
|
148
|
+
[action.piece]: to,
|
|
149
|
+
activated,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function getLightMazeMoves(
|
|
155
|
+
puzzle: LightMazePuzzleModel,
|
|
156
|
+
state: LightMazePuzzleState,
|
|
157
|
+
): LightMazeMovement[] {
|
|
158
|
+
return (['walker', 'light'] as const).flatMap((piece) =>
|
|
159
|
+
(Object.keys(deltas) as LightMazeDirection[]).flatMap((direction) => {
|
|
160
|
+
const move = simulateLightMazeMove(puzzle, state, { piece, direction });
|
|
161
|
+
return move ? [move] : [];
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
@@ -98,12 +98,16 @@ const {
|
|
|
98
98
|
);
|
|
99
99
|
const target =
|
|
100
100
|
puzzle.target.row === row && puzzle.target.column === column;
|
|
101
|
-
const hole = puzzle.holes.
|
|
101
|
+
const hole = puzzle.holes.find(
|
|
102
102
|
({ position }) =>
|
|
103
103
|
position.row === row && position.column === column,
|
|
104
104
|
);
|
|
105
|
+
const exit = puzzle.holes.find(
|
|
106
|
+
({ exit }) => exit.row === row && exit.column === column,
|
|
107
|
+
);
|
|
108
|
+
const transferTone = (hole ?? exit)?.tone ?? 'neutral';
|
|
105
109
|
const fragile =
|
|
106
|
-
hole ||
|
|
110
|
+
Boolean(hole) ||
|
|
107
111
|
puzzle.fragileCells.some(
|
|
108
112
|
(cell) => cell.row === row && cell.column === column,
|
|
109
113
|
);
|
|
@@ -130,7 +134,7 @@ const {
|
|
|
130
134
|
data-cell-row={row}
|
|
131
135
|
data-cell-column={column}
|
|
132
136
|
data-cell-label={cellLabel}
|
|
133
|
-
style={`--cell-row: ${row + 1}; --cell-column: ${column + 1};`}
|
|
137
|
+
style={`--cell-row: ${row + 1}; --cell-column: ${column + 1}; --sliding-transfer-color: var(--nox-source-tone-${transferTone});`}
|
|
134
138
|
>
|
|
135
139
|
{fragile && (
|
|
136
140
|
<svg
|
|
@@ -32,6 +32,12 @@
|
|
|
32
32
|
appearance: none;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
.sliding-puzzle__cell[data-sliding-exit-discovered] {
|
|
36
|
+
--puzzle-cell-base-border: var(--sliding-transfer-color);
|
|
37
|
+
|
|
38
|
+
border-style: dashed;
|
|
39
|
+
}
|
|
40
|
+
|
|
35
41
|
.sliding-puzzle__cell[data-sliding-target] {
|
|
36
42
|
--puzzle-cell-base-border: var(--nox-source-tone-success);
|
|
37
43
|
|
|
@@ -41,7 +47,7 @@
|
|
|
41
47
|
.sliding-puzzle__cell[data-sliding-fragile]:not([data-sliding-collapsed]):not(
|
|
42
48
|
[data-sliding-hole-open]
|
|
43
49
|
) {
|
|
44
|
-
--puzzle-cell-base-border: var(--nox-source-tone-
|
|
50
|
+
--puzzle-cell-base-border: var(--nox-source-tone-neutral);
|
|
45
51
|
|
|
46
52
|
border-style: dashed;
|
|
47
53
|
}
|
|
@@ -52,20 +58,20 @@
|
|
|
52
58
|
inline-size: 100%;
|
|
53
59
|
block-size: 100%;
|
|
54
60
|
fill: none;
|
|
55
|
-
stroke: var(--nox-source-tone-
|
|
61
|
+
stroke: var(--nox-source-tone-neutral);
|
|
56
62
|
stroke-width: 3;
|
|
57
63
|
pointer-events: none;
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
.sliding-puzzle__cell[data-sliding-collapsed] .sliding-puzzle__crack {
|
|
61
|
-
|
|
67
|
+
display: none;
|
|
62
68
|
}
|
|
63
69
|
|
|
64
70
|
.sliding-puzzle__hole {
|
|
65
71
|
display: none;
|
|
66
72
|
inline-size: 72%;
|
|
67
73
|
aspect-ratio: 1;
|
|
68
|
-
border: calc(var(--nox-border-width) * 2) solid var(--
|
|
74
|
+
border: calc(var(--nox-border-width) * 2) solid var(--sliding-transfer-color);
|
|
69
75
|
border-radius: var(--nox-radius-pill);
|
|
70
76
|
background: color-mix(
|
|
71
77
|
in srgb,
|
|
@@ -128,8 +134,10 @@
|
|
|
128
134
|
outline: none;
|
|
129
135
|
}
|
|
130
136
|
|
|
131
|
-
.sliding-puzzle__piece[data-piece-selected]
|
|
132
|
-
|
|
137
|
+
.sliding-puzzle__piece:is([data-piece-selected], :focus-visible):not(
|
|
138
|
+
[data-piece-transferring]
|
|
139
|
+
)
|
|
140
|
+
.sliding-puzzle__piece-media {
|
|
133
141
|
filter: var(--piece-selection-filter);
|
|
134
142
|
}
|
|
135
143
|
|
|
@@ -138,9 +146,10 @@
|
|
|
138
146
|
filter: var(--piece-hover-filter);
|
|
139
147
|
}
|
|
140
148
|
|
|
141
|
-
.sliding-puzzle__piece[data-piece-selected]:hover
|
|
142
|
-
|
|
143
|
-
|
|
149
|
+
.sliding-puzzle__piece:is([data-piece-selected], :focus-visible):hover:not(
|
|
150
|
+
[data-piece-transferring]
|
|
151
|
+
)
|
|
152
|
+
.sliding-puzzle__piece-media {
|
|
144
153
|
filter: var(--piece-hover-filter) var(--piece-selection-filter);
|
|
145
154
|
}
|
|
146
155
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DisplayMedia } from '../../display/contracts';
|
|
2
|
+
import type { Tone } from '../../theme/contracts';
|
|
2
3
|
|
|
3
4
|
export type SlidingDirection = 'north' | 'east' | 'south' | 'west';
|
|
4
5
|
export type SlidingPosition = { row: number; column: number };
|
|
@@ -15,7 +16,11 @@ export type SlidingPuzzleModel = {
|
|
|
15
16
|
fragileCells: readonly SlidingPosition[];
|
|
16
17
|
// Entry direction selects the adjacent landing cell around each exit.
|
|
17
18
|
// A transfer ends the move; its landing never triggers another tile.
|
|
18
|
-
holes: readonly {
|
|
19
|
+
holes: readonly {
|
|
20
|
+
position: SlidingPosition;
|
|
21
|
+
exit: SlidingPosition;
|
|
22
|
+
tone?: Tone;
|
|
23
|
+
}[];
|
|
19
24
|
};
|
|
20
25
|
|
|
21
26
|
export type SlidingPuzzleState = {
|
|
@@ -353,9 +353,6 @@ async function move(
|
|
|
353
353
|
instance.selected =
|
|
354
354
|
retainSelection && !isSlidingPuzzleComplete(puzzle, movement.state);
|
|
355
355
|
render(root, false, movement.slideTo);
|
|
356
|
-
// Ordinary slides expose the next moves immediately, like the magnetic
|
|
357
|
-
// puzzle. A transfer keeps the current preview until the piece reappears,
|
|
358
|
-
// so outgoing paths do not disclose its hidden landing position early.
|
|
359
356
|
if (!movement.teleported || !instance.selected) previews(root);
|
|
360
357
|
try {
|
|
361
358
|
if (
|
|
@@ -371,14 +368,22 @@ async function move(
|
|
|
371
368
|
)
|
|
372
369
|
return;
|
|
373
370
|
if (movement.teleported) {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
371
|
+
clearPreviews(panel);
|
|
372
|
+
if (
|
|
373
|
+
!(await animateHoleTransfer(
|
|
374
|
+
root,
|
|
375
|
+
piece,
|
|
376
|
+
puzzle,
|
|
377
|
+
movement,
|
|
378
|
+
motion.signal,
|
|
379
|
+
))
|
|
380
|
+
)
|
|
381
|
+
return;
|
|
378
382
|
}
|
|
379
383
|
if (motion.signal.aborted) return;
|
|
380
384
|
instance.motion = null;
|
|
381
385
|
render(root);
|
|
386
|
+
if (movement.teleported) previews(root);
|
|
382
387
|
emitChange(root);
|
|
383
388
|
} finally {
|
|
384
389
|
motion.signal.removeEventListener('abort', clearBusy);
|
|
@@ -387,6 +392,51 @@ async function move(
|
|
|
387
392
|
}
|
|
388
393
|
}
|
|
389
394
|
|
|
395
|
+
async function animateHoleTransfer(
|
|
396
|
+
root: HTMLElement,
|
|
397
|
+
piece: HTMLButtonElement,
|
|
398
|
+
puzzle: SlidingPuzzleModel,
|
|
399
|
+
movement: SlidingMovement,
|
|
400
|
+
signal: AbortSignal,
|
|
401
|
+
): Promise<boolean> {
|
|
402
|
+
if (signal.aborted) return false;
|
|
403
|
+
const clear = (): void => piece.removeAttribute('data-piece-transferring');
|
|
404
|
+
piece.setAttribute('data-piece-transferring', '');
|
|
405
|
+
signal.addEventListener('abort', clear, { once: true });
|
|
406
|
+
try {
|
|
407
|
+
if (!(await animateTransferPhase(piece, 'fall', signal))) return false;
|
|
408
|
+
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches)
|
|
409
|
+
return true;
|
|
410
|
+
const hole = puzzle.holes.find(
|
|
411
|
+
({ position }) =>
|
|
412
|
+
position.row === movement.slideTo.row &&
|
|
413
|
+
position.column === movement.slideTo.column,
|
|
414
|
+
)!;
|
|
415
|
+
render(root, false, hole.exit);
|
|
416
|
+
const panel = activePanel(root);
|
|
417
|
+
if (panel) renderExitMarkers(panel, movement.state);
|
|
418
|
+
if (!(await animateTransferPhase(piece, 'arrive', signal))) return false;
|
|
419
|
+
if (!(await animateLanding(piece, signal))) return false;
|
|
420
|
+
const exitBounds = piece.getBoundingClientRect();
|
|
421
|
+
render(root, false);
|
|
422
|
+
const landingBounds = piece.getBoundingClientRect();
|
|
423
|
+
return await animatePieceTransition(
|
|
424
|
+
piece,
|
|
425
|
+
[
|
|
426
|
+
{
|
|
427
|
+
transform: `translate3d(${exitBounds.left - landingBounds.left}px, ${exitBounds.top - landingBounds.top}px, 0)`,
|
|
428
|
+
},
|
|
429
|
+
{ transform: 'translate3d(0, 0, 0)' },
|
|
430
|
+
],
|
|
431
|
+
120,
|
|
432
|
+
signal,
|
|
433
|
+
);
|
|
434
|
+
} finally {
|
|
435
|
+
signal.removeEventListener('abort', clear);
|
|
436
|
+
clear();
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
390
440
|
function animateSlide(
|
|
391
441
|
panel: HTMLElement,
|
|
392
442
|
piece: HTMLButtonElement,
|
|
@@ -398,8 +448,6 @@ function animateSlide(
|
|
|
398
448
|
): Promise<boolean> {
|
|
399
449
|
const finalBounds = piece.getBoundingClientRect();
|
|
400
450
|
animatePieceMovement(piece, bounds);
|
|
401
|
-
// Follow the actual slide so each tile changes as the piece enters it,
|
|
402
|
-
// including the tile where it stops. No delayed collapse on departure.
|
|
403
451
|
const horizontal = movement.from.column !== movement.slideTo.column;
|
|
404
452
|
const start = horizontal ? bounds.left : bounds.top;
|
|
405
453
|
const travel = (horizontal ? finalBounds.left : finalBounds.top) - start;
|
|
@@ -473,24 +521,57 @@ function animateSlide(
|
|
|
473
521
|
});
|
|
474
522
|
}
|
|
475
523
|
|
|
476
|
-
function
|
|
524
|
+
function animateTransferPhase(
|
|
477
525
|
piece: HTMLButtonElement,
|
|
478
526
|
phase: 'fall' | 'arrive',
|
|
479
527
|
signal: AbortSignal,
|
|
480
528
|
): Promise<boolean> {
|
|
481
|
-
if (signal.aborted) return Promise.resolve(false);
|
|
482
|
-
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches)
|
|
483
|
-
return Promise.resolve(true);
|
|
484
529
|
const hidden = { opacity: 0, transform: 'scale(0.15)' };
|
|
485
530
|
const visible = { opacity: 1, transform: 'scale(1)' };
|
|
486
|
-
|
|
531
|
+
return animatePieceTransition(
|
|
532
|
+
piece,
|
|
487
533
|
phase === 'fall' ? [visible, hidden] : [hidden, visible],
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
534
|
+
160,
|
|
535
|
+
signal,
|
|
536
|
+
phase === 'fall' ? 'ease-out' : 'ease-in',
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function animateLanding(
|
|
541
|
+
piece: HTMLButtonElement,
|
|
542
|
+
signal: AbortSignal,
|
|
543
|
+
): Promise<boolean> {
|
|
544
|
+
return animatePieceTransition(
|
|
545
|
+
piece,
|
|
546
|
+
[
|
|
547
|
+
{ transform: 'scale(1)', transformOrigin: '50% 90%' },
|
|
548
|
+
{
|
|
549
|
+
transform: 'scale(1.04, 0.94)',
|
|
550
|
+
transformOrigin: '50% 90%',
|
|
551
|
+
offset: 0.35,
|
|
552
|
+
},
|
|
553
|
+
{ transform: 'scale(1)', transformOrigin: '50% 90%' },
|
|
554
|
+
],
|
|
555
|
+
150,
|
|
556
|
+
signal,
|
|
493
557
|
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function animatePieceTransition(
|
|
561
|
+
piece: HTMLButtonElement,
|
|
562
|
+
keyframes: Keyframe[],
|
|
563
|
+
duration: number,
|
|
564
|
+
signal: AbortSignal,
|
|
565
|
+
easing: 'ease-out' | 'ease-in' = 'ease-out',
|
|
566
|
+
): Promise<boolean> {
|
|
567
|
+
if (signal.aborted) return Promise.resolve(false);
|
|
568
|
+
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches)
|
|
569
|
+
return Promise.resolve(true);
|
|
570
|
+
const animation = piece.animate(keyframes, {
|
|
571
|
+
duration,
|
|
572
|
+
easing,
|
|
573
|
+
fill: 'forwards',
|
|
574
|
+
});
|
|
494
575
|
const cancel = (): void => animation.cancel();
|
|
495
576
|
signal.addEventListener('abort', cancel, { once: true });
|
|
496
577
|
return animation.finished
|
|
@@ -523,7 +604,10 @@ function render(
|
|
|
523
604
|
'--piece-column',
|
|
524
605
|
String((position ?? instance.state.position).column + 1),
|
|
525
606
|
);
|
|
526
|
-
if (updateTerrain)
|
|
607
|
+
if (updateTerrain) {
|
|
608
|
+
renderTerrain(panel, instance.state);
|
|
609
|
+
renderExitMarkers(panel, instance.state);
|
|
610
|
+
}
|
|
527
611
|
const target = panel.querySelector<HTMLElement>('[data-sliding-target]');
|
|
528
612
|
if (target && updateTerrain)
|
|
529
613
|
setPuzzleGridCellState(
|
|
@@ -532,11 +616,38 @@ function render(
|
|
|
532
616
|
);
|
|
533
617
|
}
|
|
534
618
|
|
|
619
|
+
function renderExitMarkers(
|
|
620
|
+
panel: HTMLElement,
|
|
621
|
+
state: SlidingPuzzleState,
|
|
622
|
+
): void {
|
|
623
|
+
const holes = model(panel).holes;
|
|
624
|
+
const discoveredExits = holes
|
|
625
|
+
.filter((hole) =>
|
|
626
|
+
state.openHoles.some(
|
|
627
|
+
(position) =>
|
|
628
|
+
position.row === hole.position.row &&
|
|
629
|
+
position.column === hole.position.column,
|
|
630
|
+
),
|
|
631
|
+
)
|
|
632
|
+
.map((hole) => hole.exit);
|
|
633
|
+
for (const { exit } of holes) {
|
|
634
|
+
const discovered = discoveredExits.some(
|
|
635
|
+
(position) =>
|
|
636
|
+
position.row === exit.row && position.column === exit.column,
|
|
637
|
+
);
|
|
638
|
+
cell(panel, exit)?.toggleAttribute(
|
|
639
|
+
'data-sliding-exit-discovered',
|
|
640
|
+
discovered,
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
535
645
|
function renderTerrain(panel: HTMLElement, state: SlidingPuzzleState): void {
|
|
646
|
+
const puzzle = model(panel);
|
|
536
647
|
const labels = JSON.parse(
|
|
537
648
|
panel.dataset.slidingLabels ?? '{}',
|
|
538
649
|
) as SlidingPuzzleLabels;
|
|
539
|
-
for (const fragile of
|
|
650
|
+
for (const fragile of puzzle.fragileCells) {
|
|
540
651
|
const element = cell(panel, fragile);
|
|
541
652
|
if (!element) continue;
|
|
542
653
|
const collapsed = state.collapsedCells.some(
|
|
@@ -552,7 +663,7 @@ function renderTerrain(panel: HTMLElement, state: SlidingPuzzleState): void {
|
|
|
552
663
|
if (!element.hasAttribute('data-sliding-destination'))
|
|
553
664
|
element.setAttribute('aria-label', element.dataset.cellLabel);
|
|
554
665
|
}
|
|
555
|
-
for (const hole of
|
|
666
|
+
for (const hole of puzzle.holes) {
|
|
556
667
|
const element = cell(panel, hole.position);
|
|
557
668
|
if (!element) continue;
|
|
558
669
|
const open = state.openHoles.some(
|
|
@@ -580,19 +691,7 @@ function previews(root: HTMLElement): void {
|
|
|
580
691
|
const labels = JSON.parse(
|
|
581
692
|
panel.dataset.slidingLabels ?? '{}',
|
|
582
693
|
) as SlidingPuzzleLabels;
|
|
583
|
-
|
|
584
|
-
// through path lengths, destination markers or accessible move labels.
|
|
585
|
-
const puzzle = model(panel);
|
|
586
|
-
const previewPuzzle = {
|
|
587
|
-
...puzzle,
|
|
588
|
-
holes: puzzle.holes.filter((hole) =>
|
|
589
|
-
instance.state.openHoles.some(
|
|
590
|
-
(position) =>
|
|
591
|
-
position.row === hole.position.row &&
|
|
592
|
-
position.column === hole.position.column,
|
|
593
|
-
),
|
|
594
|
-
),
|
|
595
|
-
};
|
|
694
|
+
const previewPuzzle = { ...model(panel), holes: [] };
|
|
596
695
|
for (const movement of getSlidingMoves(previewPuzzle, instance.state)) {
|
|
597
696
|
if (board) renderPathStream(board, { ...movement, to: movement.slideTo });
|
|
598
697
|
const destination = cell(panel, movement.slideTo);
|
package/src/patterns/index.ts
CHANGED
|
@@ -11,6 +11,10 @@ export { default as ModalClose } from './Modal/ModalClose.astro';
|
|
|
11
11
|
export { default as ModalOpen } from './Modal/ModalOpen.astro';
|
|
12
12
|
export { default as PuzzleGrid } from './PuzzleGrid/PuzzleGrid.astro';
|
|
13
13
|
export { default as SlidingPuzzle } from './SlidingPuzzle/SlidingPuzzle.astro';
|
|
14
|
+
export { default as LightMazePuzzle } from './LightMazePuzzle/LightMazePuzzle.astro';
|
|
15
|
+
export * from './LightMazePuzzle/contracts';
|
|
16
|
+
export * from './LightMazePuzzle/simulation';
|
|
17
|
+
export * from './LightMazePuzzle/lightMazePuzzleController';
|
|
14
18
|
export * from './SlidingPuzzle/contracts';
|
|
15
19
|
export * from './SlidingPuzzle/simulation';
|
|
16
20
|
export * from './SlidingPuzzle/slidingPuzzleController';
|
package/src/styles/style.css
CHANGED
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
@import '../patterns/PuzzleGrid/PuzzleGrid.css' layer(patterns);
|
|
66
66
|
@import '../patterns/PuzzleGrid/movement.css' layer(patterns);
|
|
67
67
|
@import '../patterns/SlidingPuzzle/SlidingPuzzle.css' layer(patterns);
|
|
68
|
+
@import '../patterns/LightMazePuzzle/LightMazePuzzle.css' layer(patterns);
|
|
68
69
|
@import '../patterns/CircuitPuzzle/CircuitPuzzle.css' layer(patterns);
|
|
69
70
|
@import '../patterns/Combobox/Combobox.css' layer(patterns);
|
|
70
71
|
@import '../patterns/Dropdown/Dropdown.css' layer(patterns);
|