@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.
@@ -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
+ }