@astronox/core 0.5.4 → 0.5.6

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.
Files changed (29) hide show
  1. package/README.md +13 -0
  2. package/package.json +1 -1
  3. package/src/components/content/AnnotatedText/AnnotatedText.astro +38 -0
  4. package/src/components/content/AnnotatedText/AnnotatedText.css +19 -0
  5. package/src/components/content/AnnotatedText/MarkedText.astro +11 -0
  6. package/src/components/content/InlineDefinition/InlineDefinition.astro +11 -2
  7. package/src/components/data-display/TextPair/TextPair.astro +13 -2
  8. package/src/components/feedback/Tooltip/Tooltip.astro +18 -3
  9. package/src/components/feedback/Tooltip/Tooltip.css +9 -0
  10. package/src/components/index.ts +1 -0
  11. package/src/display/annotatedText.ts +14 -0
  12. package/src/display/contracts.ts +4 -3
  13. package/src/display/index.ts +1 -0
  14. package/src/patterns/MagneticFieldPuzzle/MagneticFieldPuzzle.css +0 -79
  15. package/src/patterns/MagneticFieldPuzzle/magneticFieldPuzzleController.ts +14 -148
  16. package/src/patterns/PuzzleGrid/movement.css +80 -0
  17. package/src/patterns/PuzzleGrid/movement.ts +149 -0
  18. package/src/patterns/PuzzleGrid/selectionDismissal.ts +4 -2
  19. package/src/patterns/SlidingPuzzle/SlidingPuzzle.astro +181 -0
  20. package/src/patterns/SlidingPuzzle/SlidingPuzzle.css +177 -0
  21. package/src/patterns/SlidingPuzzle/contracts.ts +57 -0
  22. package/src/patterns/SlidingPuzzle/simulation.ts +106 -0
  23. package/src/patterns/SlidingPuzzle/slidingPuzzleController.ts +703 -0
  24. package/src/patterns/Table/TableBody.astro +7 -1
  25. package/src/patterns/Table/TableCell.astro +3 -1
  26. package/src/patterns/Table/TableCellValue.astro +10 -1
  27. package/src/patterns/index.ts +4 -0
  28. package/src/primitives/text/TextBlock/TextBlock.astro +54 -4
  29. package/src/styles/style.css +4 -0
