@astronox/core 0.5.7 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astronox/core",
3
- "version": "0.5.7",
3
+ "version": "0.5.8",
4
4
  "description": "Reusable Astro components, patterns, styles, and infrastructure for AstroNox sites.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -0,0 +1,210 @@
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 { presentLightMazeCell } from './presentation';
6
+ import type {
7
+ LightMazePuzzleLabels,
8
+ LightMazePuzzleMedia,
9
+ LightMazePuzzleModel,
10
+ } from './contracts';
11
+ import {
12
+ createLightMazePuzzleState,
13
+ evaluateLightMazeMechanism,
14
+ } from './simulation';
15
+
16
+ type Props = HTMLAttributes<'section'> & {
17
+ label: string;
18
+ labels: LightMazePuzzleLabels;
19
+ media: LightMazePuzzleMedia;
20
+ puzzles: readonly LightMazePuzzleModel[];
21
+ };
22
+
23
+ const {
24
+ class: className,
25
+ label,
26
+ labels,
27
+ media,
28
+ puzzles,
29
+ ...attrs
30
+ } = Astro.props;
31
+
32
+ if (puzzles.some((puzzle) => puzzle.activators?.length) && !media.mechanism)
33
+ throw new Error('Light maze mechanisms require display media.');
34
+ ---
35
+
36
+ <section
37
+ {...attrs}
38
+ class:list={['light-maze-puzzle', className]}
39
+ aria-label={label}
40
+ data-light-maze-puzzle
41
+ >
42
+ {
43
+ puzzles.map((puzzle, index) => {
44
+ const initial = createLightMazePuzzleState(puzzle);
45
+ const mechanism = evaluateLightMazeMechanism(puzzle, initial);
46
+ return (
47
+ <div
48
+ class="light-maze-puzzle__instance"
49
+ data-puzzle-id={puzzle.id}
50
+ data-puzzle-status="incomplete"
51
+ data-light-maze-model={JSON.stringify(puzzle)}
52
+ data-light-maze-labels={JSON.stringify(labels)}
53
+ hidden={index !== 0}
54
+ style={`--piece-selection-filter: url(#light-maze-outline-${encodeURIComponent(puzzle.id)}); --piece-hover-filter: url(#light-maze-hover-${encodeURIComponent(puzzle.id)});`}
55
+ >
56
+ <svg
57
+ class="light-maze-puzzle__filters"
58
+ aria-hidden="true"
59
+ focusable="false"
60
+ >
61
+ <defs>
62
+ <filter
63
+ id={`light-maze-hover-${encodeURIComponent(puzzle.id)}`}
64
+ color-interpolation-filters="sRGB"
65
+ >
66
+ <feFlood
67
+ class="light-maze-puzzle__hover-color"
68
+ flood-opacity="0.1"
69
+ />
70
+ <feComposite operator="in" in2="SourceAlpha" />
71
+ <feMerge>
72
+ <feMergeNode in="SourceGraphic" />
73
+ <feMergeNode />
74
+ </feMerge>
75
+ </filter>
76
+ <filter
77
+ id={`light-maze-outline-${encodeURIComponent(puzzle.id)}`}
78
+ x="-25%"
79
+ y="-25%"
80
+ width="150%"
81
+ height="150%"
82
+ color-interpolation-filters="sRGB"
83
+ >
84
+ <feMorphology in="SourceAlpha" operator="dilate" radius="2" />
85
+ <feGaussianBlur stdDeviation="0.5" result="outline" />
86
+ <feFlood class="light-maze-puzzle__ring-color" />
87
+ <feComposite operator="in" in2="outline" />
88
+ <feMerge>
89
+ <feMergeNode />
90
+ <feMergeNode in="SourceGraphic" />
91
+ </feMerge>
92
+ </filter>
93
+ </defs>
94
+ </svg>
95
+ <PuzzleGrid
96
+ class="light-maze-puzzle__board"
97
+ label={puzzle.label}
98
+ rows={puzzle.rows}
99
+ columns={puzzle.columns}
100
+ >
101
+ {Array.from(
102
+ { length: puzzle.rows * puzzle.columns },
103
+ (_, cellIndex) => {
104
+ const row = Math.floor(cellIndex / puzzle.columns);
105
+ const column = cellIndex % puzzle.columns;
106
+ const {
107
+ disabled: blocked,
108
+ target,
109
+ lit,
110
+ activator,
111
+ consumed,
112
+ removableWall,
113
+ releasedWall,
114
+ label: cellLabel,
115
+ } = presentLightMazeCell(puzzle, mechanism, labels, {
116
+ row,
117
+ column,
118
+ });
119
+ return (
120
+ <button
121
+ type="button"
122
+ class="puzzle-grid__cell light-maze-puzzle__cell"
123
+ disabled={blocked}
124
+ tabindex={-1}
125
+ aria-label={cellLabel}
126
+ data-puzzle-cell
127
+ data-puzzle-cell-disabled={blocked ? '' : undefined}
128
+ data-light-maze-cell
129
+ data-light-maze-lit={lit ? '' : undefined}
130
+ data-light-maze-target={target ? '' : undefined}
131
+ data-light-maze-wall={removableWall ? '' : undefined}
132
+ data-light-maze-wall-released={
133
+ releasedWall ? '' : undefined
134
+ }
135
+ data-light-maze-activator={activator ? '' : undefined}
136
+ data-light-maze-activator-consumed={
137
+ consumed ? '' : undefined
138
+ }
139
+ data-cell-row={row}
140
+ data-cell-column={column}
141
+ data-cell-label={cellLabel}
142
+ style={`--cell-row: ${row + 1}; --cell-column: ${column + 1};`}
143
+ >
144
+ {activator && media.mechanism && (
145
+ <span
146
+ class="light-maze-puzzle__activator"
147
+ aria-hidden="true"
148
+ >
149
+ <span data-activator-inactive>
150
+ <Media
151
+ media={media.mechanism.activatorInactive}
152
+ size="fill"
153
+ />
154
+ </span>
155
+ <span data-activator-active>
156
+ <Media
157
+ media={media.mechanism.activatorActive}
158
+ size="fill"
159
+ />
160
+ </span>
161
+ </span>
162
+ )}
163
+ {removableWall && (
164
+ <span
165
+ class="light-maze-puzzle__wall-fill"
166
+ data-wall-fill
167
+ aria-hidden="true"
168
+ >
169
+ <svg
170
+ class="light-maze-puzzle__wall-cracks"
171
+ viewBox="0 0 100 100"
172
+ focusable="false"
173
+ >
174
+ <path d="M29 0L34 17L27 29L40 39L37 56M27 29L15 34L9 46" />
175
+ <path d="M100 31L82 37L78 49L63 55L58 69M78 49L85 59L83 70" />
176
+ <path d="M24 100L32 84L29 75L43 67M32 84L47 89" />
177
+ </svg>
178
+ </span>
179
+ )}
180
+ </button>
181
+ );
182
+ },
183
+ )}
184
+ {(['walker', 'light'] as const).map((piece) => (
185
+ <button
186
+ type="button"
187
+ class="light-maze-puzzle__piece"
188
+ aria-label={labels[piece]}
189
+ aria-pressed="false"
190
+ data-light-maze-piece={piece}
191
+ style={`--piece-row: ${initial[piece].row + 1}; --piece-column: ${initial[piece].column + 1};`}
192
+ >
193
+ <span class="light-maze-puzzle__piece-media" aria-hidden="true">
194
+ <Media media={media[piece]} size="fill" />
195
+ </span>
196
+ </button>
197
+ ))}
198
+ </PuzzleGrid>
199
+ </div>
200
+ );
201
+ })
202
+ }
203
+ </section>
204
+
205
+ <script>
206
+ import { initLightMazePuzzle } from './lightMazePuzzleController';
207
+ for (const root of document.querySelectorAll('[data-light-maze-puzzle]')) {
208
+ if (root instanceof HTMLElement) initLightMazePuzzle(root);
209
+ }
210
+ </script>
@@ -0,0 +1,189 @@
1
+ .light-maze-puzzle,
2
+ .light-maze-puzzle__instance {
3
+ display: grid;
4
+ min-inline-size: 0;
5
+ }
6
+
7
+ .light-maze-puzzle__instance[hidden] {
8
+ display: none;
9
+ }
10
+
11
+ .light-maze-puzzle__filters {
12
+ position: absolute;
13
+ inline-size: 0;
14
+ block-size: 0;
15
+ pointer-events: none;
16
+ }
17
+
18
+ .light-maze-puzzle__ring-color {
19
+ flood-color: var(--nox-source-tone-info);
20
+ }
21
+ .light-maze-puzzle__hover-color {
22
+ flood-color: var(--nox-tone-color);
23
+ }
24
+
25
+ .light-maze-puzzle__board {
26
+ isolation: isolate;
27
+ touch-action: manipulation;
28
+ }
29
+
30
+ .light-maze-puzzle__cell {
31
+ appearance: none;
32
+ }
33
+
34
+ .light-maze-puzzle__activator {
35
+ position: absolute;
36
+ inset: 18%;
37
+ display: grid;
38
+ pointer-events: none;
39
+ }
40
+
41
+ .light-maze-puzzle__activator > span {
42
+ display: grid;
43
+ grid-area: 1 / 1;
44
+ }
45
+
46
+ .light-maze-puzzle__cell:not([data-light-maze-activator-consumed])
47
+ [data-activator-active],
48
+ .light-maze-puzzle__cell[data-light-maze-activator-consumed]
49
+ [data-activator-inactive] {
50
+ display: none;
51
+ }
52
+
53
+ .light-maze-puzzle__wall-fill {
54
+ position: absolute;
55
+ inset: 0;
56
+ display: grid;
57
+ border-radius: inherit;
58
+ overflow: hidden;
59
+ background: var(--nox-surface-strong);
60
+ pointer-events: none;
61
+ }
62
+
63
+ .light-maze-puzzle__wall-cracks {
64
+ position: absolute;
65
+ inset: 0;
66
+ inline-size: 100%;
67
+ block-size: 100%;
68
+ fill: none;
69
+ stroke: var(--nox-text-muted);
70
+ stroke-width: 1.5;
71
+ stroke-linecap: round;
72
+ stroke-linejoin: round;
73
+ }
74
+
75
+ .light-maze-puzzle__cell[data-light-maze-wall-released]:not(
76
+ [data-wall-removing]
77
+ )
78
+ .light-maze-puzzle__wall-fill {
79
+ display: none;
80
+ }
81
+
82
+ .light-maze-puzzle__cell[data-light-maze-lit]:not([data-puzzle-cell-disabled]) {
83
+ --puzzle-cell-base-background: color-mix(
84
+ in srgb,
85
+ var(--nox-source-tone-warning) 12%,
86
+ var(--nox-surface)
87
+ );
88
+ }
89
+
90
+ .light-maze-puzzle__cell[data-light-maze-target] {
91
+ --puzzle-cell-base-border: var(--nox-source-tone-success);
92
+ border-style: dashed;
93
+ }
94
+
95
+ .light-maze-puzzle__cell[data-light-maze-destination] {
96
+ --puzzle-cell-ring-width: var(--nox-focus-ring-width);
97
+ --puzzle-cell-ring-color: var(--nox-source-tone-info);
98
+ z-index: 3;
99
+ overflow: visible;
100
+ }
101
+
102
+ .light-maze-puzzle__piece {
103
+ position: relative;
104
+ z-index: 4;
105
+ display: grid;
106
+ grid-row: var(--piece-row);
107
+ grid-column: var(--piece-column);
108
+ min-inline-size: 0;
109
+ min-block-size: 0;
110
+ margin: var(--nox-space-1);
111
+ padding: 0;
112
+ place-items: center;
113
+ border: 0;
114
+ border-radius: var(--nox-radius-sm);
115
+ background: transparent;
116
+ cursor: grab;
117
+ touch-action: none;
118
+ user-select: none;
119
+ }
120
+
121
+ .light-maze-puzzle__piece-media {
122
+ display: grid;
123
+ inline-size: 80%;
124
+ aspect-ratio: 1;
125
+ pointer-events: none;
126
+ }
127
+
128
+ .light-maze-puzzle__piece:focus-visible {
129
+ outline: none;
130
+ }
131
+ .light-maze-puzzle__piece:is([data-piece-selected], :focus-visible)
132
+ .light-maze-puzzle__piece-media {
133
+ filter: var(--piece-selection-filter);
134
+ }
135
+
136
+ @media (hover: hover) {
137
+ .light-maze-puzzle__piece:hover .light-maze-puzzle__piece-media {
138
+ filter: var(--piece-hover-filter);
139
+ }
140
+ .light-maze-puzzle__piece:is([data-piece-selected], :focus-visible):hover
141
+ .light-maze-puzzle__piece-media {
142
+ filter: var(--piece-hover-filter) var(--piece-selection-filter);
143
+ }
144
+ }
145
+
146
+ .light-maze-puzzle__piece[data-piece-dragging] {
147
+ z-index: 6;
148
+ cursor: grabbing;
149
+ }
150
+ .light-maze-puzzle__piece[data-piece-dragging] .light-maze-puzzle__piece-media {
151
+ transform: translate3d(var(--piece-drag-x, 0px), var(--piece-drag-y, 0px), 0)
152
+ scale(1.04);
153
+ will-change: transform;
154
+ }
155
+ .light-maze-puzzle__instance[data-puzzle-status='valid']
156
+ .light-maze-puzzle__piece {
157
+ cursor: default;
158
+ }
159
+
160
+ .light-maze-puzzle__instance[data-light-maze-transitioning]
161
+ .light-maze-puzzle__piece {
162
+ cursor: default;
163
+ }
164
+
165
+ .light-maze-puzzle__instance[data-light-maze-transitioning]
166
+ .light-maze-puzzle__piece
167
+ .light-maze-puzzle__piece-media {
168
+ filter: none;
169
+ }
170
+
171
+ .light-maze-puzzle__instance[data-light-maze-transitioning]
172
+ .light-maze-puzzle__cell[data-puzzle-cell] {
173
+ --puzzle-cell-ring-width: 0px;
174
+ }
175
+
176
+ @media (prefers-reduced-motion: no-preference) {
177
+ .light-maze-puzzle__piece[data-piece-moved] {
178
+ animation: light-maze-puzzle-move 260ms var(--nox-motion-easing-standard);
179
+ }
180
+ }
181
+ @keyframes light-maze-puzzle-move {
182
+ from {
183
+ transform: translate3d(
184
+ var(--piece-move-inline-distance),
185
+ var(--piece-move-block-distance),
186
+ 0
187
+ );
188
+ }
189
+ }
@@ -0,0 +1,75 @@
1
+ import type { DisplayMedia } from '../../display/contracts';
2
+
3
+ export type LightMazePosition = { row: number; column: number };
4
+ export type LightMazeDirection = 'north' | 'east' | 'south' | 'west';
5
+ export type LightMazePiece = 'walker' | 'light';
6
+ export type LightMazePuzzleStatus = 'incomplete' | 'valid';
7
+
8
+ export type LightMazePuzzleModel = {
9
+ id: string;
10
+ label: string;
11
+ rows: number;
12
+ columns: number;
13
+ start: LightMazePosition;
14
+ light: LightMazePosition;
15
+ target: LightMazePosition;
16
+ blockedCells: readonly LightMazePosition[];
17
+ activators?: readonly {
18
+ position: LightMazePosition;
19
+ walls: readonly LightMazePosition[];
20
+ }[];
21
+ };
22
+
23
+ export type LightMazeMechanism = {
24
+ illuminatedCells: readonly LightMazePosition[];
25
+ blockedCells: readonly LightMazePosition[];
26
+ activatedCells: readonly LightMazePosition[];
27
+ };
28
+
29
+ export type LightMazePuzzleState = {
30
+ walker: LightMazePosition;
31
+ light: LightMazePosition;
32
+ activated: readonly number[];
33
+ };
34
+
35
+ export type LightMazeAction = {
36
+ piece: LightMazePiece;
37
+ direction: LightMazeDirection;
38
+ };
39
+
40
+ export type LightMazeMovement = LightMazeAction & {
41
+ from: LightMazePosition;
42
+ to: LightMazePosition;
43
+ stoppedByWalker: boolean;
44
+ state: LightMazePuzzleState;
45
+ };
46
+
47
+ export type LightMazePuzzleLabels = {
48
+ walker: string;
49
+ light: string;
50
+ target: string;
51
+ blockedCell: string;
52
+ litCell: string;
53
+ darkCell: string;
54
+ directions: Readonly<Record<LightMazeDirection, string>>;
55
+ moveTemplate: string;
56
+ mechanism?: {
57
+ activatorAvailable: string;
58
+ activatorConsumed: string;
59
+ removableWall: string;
60
+ releasedWall: string;
61
+ };
62
+ };
63
+
64
+ export type LightMazePuzzleMedia = Record<LightMazePiece, DisplayMedia> & {
65
+ mechanism?: {
66
+ activatorInactive: DisplayMedia;
67
+ activatorActive: DisplayMedia;
68
+ };
69
+ };
70
+
71
+ export type LightMazePuzzleChangeDetail = {
72
+ kind: 'light-maze';
73
+ puzzleId: string;
74
+ state: LightMazePuzzleState;
75
+ };
@@ -0,0 +1,684 @@
1
+ import {
2
+ animatePieceMovement,
3
+ cancelDragFrame,
4
+ findDirectionalDropDestination,
5
+ hasLeftOriginCell,
6
+ renderPathStream,
7
+ scheduleDragFrame,
8
+ type PuzzleDrag,
9
+ } from '../PuzzleGrid/movement';
10
+ import { initPuzzleSelectionDismissal } from '../PuzzleGrid/selectionDismissal';
11
+ import { presentLightMazeCell } from './presentation';
12
+ import {
13
+ setPuzzleGridCellState,
14
+ setPuzzleGridState,
15
+ } from '../PuzzleGrid/state';
16
+ import type {
17
+ LightMazeDirection,
18
+ LightMazePiece,
19
+ LightMazePosition,
20
+ LightMazePuzzleChangeDetail,
21
+ LightMazePuzzleLabels,
22
+ LightMazePuzzleModel,
23
+ LightMazePuzzleState,
24
+ LightMazePuzzleStatus,
25
+ } from './contracts';
26
+ import {
27
+ createLightMazePuzzleState,
28
+ evaluateLightMazeMechanism,
29
+ getLightMazeMoves,
30
+ isLightMazePuzzleComplete,
31
+ simulateLightMazeMove,
32
+ } from './simulation';
33
+
34
+ type Panel = {
35
+ element: HTMLElement;
36
+ board: HTMLElement;
37
+ model: LightMazePuzzleModel;
38
+ labels: LightMazePuzzleLabels;
39
+ state: LightMazePuzzleState;
40
+ };
41
+ type Instance = {
42
+ panels: Panel[];
43
+ activeIndex: number;
44
+ selected: LightMazePiece | null;
45
+ motion: AbortController | null;
46
+ drag: PuzzleDrag | null;
47
+ ignoreClick: boolean;
48
+ clickTimer: number | null;
49
+ controller: AbortController;
50
+ dismiss: () => void;
51
+ };
52
+
53
+ const instances = new WeakMap<HTMLElement, Instance>();
54
+ const arrows: Record<string, LightMazeDirection> = {
55
+ ArrowUp: 'north',
56
+ ArrowRight: 'east',
57
+ ArrowDown: 'south',
58
+ ArrowLeft: 'west',
59
+ };
60
+
61
+ export function initLightMazePuzzle(root: HTMLElement): void {
62
+ if (instances.has(root)) return;
63
+ const panels = [
64
+ ...root.querySelectorAll<HTMLElement>('[data-light-maze-model]'),
65
+ ].map((element) => {
66
+ const model = JSON.parse(
67
+ element.dataset.lightMazeModel!,
68
+ ) as LightMazePuzzleModel;
69
+ return {
70
+ element,
71
+ board: element.querySelector<HTMLElement>('[data-puzzle-grid]')!,
72
+ model,
73
+ labels: JSON.parse(
74
+ element.dataset.lightMazeLabels!,
75
+ ) as LightMazePuzzleLabels,
76
+ state: createLightMazePuzzleState(model),
77
+ };
78
+ });
79
+ if (!panels.length) return;
80
+ const controller = new AbortController();
81
+ const instance: Instance = {
82
+ panels,
83
+ activeIndex: 0,
84
+ selected: null,
85
+ motion: null,
86
+ drag: null,
87
+ ignoreClick: false,
88
+ clickTimer: null,
89
+ controller,
90
+ dismiss: () => undefined,
91
+ };
92
+ instances.set(root, instance);
93
+ instance.dismiss = initPuzzleSelectionDismissal(root, {
94
+ dismiss: () => selectPiece(root, null),
95
+ isSelectionTarget: (target) => {
96
+ const panel = active(instance);
97
+ return (
98
+ panel.element.dataset.puzzleStatus !== 'valid' &&
99
+ panel.element.contains(target) &&
100
+ Boolean(
101
+ target.closest(
102
+ '[data-light-maze-piece], [data-light-maze-destination]',
103
+ ),
104
+ )
105
+ );
106
+ },
107
+ });
108
+ const options = { signal: controller.signal };
109
+ root.addEventListener(
110
+ 'click',
111
+ (event) => {
112
+ if (!(event.target instanceof Element)) return;
113
+ if (instance.motion) {
114
+ event.preventDefault();
115
+ return;
116
+ }
117
+ if (instance.ignoreClick && event.detail !== 0) {
118
+ instance.ignoreClick = false;
119
+ event.preventDefault();
120
+ return;
121
+ }
122
+ instance.ignoreClick = false;
123
+ const piece = event.target.closest<HTMLButtonElement>(
124
+ '[data-light-maze-piece]',
125
+ );
126
+ if (piece) {
127
+ const id = piece.dataset.lightMazePiece as LightMazePiece;
128
+ selectPiece(root, instance.selected === id ? null : id);
129
+ return;
130
+ }
131
+ const destination = event.target.closest<HTMLButtonElement>(
132
+ '[data-light-maze-destination]',
133
+ );
134
+ if (destination && !destination.disabled) {
135
+ void move(
136
+ root,
137
+ destination.dataset.destinationPiece as LightMazePiece,
138
+ destination.dataset.destinationDirection as LightMazeDirection,
139
+ );
140
+ } else selectPiece(root, null);
141
+ },
142
+ options,
143
+ );
144
+
145
+ root.addEventListener(
146
+ 'keydown',
147
+ (event) => {
148
+ if (instance.motion) {
149
+ if (arrows[event.key] || ['Enter', ' ', 'Escape'].includes(event.key))
150
+ event.preventDefault();
151
+ return;
152
+ }
153
+ if (event.altKey || event.ctrlKey || event.metaKey || instance.drag)
154
+ return;
155
+ if (event.key === 'Escape') {
156
+ selectPiece(root, null);
157
+ return;
158
+ }
159
+ const direction = arrows[event.key];
160
+ if (!direction) return;
161
+ const focused =
162
+ event.target instanceof Element
163
+ ? event.target.closest<HTMLElement>('[data-light-maze-piece]')
164
+ : null;
165
+ const piece =
166
+ (focused?.dataset.lightMazePiece as LightMazePiece | undefined) ??
167
+ instance.selected;
168
+ if (!piece) return;
169
+ event.preventDefault();
170
+ void move(root, piece, direction);
171
+ },
172
+ options,
173
+ );
174
+
175
+ root.addEventListener(
176
+ 'pointerdown',
177
+ (event) => {
178
+ if (instance.motion) {
179
+ event.preventDefault();
180
+ clearClickTimer(instance);
181
+ instance.ignoreClick = true;
182
+ return;
183
+ }
184
+ instance.ignoreClick = false;
185
+ if (
186
+ !event.isPrimary ||
187
+ event.button !== 0 ||
188
+ instance.drag ||
189
+ active(instance).element.dataset.puzzleStatus === 'valid'
190
+ )
191
+ return;
192
+ if (!(event.target instanceof Element)) return;
193
+ const element = event.target.closest<HTMLButtonElement>(
194
+ '[data-light-maze-piece]',
195
+ );
196
+ if (!element) return;
197
+ const pieceId = element.dataset.lightMazePiece as LightMazePiece;
198
+ const origin = findCell(
199
+ active(instance),
200
+ active(instance).state[pieceId],
201
+ );
202
+ if (!origin) return;
203
+ const bounds = element.getBoundingClientRect();
204
+ element.setPointerCapture(event.pointerId);
205
+ instance.drag = {
206
+ pointerId: event.pointerId,
207
+ pieceId,
208
+ startX: event.clientX,
209
+ startY: event.clientY,
210
+ currentX: event.clientX,
211
+ currentY: event.clientY,
212
+ frameId: null,
213
+ element,
214
+ originCellBounds: origin.getBoundingClientRect(),
215
+ originCenterX: (bounds.left + bounds.right) / 2,
216
+ originCenterY: (bounds.top + bounds.bottom) / 2,
217
+ destinations: null,
218
+ moved: false,
219
+ };
220
+ },
221
+ options,
222
+ );
223
+
224
+ root.addEventListener(
225
+ 'pointermove',
226
+ (event) => {
227
+ const drag = instance.drag;
228
+ if (!drag || drag.pointerId !== event.pointerId) return;
229
+ if (!drag.moved) {
230
+ if (
231
+ Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) <
232
+ 12
233
+ )
234
+ return;
235
+ drag.moved = true;
236
+ drag.element.setAttribute('data-piece-dragging', '');
237
+ selectPiece(root, drag.pieceId as LightMazePiece);
238
+ drag.destinations = [
239
+ ...active(instance).element.querySelectorAll<HTMLButtonElement>(
240
+ '[data-light-maze-destination]',
241
+ ),
242
+ ];
243
+ }
244
+ event.preventDefault();
245
+ drag.currentX = event.clientX;
246
+ drag.currentY = event.clientY;
247
+ scheduleDragFrame(drag, () => instance.drag === drag);
248
+ },
249
+ options,
250
+ );
251
+ root.addEventListener(
252
+ 'pointerup',
253
+ (event) => finishDrag(root, event, true),
254
+ options,
255
+ );
256
+ root.addEventListener(
257
+ 'pointercancel',
258
+ (event) => finishDrag(root, event, false),
259
+ options,
260
+ );
261
+ root.addEventListener(
262
+ 'lostpointercapture',
263
+ (event) => finishDrag(root, event, false),
264
+ options,
265
+ );
266
+ render(root);
267
+ }
268
+
269
+ export function selectLightMazePuzzle(root: HTMLElement, index: number): void {
270
+ initLightMazePuzzle(root);
271
+ const instance = instances.get(root);
272
+ if (!instance) return;
273
+ cancelMotion(instance);
274
+ cancelDrag(instance);
275
+ instance.activeIndex = Math.max(
276
+ 0,
277
+ Math.min(index, instance.panels.length - 1),
278
+ );
279
+ instance.panels.forEach((panel, i) => {
280
+ panel.element.hidden = i !== instance.activeIndex;
281
+ });
282
+ resetLightMazePuzzle(root);
283
+ }
284
+
285
+ export function resetLightMazePuzzle(root: HTMLElement): void {
286
+ initLightMazePuzzle(root);
287
+ const instance = instances.get(root);
288
+ if (!instance) return;
289
+ cancelMotion(instance);
290
+ cancelDrag(instance);
291
+ clearClickTimer(instance);
292
+ instance.ignoreClick = false;
293
+ instance.selected = null;
294
+ const panel = active(instance);
295
+ panel.state = createLightMazePuzzleState(panel.model);
296
+ panel.element.dataset.puzzleStatus = 'incomplete';
297
+ setPuzzleGridState(panel.board, null);
298
+ for (const piece of panel.element.querySelectorAll<HTMLElement>(
299
+ '[data-light-maze-piece]',
300
+ )) {
301
+ piece.getAnimations().forEach((animation) => animation.cancel());
302
+ piece.removeAttribute('data-piece-moved');
303
+ }
304
+ render(root);
305
+ emitChange(root, panel);
306
+ }
307
+
308
+ export function setLightMazePuzzleStatus(
309
+ root: HTMLElement,
310
+ puzzleId: string,
311
+ status: LightMazePuzzleStatus,
312
+ ): void {
313
+ const instance = instances.get(root);
314
+ const panel = instance?.panels.find(
315
+ (candidate) => candidate.model.id === puzzleId,
316
+ );
317
+ if (!instance || !panel) return;
318
+ const previous = panel.element.dataset.puzzleStatus;
319
+ panel.element.dataset.puzzleStatus = status;
320
+ setPuzzleGridState(panel.board, status === 'valid' ? 'valid' : null);
321
+ if (status === 'valid' && panel === active(instance)) {
322
+ cancelDrag(instance);
323
+ instance.selected = null;
324
+ renderSelection(root);
325
+ }
326
+ if (status === 'valid' && previous !== 'valid') {
327
+ root.dispatchEvent(
328
+ new CustomEvent('puzzle:complete', {
329
+ bubbles: true,
330
+ detail: { kind: 'light-maze', puzzleId },
331
+ }),
332
+ );
333
+ }
334
+ }
335
+
336
+ export function destroyLightMazePuzzle(root: HTMLElement): void {
337
+ const instance = instances.get(root);
338
+ if (!instance) return;
339
+ cancelMotion(instance);
340
+ instance.controller.abort();
341
+ instance.dismiss();
342
+ cancelDrag(instance);
343
+ clearClickTimer(instance);
344
+ for (const panel of instance.panels) {
345
+ clearPreviews(panel);
346
+ for (const piece of panel.element.querySelectorAll<HTMLElement>(
347
+ '[data-light-maze-piece]',
348
+ )) {
349
+ piece.getAnimations().forEach((animation) => animation.cancel());
350
+ }
351
+ }
352
+ instances.delete(root);
353
+ }
354
+
355
+ function active(instance: Instance): Panel {
356
+ return instance.panels[instance.activeIndex]!;
357
+ }
358
+
359
+ function selectPiece(root: HTMLElement, piece: LightMazePiece | null): void {
360
+ const instance = instances.get(root);
361
+ if (
362
+ !instance ||
363
+ instance.motion ||
364
+ active(instance).element.dataset.puzzleStatus === 'valid'
365
+ )
366
+ return;
367
+ instance.selected = piece;
368
+ renderSelection(root);
369
+ }
370
+
371
+ async function move(
372
+ root: HTMLElement,
373
+ piece: LightMazePiece,
374
+ direction: LightMazeDirection,
375
+ retainSelection = true,
376
+ ): Promise<void> {
377
+ const instance = instances.get(root);
378
+ if (!instance || instance.motion) return;
379
+ const panel = active(instance);
380
+ if (panel.element.dataset.puzzleStatus === 'valid') return;
381
+ const movement = simulateLightMazeMove(panel.model, panel.state, {
382
+ piece,
383
+ direction,
384
+ });
385
+ if (!movement) return;
386
+ const element = panel.element.querySelector<HTMLButtonElement>(
387
+ `[data-light-maze-piece="${piece}"]`,
388
+ )!;
389
+ const bounds = element.getBoundingClientRect();
390
+ element.getAnimations().forEach((animation) => animation.cancel());
391
+ element.removeAttribute('data-piece-moved');
392
+ const walls = (panel.model.activators ?? []).flatMap((activator, index) =>
393
+ movement.state.activated.includes(index) &&
394
+ !panel.state.activated.includes(index)
395
+ ? activator.walls
396
+ : [],
397
+ );
398
+ const motion =
399
+ walls.length && !reducedMotion() ? new AbortController() : null;
400
+ if (motion) {
401
+ instance.motion = motion;
402
+ panel.element.setAttribute('data-light-maze-transitioning', '');
403
+ panel.board.setAttribute('aria-busy', 'true');
404
+ }
405
+ panel.state = motion
406
+ ? { ...movement.state, activated: panel.state.activated }
407
+ : movement.state;
408
+ instance.selected =
409
+ (retainSelection || walls.length > 0) &&
410
+ !isLightMazePuzzleComplete(panel.model, panel.state)
411
+ ? piece
412
+ : null;
413
+ render(root);
414
+ animatePieceMovement(element, bounds);
415
+ if (!motion) {
416
+ emitChange(root, panel);
417
+ return;
418
+ }
419
+ try {
420
+ if (!(await waitForAnimations(element.getAnimations(), motion.signal)))
421
+ return;
422
+ const cells = walls.flatMap((position) => {
423
+ const cell = findCell(panel, position);
424
+ return cell ? [cell] : [];
425
+ });
426
+ for (const cell of cells) cell.setAttribute('data-wall-removing', '');
427
+ panel.state = movement.state;
428
+ render(root);
429
+ if (!reducedMotion()) await animateWallRemoval(cells, motion.signal);
430
+ } finally {
431
+ if (instance.motion === motion) {
432
+ instance.motion = null;
433
+ clearMotionPresentation(panel);
434
+ if (!motion.signal.aborted) {
435
+ panel.state = movement.state;
436
+ render(root);
437
+ emitChange(root, panel);
438
+ }
439
+ }
440
+ }
441
+ }
442
+
443
+ function reducedMotion(): boolean {
444
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
445
+ }
446
+
447
+ function animateWallRemoval(
448
+ cells: HTMLButtonElement[],
449
+ signal: AbortSignal,
450
+ ): Promise<boolean> {
451
+ const timing: KeyframeAnimationOptions = {
452
+ duration: 590,
453
+ easing: 'ease-out',
454
+ fill: 'forwards',
455
+ };
456
+ const animations = cells.flatMap((cell) => {
457
+ const fill = cell.querySelector<HTMLElement>('[data-wall-fill]');
458
+ if (!fill) return [];
459
+ return [fill.animate([{ opacity: 1 }, { opacity: 0 }], timing)];
460
+ });
461
+ return waitForAnimations(animations, signal);
462
+ }
463
+
464
+ async function waitForAnimations(
465
+ animations: Animation[],
466
+ signal: AbortSignal,
467
+ ): Promise<boolean> {
468
+ const cancel = (): void =>
469
+ animations.forEach((animation) => animation.cancel());
470
+ if (signal.aborted) {
471
+ cancel();
472
+ return false;
473
+ }
474
+ signal.addEventListener('abort', cancel, { once: true });
475
+ try {
476
+ await Promise.all(
477
+ animations.map((animation) => animation.finished.catch(() => undefined)),
478
+ );
479
+ return !signal.aborted;
480
+ } finally {
481
+ signal.removeEventListener('abort', cancel);
482
+ cancel();
483
+ }
484
+ }
485
+
486
+ function clearMotionPresentation(panel: Panel): void {
487
+ panel.element.removeAttribute('data-light-maze-transitioning');
488
+ panel.board.removeAttribute('aria-busy');
489
+ for (const cell of panel.element.querySelectorAll('[data-wall-removing]'))
490
+ cell.removeAttribute('data-wall-removing');
491
+ for (const piece of panel.element.querySelectorAll('[data-light-maze-piece]'))
492
+ piece.removeAttribute('aria-disabled');
493
+ }
494
+
495
+ function cancelMotion(instance: Instance): void {
496
+ instance.motion?.abort();
497
+ instance.motion = null;
498
+ for (const panel of instance.panels) clearMotionPresentation(panel);
499
+ }
500
+
501
+ function render(root: HTMLElement): void {
502
+ const instance = instances.get(root);
503
+ if (!instance) return;
504
+ const panel = active(instance);
505
+ clearPreviews(panel);
506
+ const mechanism = evaluateLightMazeMechanism(panel.model, panel.state);
507
+ const lit = new Set(
508
+ mechanism.illuminatedCells.map((p) => `${p.row}:${p.column}`),
509
+ );
510
+ for (const cell of panel.element.querySelectorAll<HTMLButtonElement>(
511
+ '[data-light-maze-cell]',
512
+ )) {
513
+ const display = presentLightMazeCell(panel.model, mechanism, panel.labels, {
514
+ row: Number(cell.dataset.cellRow),
515
+ column: Number(cell.dataset.cellColumn),
516
+ });
517
+ cell.disabled = display.disabled;
518
+ cell.toggleAttribute('data-puzzle-cell-disabled', cell.disabled);
519
+ cell.toggleAttribute(
520
+ 'data-light-maze-activator-consumed',
521
+ display.consumed,
522
+ );
523
+ cell.toggleAttribute('data-light-maze-lit', display.lit);
524
+ cell.toggleAttribute('data-light-maze-wall-released', display.releasedWall);
525
+ cell.dataset.cellLabel = display.label;
526
+ cell.setAttribute('aria-label', display.label);
527
+ if (display.target)
528
+ setPuzzleGridCellState(
529
+ cell,
530
+ isLightMazePuzzleComplete(panel.model, panel.state) ? 'valid' : null,
531
+ );
532
+ }
533
+ for (const piece of ['walker', 'light'] as const) {
534
+ const element = panel.element.querySelector<HTMLElement>(
535
+ `[data-light-maze-piece="${piece}"]`,
536
+ )!;
537
+ const position = panel.state[piece];
538
+ element.style.setProperty('--piece-row', String(position.row + 1));
539
+ element.style.setProperty('--piece-column', String(position.column + 1));
540
+ element.setAttribute(
541
+ 'aria-label',
542
+ `${panel.labels[piece]} — ${lit.has(`${position.row}:${position.column}`) ? panel.labels.litCell : panel.labels.darkCell}`,
543
+ );
544
+ }
545
+ renderSelection(root);
546
+ }
547
+
548
+ function renderSelection(root: HTMLElement): void {
549
+ const instance = instances.get(root);
550
+ if (!instance) return;
551
+ const panel = active(instance);
552
+ clearPreviews(panel);
553
+ for (const piece of panel.element.querySelectorAll<HTMLElement>(
554
+ '[data-light-maze-piece]',
555
+ )) {
556
+ const selected =
557
+ !instance.motion && piece.dataset.lightMazePiece === instance.selected;
558
+ piece.toggleAttribute('data-piece-selected', selected);
559
+ piece.setAttribute('aria-pressed', String(selected));
560
+ if (instance.motion) piece.setAttribute('aria-disabled', 'true');
561
+ else piece.removeAttribute('aria-disabled');
562
+ }
563
+ if (
564
+ instance.motion ||
565
+ !instance.selected ||
566
+ panel.element.dataset.puzzleStatus === 'valid'
567
+ )
568
+ return;
569
+ for (const movement of getLightMazeMoves(panel.model, panel.state)) {
570
+ if (movement.piece !== instance.selected) continue;
571
+ const destination = findCell(panel, movement.to);
572
+ if (!destination) continue;
573
+ renderPathStream(panel.board, movement);
574
+ destination.tabIndex = 0;
575
+ destination.setAttribute('data-light-maze-destination', '');
576
+ destination.setAttribute('data-puzzle-cell-interactive', '');
577
+ destination.dataset.destinationPiece = movement.piece;
578
+ destination.dataset.destinationDirection = movement.direction;
579
+ const label = panel.labels.moveTemplate
580
+ .replaceAll('{piece}', panel.labels[movement.piece])
581
+ .replaceAll('{direction}', panel.labels.directions[movement.direction]);
582
+ destination.setAttribute(
583
+ 'aria-label',
584
+ `${label} — ${destination.dataset.cellLabel}`,
585
+ );
586
+ }
587
+ }
588
+
589
+ function clearPreviews(panel: Panel): void {
590
+ for (const stream of panel.element.querySelectorAll(
591
+ '[data-puzzle-path-stream]',
592
+ ))
593
+ stream.remove();
594
+ for (const cell of panel.element.querySelectorAll<HTMLButtonElement>(
595
+ '[data-light-maze-destination]',
596
+ )) {
597
+ cell.tabIndex = -1;
598
+ for (const attribute of [
599
+ 'data-light-maze-destination',
600
+ 'data-puzzle-cell-interactive',
601
+ 'data-destination-piece',
602
+ 'data-destination-direction',
603
+ ])
604
+ cell.removeAttribute(attribute);
605
+ cell.setAttribute('aria-label', cell.dataset.cellLabel ?? '');
606
+ }
607
+ }
608
+
609
+ function findCell(
610
+ panel: Panel,
611
+ position: LightMazePosition,
612
+ ): HTMLButtonElement | null {
613
+ return panel.element.querySelector(
614
+ `[data-light-maze-cell][data-cell-row="${position.row}"][data-cell-column="${position.column}"]`,
615
+ );
616
+ }
617
+
618
+ function cancelDrag(instance: Instance): void {
619
+ const drag = instance.drag;
620
+ if (!drag) return;
621
+ instance.drag = null;
622
+ cancelDragFrame(drag);
623
+ drag.element.removeAttribute('data-piece-dragging');
624
+ drag.element.style.removeProperty('--piece-drag-x');
625
+ drag.element.style.removeProperty('--piece-drag-y');
626
+ if (drag.element.hasPointerCapture(drag.pointerId))
627
+ drag.element.releasePointerCapture(drag.pointerId);
628
+ }
629
+
630
+ function finishDrag(
631
+ root: HTMLElement,
632
+ event: PointerEvent,
633
+ commit: boolean,
634
+ ): void {
635
+ const instance = instances.get(root);
636
+ const drag = instance?.drag;
637
+ if (!instance || !drag || event.pointerId !== drag.pointerId) return;
638
+ const dx = event.clientX - drag.startX,
639
+ dy = event.clientY - drag.startY;
640
+ const destination =
641
+ commit && drag.moved && hasLeftOriginCell(drag, dx, dy)
642
+ ? findDirectionalDropDestination(drag, dx, dy)
643
+ : null;
644
+ cancelDrag(instance);
645
+ if (!drag.moved) return;
646
+ if (commit) {
647
+ clearClickTimer(instance);
648
+ instance.ignoreClick = true;
649
+ instance.clickTimer = window.setTimeout(() => {
650
+ instance.ignoreClick = false;
651
+ instance.clickTimer = null;
652
+ }, 0);
653
+ if (destination) {
654
+ void move(
655
+ root,
656
+ drag.pieceId as LightMazePiece,
657
+ destination.dataset.destinationDirection as LightMazeDirection,
658
+ false,
659
+ );
660
+ return;
661
+ }
662
+ }
663
+ selectPiece(root, null);
664
+ }
665
+
666
+ function clearClickTimer(instance: Instance): void {
667
+ if (instance.clickTimer !== null) window.clearTimeout(instance.clickTimer);
668
+ instance.clickTimer = null;
669
+ }
670
+
671
+ function emitChange(root: HTMLElement, panel: Panel): void {
672
+ const detail: LightMazePuzzleChangeDetail = {
673
+ kind: 'light-maze',
674
+ puzzleId: panel.model.id,
675
+ state: {
676
+ walker: { ...panel.state.walker },
677
+ light: { ...panel.state.light },
678
+ activated: [...panel.state.activated],
679
+ },
680
+ };
681
+ root.dispatchEvent(
682
+ new CustomEvent('puzzle:change', { bubbles: true, detail }),
683
+ );
684
+ }
@@ -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
+ }
@@ -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';
@@ -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);