@@ -0,0 +1,149 @@
1
+ export type PuzzleDrag = {
2
+ pointerId: number;
3
+ pieceId: string;
4
+ startX: number;
5
+ startY: number;
6
+ currentX: number;
7
+ currentY: number;
8
+ frameId: number | null;
9
+ element: HTMLButtonElement;
10
+ originCellBounds: DOMRect;
11
+ originCenterX: number;
12
+ originCenterY: number;
13
+ destinations: readonly HTMLButtonElement[] | null;
14
+ moved: boolean;
15
+ };
16
+
17
+ type Direction = 'north' | 'east' | 'south' | 'west';
18
+
19
+ const pathChevronsPerCell = 3;
20
+ const pathDurationPerCellMs = 2100;
21
+
22
+ export function animatePieceMovement(
23
+ element: HTMLButtonElement,
24
+ initialBounds: DOMRect,
25
+ ): void {
26
+ const finalBounds = element.getBoundingClientRect();
27
+ element.style.setProperty(
28
+ '--piece-move-inline-distance',
29
+ `${initialBounds.left - finalBounds.left}px`,
30
+ );
31
+ element.style.setProperty(
32
+ '--piece-move-block-distance',
33
+ `${initialBounds.top - finalBounds.top}px`,
34
+ );
35
+ element.setAttribute('data-piece-moved', '');
36
+ }
37
+
38
+ export function hasLeftOriginCell(
39
+ drag: PuzzleDrag,
40
+ deltaX: number,
41
+ deltaY: number,
42
+ ): boolean {
43
+ const centerX = drag.originCenterX + deltaX;
44
+ const centerY = drag.originCenterY + deltaY;
45
+ const { bottom, left, right, top } = drag.originCellBounds;
46
+
47
+ return centerX < left || centerX > right || centerY < top || centerY > bottom;
48
+ }
49
+
50
+ export function scheduleDragFrame(
51
+ drag: PuzzleDrag,
52
+ isCurrent: () => boolean,
53
+ ): void {
54
+ if (drag.frameId !== null) return;
55
+ drag.frameId = window.requestAnimationFrame(() => {
56
+ drag.frameId = null;
57
+ if (!isCurrent()) return;
58
+
59
+ drag.element.style.setProperty(
60
+ '--piece-drag-x',
61
+ `${drag.currentX - drag.startX}px`,
62
+ );
63
+ drag.element.style.setProperty(
64
+ '--piece-drag-y',
65
+ `${drag.currentY - drag.startY}px`,
66
+ );
67
+ });
68
+ }
69
+
70
+ export function cancelDragFrame(drag: PuzzleDrag): void {
71
+ if (drag.frameId === null) return;
72
+ window.cancelAnimationFrame(drag.frameId);
73
+ drag.frameId = null;
74
+ }
75
+
76
+ export function findDirectionalDropDestination(
77
+ drag: PuzzleDrag,
78
+ deltaX: number,
79
+ deltaY: number,
80
+ ): HTMLButtonElement | null {
81
+ let direction: Direction;
82
+ if (Math.abs(deltaX) >= Math.abs(deltaY)) {
83
+ direction = deltaX >= 0 ? 'east' : 'west';
84
+ } else {
85
+ direction = deltaY >= 0 ? 'south' : 'north';
86
+ }
87
+
88
+ return (
89
+ (drag.destinations ?? []).find(
90
+ (destination) =>
91
+ destination.dataset.destinationDirection === direction &&
92
+ destination.dataset.destinationPiece === drag.pieceId,
93
+ ) ?? null
94
+ );
95
+ }
96
+
97
+ export function renderPathStream(
98
+ board: HTMLElement,
99
+ movement: {
100
+ from: { row: number; column: number };
101
+ to: { row: number; column: number };
102
+ direction: Direction;
103
+ },
104
+ ): void {
105
+ const rowDistance = movement.to.row - movement.from.row;
106
+ const columnDistance = movement.to.column - movement.from.column;
107
+ const cellDistance = Math.abs(rowDistance) + Math.abs(columnDistance);
108
+ const chevronCount = pathChevronsPerCell * cellDistance;
109
+ const stream = document.createElement('span');
110
+ stream.className = 'puzzle-grid__path-stream';
111
+ stream.dataset.puzzlePathStream = '';
112
+ stream.dataset.puzzlePathDirection = movement.direction;
113
+ stream.setAttribute('aria-hidden', 'true');
114
+ stream.style.setProperty('--puzzle-path-row', String(movement.from.row + 1));
115
+ stream.style.setProperty(
116
+ '--puzzle-path-column',
117
+ String(movement.from.column + 1),
118
+ );
119
+ stream.style.setProperty(
120
+ '--puzzle-path-target-inline',
121
+ `${50 + columnDistance * 100}%`,
122
+ );
123
+ stream.style.setProperty(
124
+ '--puzzle-path-target-block',
125
+ `${50 + rowDistance * 100}%`,
126
+ );
127
+ stream.style.setProperty(
128
+ '--puzzle-path-duration',
129
+ `${pathDurationPerCellMs * cellDistance}ms`,
130
+ );
131
+
132
+ for (let index = 0; index < chevronCount; index += 1) {
133
+ const progress = (index + 0.5) / chevronCount;
134
+ const chevron = document.createElement('span');
135
+ chevron.className = 'puzzle-grid__path-chevron';
136
+ chevron.style.setProperty('--puzzle-path-chevron-index', String(index));
137
+ chevron.style.setProperty(
138
+ '--puzzle-path-static-inline',
139
+ `${50 + columnDistance * progress * 100}%`,
140
+ );
141
+ chevron.style.setProperty(
142
+ '--puzzle-path-static-block',
143
+ `${50 + rowDistance * progress * 100}%`,
144
+ );
145
+ stream.append(chevron);
146
+ }
147
+
148
+ board.append(stream);
149
+ }
@@ -6,7 +6,8 @@ type PuzzleSelectionDismissalOptions = {
6
6
  export function initPuzzleSelectionDismissal(
7
7
  root: HTMLElement,
8
8
  { dismiss, isSelectionTarget }: PuzzleSelectionDismissalOptions,
9
- ): void {
9
+ ): () => void {
10
+ const controller = new AbortController();
10
11
  document.addEventListener(
11
12
  'pointerdown',
12
13
  (event) => {
@@ -22,6 +23,7 @@ export function initPuzzleSelectionDismissal(
22
23
  focused.blur();
23
24
  }
24
25
  },
25
- true,
26
+ { capture: true, signal: controller.signal },
26
27
  );
28
+ return () => controller.abort();
27
29
  }
@@ -0,0 +1,181 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types';
3
+ import Media from '../../primitives/media/Media/Media.astro';
4
+ import PuzzleGrid from '../PuzzleGrid/PuzzleGrid.astro';
5
+ import type {
6
+ SlidingPuzzleLabels,
7
+ SlidingPuzzleMedia,
8
+ SlidingPuzzleModel,
9
+ } from './contracts';
10
+
11
+ type Props = HTMLAttributes<'section'> & {
12
+ label: string;
13
+ labels: SlidingPuzzleLabels;
14
+ media: SlidingPuzzleMedia;
15
+ puzzles: readonly SlidingPuzzleModel[];
16
+ };
17
+
18
+ const {
19
+ class: className,
20
+ label,
21
+ labels,
22
+ media,
23
+ puzzles,
24
+ ...attrs
25
+ }: Props = Astro.props;
26
+ ---
27
+
28
+ <section
29
+ {...attrs}
30
+ class:list={['sliding-puzzle', className]}
31
+ aria-label={label}
32
+ data-sliding-puzzle
33
+ >
34
+ {
35
+ puzzles.map((puzzle, index) => (
36
+ <div
37
+ class="sliding-puzzle__instance"
38
+ data-puzzle-id={puzzle.id}
39
+ data-puzzle-status="incomplete"
40
+ data-sliding-model={JSON.stringify(puzzle)}
41
+ data-sliding-labels={JSON.stringify(labels)}
42
+ hidden={index !== 0}
43
+ style={`--piece-selection-filter: url(#sliding-outline-${encodeURIComponent(puzzle.id)}); --piece-hover-filter: url(#sliding-hover-${encodeURIComponent(puzzle.id)});`}
44
+ >
45
+ <svg
46
+ class="sliding-puzzle__filters"
47
+ aria-hidden="true"
48
+ focusable="false"
49
+ >
50
+ <defs>
51
+ <filter
52
+ id={`sliding-hover-${encodeURIComponent(puzzle.id)}`}
53
+ color-interpolation-filters="sRGB"
54
+ >
55
+ <feFlood
56
+ class="sliding-puzzle__hover-color"
57
+ flood-opacity="0.1"
58
+ />
59
+ <feComposite operator="in" in2="SourceAlpha" />
60
+ <feMerge>
61
+ <feMergeNode in="SourceGraphic" />
62
+ <feMergeNode />
63
+ </feMerge>
64
+ </filter>
65
+ <filter
66
+ id={`sliding-outline-${encodeURIComponent(puzzle.id)}`}
67
+ x="-25%"
68
+ y="-25%"
69
+ width="150%"
70
+ height="150%"
71
+ color-interpolation-filters="sRGB"
72
+ >
73
+ {/* Soften the square dilation at silhouette corners. */}
74
+ <feMorphology in="SourceAlpha" operator="dilate" radius="2" />
75
+ <feGaussianBlur stdDeviation="0.5" result="outline" />
76
+ <feFlood class="sliding-puzzle__ring-color" />
77
+ <feComposite operator="in" in2="outline" />
78
+ <feMerge>
79
+ <feMergeNode />
80
+ <feMergeNode in="SourceGraphic" />
81
+ </feMerge>
82
+ </filter>
83
+ </defs>
84
+ </svg>
85
+ <PuzzleGrid
86
+ class="sliding-puzzle__board"
87
+ label={puzzle.label}
88
+ rows={puzzle.rows}
89
+ columns={puzzle.columns}
90
+ >
91
+ {Array.from(
92
+ { length: puzzle.rows * puzzle.columns },
93
+ (_, cellIndex) => {
94
+ const row = Math.floor(cellIndex / puzzle.columns);
95
+ const column = cellIndex % puzzle.columns;
96
+ const blocked = puzzle.blockedCells.some(
97
+ (cell) => cell.row === row && cell.column === column,
98
+ );
99
+ const target =
100
+ puzzle.target.row === row && puzzle.target.column === column;
101
+ const hole = puzzle.holes.some(
102
+ ({ position }) =>
103
+ position.row === row && position.column === column,
104
+ );
105
+ const fragile =
106
+ hole ||
107
+ puzzle.fragileCells.some(
108
+ (cell) => cell.row === row && cell.column === column,
109
+ );
110
+ const cellLabel = blocked
111
+ ? labels.blockedCell
112
+ : target
113
+ ? labels.target
114
+ : fragile
115
+ ? labels.fragileCell
116
+ : '';
117
+ return (
118
+ <button
119
+ class="puzzle-grid__cell sliding-puzzle__cell"
120
+ type="button"
121
+ disabled={blocked}
122
+ tabindex={-1}
123
+ aria-label={cellLabel || undefined}
124
+ data-puzzle-cell
125
+ data-puzzle-cell-disabled={blocked ? '' : undefined}
126
+ data-sliding-cell
127
+ data-sliding-target={target ? '' : undefined}
128
+ data-sliding-fragile={fragile ? '' : undefined}
129
+ data-sliding-hole={hole ? '' : undefined}
130
+ data-cell-row={row}
131
+ data-cell-column={column}
132
+ data-cell-label={cellLabel}
133
+ style={`--cell-row: ${row + 1}; --cell-column: ${column + 1};`}
134
+ >
135
+ {fragile && (
136
+ <svg
137
+ class="sliding-puzzle__crack"
138
+ viewBox="0 0 100 100"
139
+ aria-hidden="true"
140
+ focusable="false"
141
+ >
142
+ <path d="M44 0 54 25 40 46 60 65 48 100M40 46 18 36 0 44M60 65 80 49 100 55M54 25 75 14" />
143
+ </svg>
144
+ )}
145
+ {hole && (
146
+ <span class="sliding-puzzle__hole" aria-hidden="true" />
147
+ )}
148
+ {target && (
149
+ <span class="sliding-puzzle__target" aria-hidden="true">
150
+ <Media media={media.target} size="fill" />
151
+ </span>
152
+ )}
153
+ </button>
154
+ );
155
+ },
156
+ )}
157
+ <button
158
+ class="sliding-puzzle__piece"
159
+ type="button"
160
+ aria-label={labels.piece}
161
+ aria-pressed="false"
162
+ data-sliding-piece
163
+ style={`--piece-row: ${puzzle.start.row + 1}; --piece-column: ${puzzle.start.column + 1};`}
164
+ >
165
+ <span class="sliding-puzzle__piece-media" aria-hidden="true">
166
+ <Media media={media.piece} size="fill" />
167
+ </span>
168
+ </button>
169
+ </PuzzleGrid>
170
+ </div>
171
+ ))
172
+ }
173
+ </section>
174
+
175
+ <script>
176
+ import { initSlidingPuzzle } from './slidingPuzzleController';
177
+
178
+ for (const root of document.querySelectorAll('[data-sliding-puzzle]')) {
179
+ if (root instanceof HTMLElement) initSlidingPuzzle(root);
180
+ }
181
+ </script>
@@ -0,0 +1,177 @@
1
+ .sliding-puzzle,
2
+ .sliding-puzzle__instance {
3
+ display: grid;
4
+ min-inline-size: 0;
5
+ }
6
+
7
+ .sliding-puzzle__instance[hidden] {
8
+ display: none;
9
+ }
10
+
11
+ .sliding-puzzle__filters {
12
+ position: absolute;
13
+ inline-size: 0;
14
+ block-size: 0;
15
+ pointer-events: none;
16
+ }
17
+
18
+ .sliding-puzzle__ring-color {
19
+ flood-color: var(--nox-source-tone-info);
20
+ }
21
+
22
+ .sliding-puzzle__hover-color {
23
+ flood-color: var(--nox-tone-color);
24
+ }
25
+
26
+ .sliding-puzzle__board {
27
+ isolation: isolate;
28
+ touch-action: manipulation;
29
+ }
30
+
31
+ .sliding-puzzle__cell {
32
+ appearance: none;
33
+ }
34
+
35
+ .sliding-puzzle__cell[data-sliding-target] {
36
+ --puzzle-cell-base-border: var(--nox-source-tone-success);
37
+
38
+ border-style: dashed;
39
+ }
40
+
41
+ .sliding-puzzle__cell[data-sliding-fragile]:not([data-sliding-collapsed]):not(
42
+ [data-sliding-hole-open]
43
+ ) {
44
+ --puzzle-cell-base-border: var(--nox-source-tone-info);
45
+
46
+ border-style: dashed;
47
+ }
48
+
49
+ .sliding-puzzle__crack {
50
+ position: absolute;
51
+ inset: 0;
52
+ inline-size: 100%;
53
+ block-size: 100%;
54
+ fill: none;
55
+ stroke: var(--nox-source-tone-info);
56
+ stroke-width: 3;
57
+ pointer-events: none;
58
+ }
59
+
60
+ .sliding-puzzle__cell[data-sliding-collapsed] .sliding-puzzle__crack {
61
+ stroke: var(--nox-text-disabled);
62
+ }
63
+
64
+ .sliding-puzzle__hole {
65
+ display: none;
66
+ inline-size: 72%;
67
+ aspect-ratio: 1;
68
+ border: calc(var(--nox-border-width) * 2) solid var(--nox-source-tone-info);
69
+ border-radius: var(--nox-radius-pill);
70
+ background: color-mix(
71
+ in srgb,
72
+ var(--nox-source-tone-neutral) 20%,
73
+ var(--nox-mix-dark)
74
+ );
75
+ pointer-events: none;
76
+ }
77
+
78
+ .sliding-puzzle__cell[data-sliding-hole-open] .sliding-puzzle__hole {
79
+ display: block;
80
+ }
81
+
82
+ .sliding-puzzle__cell[data-sliding-hole-open] .sliding-puzzle__crack {
83
+ display: none;
84
+ }
85
+
86
+ .sliding-puzzle__cell[data-sliding-destination] {
87
+ --puzzle-cell-ring-width: var(--nox-focus-ring-width);
88
+ --puzzle-cell-ring-color: var(--nox-source-tone-info);
89
+
90
+ z-index: 3;
91
+ overflow: visible;
92
+ }
93
+
94
+ .sliding-puzzle__target {
95
+ display: grid;
96
+ inline-size: 90%;
97
+ aspect-ratio: 1;
98
+ pointer-events: none;
99
+ }
100
+
101
+ .sliding-puzzle__piece {
102
+ position: relative;
103
+ z-index: 4;
104
+ display: grid;
105
+ grid-row: var(--piece-row);
106
+ grid-column: var(--piece-column);
107
+ min-inline-size: 0;
108
+ min-block-size: 0;
109
+ margin: var(--nox-space-1);
110
+ padding: 0;
111
+ place-items: center;
112
+ border: 0;
113
+ border-radius: var(--nox-radius-sm);
114
+ background: transparent;
115
+ cursor: grab;
116
+ touch-action: none;
117
+ user-select: none;
118
+ }
119
+
120
+ .sliding-puzzle__piece-media {
121
+ display: grid;
122
+ inline-size: 80%;
123
+ aspect-ratio: 1;
124
+ pointer-events: none;
125
+ }
126
+
127
+ .sliding-puzzle__piece:focus-visible {
128
+ outline: none;
129
+ }
130
+
131
+ .sliding-puzzle__piece[data-piece-selected] .sliding-puzzle__piece-media,
132
+ .sliding-puzzle__piece:focus-visible .sliding-puzzle__piece-media {
133
+ filter: var(--piece-selection-filter);
134
+ }
135
+
136
+ @media (hover: hover) {
137
+ .sliding-puzzle__piece:hover .sliding-puzzle__piece-media {
138
+ filter: var(--piece-hover-filter);
139
+ }
140
+
141
+ .sliding-puzzle__piece[data-piece-selected]:hover
142
+ .sliding-puzzle__piece-media,
143
+ .sliding-puzzle__piece:focus-visible:hover .sliding-puzzle__piece-media {
144
+ filter: var(--piece-hover-filter) var(--piece-selection-filter);
145
+ }
146
+ }
147
+
148
+ .sliding-puzzle__piece[data-piece-dragging] {
149
+ z-index: 6;
150
+ cursor: grabbing;
151
+ }
152
+
153
+ .sliding-puzzle__piece[data-piece-dragging] .sliding-puzzle__piece-media {
154
+ transform: translate3d(var(--piece-drag-x, 0px), var(--piece-drag-y, 0px), 0)
155
+ scale(1.04);
156
+ will-change: transform;
157
+ }
158
+
159
+ .sliding-puzzle__instance[data-puzzle-status='valid'] .sliding-puzzle__piece {
160
+ cursor: default;
161
+ }
162
+
163
+ @media (prefers-reduced-motion: no-preference) {
164
+ .sliding-puzzle__piece[data-piece-moved] {
165
+ animation: sliding-puzzle-move 260ms var(--nox-motion-easing-standard);
166
+ }
167
+ }
168
+
169
+ @keyframes sliding-puzzle-move {
170
+ from {
171
+ transform: translate3d(
172
+ var(--piece-move-inline-distance),
173
+ var(--piece-move-block-distance),
174
+ 0
175
+ );
176
+ }
177
+ }
@@ -0,0 +1,57 @@
1
+ import type { DisplayMedia } from '../../display/contracts';
2
+
3
+ export type SlidingDirection = 'north' | 'east' | 'south' | 'west';
4
+ export type SlidingPosition = { row: number; column: number };
5
+ export type SlidingPuzzleStatus = 'incomplete' | 'valid';
6
+
7
+ export type SlidingPuzzleModel = {
8
+ id: string;
9
+ label: string;
10
+ rows: number;
11
+ columns: number;
12
+ start: SlidingPosition;
13
+ target: SlidingPosition;
14
+ blockedCells: readonly SlidingPosition[];
15
+ fragileCells: readonly SlidingPosition[];
16
+ // Entry direction selects the adjacent landing cell around each exit.
17
+ // A transfer ends the move; its landing never triggers another tile.
18
+ holes: readonly { position: SlidingPosition; exit: SlidingPosition }[];
19
+ };
20
+
21
+ export type SlidingPuzzleState = {
22
+ position: SlidingPosition;
23
+ collapsedCells: readonly SlidingPosition[];
24
+ // Source tile positions, tracked independently and cleared on reset.
25
+ openHoles: readonly SlidingPosition[];
26
+ };
27
+
28
+ export type SlidingPuzzleLabels = {
29
+ piece: string;
30
+ target: string;
31
+ blockedCell: string;
32
+ fragileCell: string;
33
+ collapsedCell: string;
34
+ hole: string;
35
+ directions: Readonly<Record<SlidingDirection, string>>;
36
+ moveTemplate: string;
37
+ };
38
+
39
+ export type SlidingPuzzleMedia = {
40
+ piece: DisplayMedia;
41
+ target: DisplayMedia;
42
+ };
43
+
44
+ export type SlidingMovement = {
45
+ direction: SlidingDirection;
46
+ from: SlidingPosition;
47
+ slideTo: SlidingPosition;
48
+ to: SlidingPosition;
49
+ teleported: boolean;
50
+ state: SlidingPuzzleState;
51
+ };
52
+
53
+ export type SlidingPuzzleChangeDetail = {
54
+ kind: 'sliding';
55
+ puzzleId: string;
56
+ state: SlidingPuzzleState;
57
+ };
@@ -0,0 +1,106 @@
1
+ import type {
2
+ SlidingDirection,
3
+ SlidingMovement,
4
+ SlidingPuzzleModel,
5
+ SlidingPuzzleState,
6
+ } from './contracts';
7
+
8
+ const deltas = {
9
+ north: [-1, 0],
10
+ east: [0, 1],
11
+ south: [1, 0],
12
+ west: [0, -1],
13
+ } as const;
14
+
15
+ export function createSlidingPuzzleState(
16
+ puzzle: SlidingPuzzleModel,
17
+ ): SlidingPuzzleState {
18
+ return { position: { ...puzzle.start }, collapsedCells: [], openHoles: [] };
19
+ }
20
+
21
+ export function isSlidingPuzzleComplete(
22
+ puzzle: SlidingPuzzleModel,
23
+ state: SlidingPuzzleState,
24
+ ): boolean {
25
+ return (
26
+ state.position.row === puzzle.target.row &&
27
+ state.position.column === puzzle.target.column
28
+ );
29
+ }
30
+
31
+ export function simulateSlidingMove(
32
+ puzzle: SlidingPuzzleModel,
33
+ state: SlidingPuzzleState,
34
+ direction: SlidingDirection,
35
+ ): SlidingMovement | null {
36
+ if (isSlidingPuzzleComplete(puzzle, state)) return null;
37
+ const [dr, dc] = deltas[direction];
38
+ let to = { ...state.position };
39
+ const collapsedCells = [...state.collapsedCells];
40
+ while (true) {
41
+ const next = { row: to.row + dr, column: to.column + dc };
42
+ if (
43
+ next.row < 0 ||
44
+ next.row >= puzzle.rows ||
45
+ next.column < 0 ||
46
+ next.column >= puzzle.columns ||
47
+ [...puzzle.blockedCells, ...collapsedCells].some(
48
+ (cell) => cell.row === next.row && cell.column === next.column,
49
+ )
50
+ )
51
+ break;
52
+ // Entering a fragile tile collapses it, including the stopping tile.
53
+ // A piece resting on a collapsed tile may leave it, but cannot return.
54
+ to = next;
55
+ const hole = puzzle.holes.find(
56
+ ({ position }) =>
57
+ position.row === to.row && position.column === to.column,
58
+ );
59
+ if (hole) {
60
+ const arrival = {
61
+ row: hole.exit.row + dr,
62
+ column: hole.exit.column + dc,
63
+ };
64
+ const openHoles = state.openHoles.some(
65
+ (position) => position.row === to.row && position.column === to.column,
66
+ )
67
+ ? [...state.openHoles]
68
+ : [...state.openHoles, { ...to }];
69
+ return {
70
+ direction,
71
+ from: { ...state.position },
72
+ slideTo: to,
73
+ to: arrival,
74
+ teleported: true,
75
+ state: { position: arrival, collapsedCells, openHoles },
76
+ };
77
+ }
78
+ if (
79
+ puzzle.fragileCells.some(
80
+ (cell) => cell.row === to.row && cell.column === to.column,
81
+ )
82
+ ) {
83
+ collapsedCells.push({ ...to });
84
+ }
85
+ }
86
+ if (to.row === state.position.row && to.column === state.position.column)
87
+ return null;
88
+ return {
89
+ direction,
90
+ from: { ...state.position },
91
+ slideTo: to,
92
+ to,
93
+ teleported: false,
94
+ state: { position: to, collapsedCells, openHoles: [...state.openHoles] },
95
+ };
96
+ }
97
+
98
+ export function getSlidingMoves(
99
+ puzzle: SlidingPuzzleModel,
100
+ state: SlidingPuzzleState,
101
+ ): SlidingMovement[] {
102
+ return (Object.keys(deltas) as SlidingDirection[]).flatMap((direction) => {
103
+ const movement = simulateSlidingMove(puzzle, state, direction);
104
+ return movement ? [movement] : [];
105
+ });
106
+ }