@astronox/core 0.5.7 → 0.5.9

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,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
+ }
@@ -4,14 +4,17 @@ import MinusIcon from '../../components/icons/MinusIcon.astro';
4
4
  import PlusIcon from '../../components/icons/PlusIcon.astro';
5
5
  import SlashIcon from '../../components/icons/SlashIcon.astro';
6
6
  import PuzzleGrid from '../PuzzleGrid/PuzzleGrid.astro';
7
+ import Media from '../../primitives/media/Media/Media.astro';
7
8
  import type {
8
9
  MagneticFieldPuzzleLabels,
9
10
  MagneticFieldPuzzleModel,
11
+ MagneticFieldPuzzleMedia,
10
12
  } from './contracts';
11
13
 
12
14
  type Props = HTMLAttributes<'section'> & {
13
15
  label: string;
14
16
  labels: MagneticFieldPuzzleLabels;
17
+ media?: MagneticFieldPuzzleMedia;
15
18
  puzzles: readonly MagneticFieldPuzzleModel[];
16
19
  };
17
20
 
@@ -19,9 +22,16 @@ const {
19
22
  class: className,
20
23
  label,
21
24
  labels,
25
+ media,
22
26
  puzzles,
23
27
  ...attrs
24
28
  }: Props = Astro.props;
29
+
30
+ if (
31
+ media &&
32
+ puzzles.some((puzzle) => puzzle.pieces.some((piece) => !media[piece.id]))
33
+ )
34
+ throw new Error('Magnetic field puzzle media must cover every piece.');
25
35
  ---
26
36
 
27
37
  <section
@@ -46,7 +56,53 @@ const {
46
56
  data-puzzle-id={puzzle.id}
47
57
  data-puzzle-status="incomplete"
48
58
  hidden={puzzleIndex !== 0}
59
+ style={
60
+ media
61
+ ? `--piece-selection-filter: url(#magnetic-outline-${encodeURIComponent(puzzle.id)}); --piece-hover-filter: url(#magnetic-hover-${encodeURIComponent(puzzle.id)});`
62
+ : undefined
63
+ }
49
64
  >
65
+ {media && (
66
+ <svg
67
+ class="magnetic-field-puzzle__filters"
68
+ aria-hidden="true"
69
+ focusable="false"
70
+ >
71
+ <defs>
72
+ <filter
73
+ id={`magnetic-hover-${encodeURIComponent(puzzle.id)}`}
74
+ color-interpolation-filters="sRGB"
75
+ >
76
+ <feFlood
77
+ class="magnetic-field-puzzle__hover-color"
78
+ flood-opacity="0.1"
79
+ />
80
+ <feComposite operator="in" in2="SourceAlpha" />
81
+ <feMerge>
82
+ <feMergeNode in="SourceGraphic" />
83
+ <feMergeNode />
84
+ </feMerge>
85
+ </filter>
86
+ <filter
87
+ id={`magnetic-outline-${encodeURIComponent(puzzle.id)}`}
88
+ x="-25%"
89
+ y="-25%"
90
+ width="150%"
91
+ height="150%"
92
+ color-interpolation-filters="sRGB"
93
+ >
94
+ <feMorphology in="SourceAlpha" operator="dilate" radius="2" />
95
+ <feGaussianBlur stdDeviation="0.5" result="outline" />
96
+ <feFlood class="magnetic-field-puzzle__ring-color" />
97
+ <feComposite operator="in" in2="outline" />
98
+ <feMerge>
99
+ <feMergeNode />
100
+ <feMergeNode in="SourceGraphic" />
101
+ </feMerge>
102
+ </filter>
103
+ </defs>
104
+ </svg>
105
+ )}
50
106
  <PuzzleGrid
51
107
  class="magnetic-field-puzzle__board"
52
108
  label={puzzle.label}
@@ -158,6 +214,7 @@ const {
158
214
  aria-label={`${piece.label} — ${labels.polarities[piece.polarity]}`}
159
215
  aria-pressed="false"
160
216
  data-field-piece={piece.id}
217
+ data-field-piece-media={media ? '' : undefined}
161
218
  data-piece-label={piece.label}
162
219
  data-piece-short-label={piece.shortLabel}
163
220
  data-piece-row={piece.row}
@@ -175,9 +232,13 @@ const {
175
232
  class="magnetic-field-puzzle__piece-core"
176
233
  aria-hidden="true"
177
234
  >
178
- <span class="magnetic-field-puzzle__piece-iris">
179
- <span class="magnetic-field-puzzle__piece-pupil" />
180
- </span>
235
+ {media ? (
236
+ <Media media={media[piece.id]!} size="fill" />
237
+ ) : (
238
+ <span class="magnetic-field-puzzle__piece-iris">
239
+ <span class="magnetic-field-puzzle__piece-pupil" />
240
+ </span>
241
+ )}
181
242
  <span class="magnetic-field-puzzle__piece-identity-region">
182
243
  <span
183
244
  class="magnetic-field-puzzle__identity"
@@ -19,6 +19,21 @@
19
19
  display: none;
20
20
  }
21
21
 
22
+ .magnetic-field-puzzle__filters {
23
+ position: absolute;
24
+ inline-size: 0;
25
+ block-size: 0;
26
+ pointer-events: none;
27
+ }
28
+
29
+ .magnetic-field-puzzle__ring-color {
30
+ flood-color: var(--nox-source-tone-info);
31
+ }
32
+
33
+ .magnetic-field-puzzle__hover-color {
34
+ flood-color: var(--nox-tone-color);
35
+ }
36
+
22
37
  .magnetic-field-puzzle__board {
23
38
  isolation: isolate;
24
39
  touch-action: manipulation;
@@ -287,6 +302,60 @@
287
302
  );
288
303
  }
289
304
 
305
+ .magnetic-field-puzzle__piece[data-field-piece-media] {
306
+ border: var(--nox-border-width) solid transparent;
307
+ }
308
+
309
+ .magnetic-field-puzzle__piece[data-field-piece-media]
310
+ .magnetic-field-puzzle__piece-core {
311
+ inline-size: 84%;
312
+ border: 0;
313
+ border-radius: 0;
314
+ background: none;
315
+ box-shadow: none;
316
+ container-type: inline-size;
317
+ pointer-events: none;
318
+ }
319
+
320
+ .magnetic-field-puzzle__piece[data-field-piece-media]
321
+ .magnetic-field-puzzle__piece-identity-region {
322
+ inset-block: 68% 14%;
323
+ }
324
+
325
+ .magnetic-field-puzzle__piece[data-field-piece-media]
326
+ .magnetic-field-puzzle__identity {
327
+ font-size: clamp(0.625rem, 22cqi, var(--nox-font-size-md));
328
+ }
329
+
330
+ .magnetic-field-puzzle__piece[data-field-piece-media]:is(
331
+ [data-piece-selected],
332
+ :focus-visible
333
+ )
334
+ .magnetic-field-puzzle__piece-core {
335
+ filter: var(--piece-selection-filter);
336
+ }
337
+
338
+ @media (hover: hover) {
339
+ .magnetic-field-puzzle__piece[data-field-piece-media]:hover
340
+ .magnetic-field-puzzle__piece-core {
341
+ filter: var(--piece-hover-filter);
342
+ }
343
+
344
+ .magnetic-field-puzzle__piece[data-field-piece-media]:is(
345
+ [data-piece-selected],
346
+ :focus-visible
347
+ ):hover
348
+ .magnetic-field-puzzle__piece-core {
349
+ filter: var(--piece-hover-filter) var(--piece-selection-filter);
350
+ }
351
+ }
352
+
353
+ @media (width < 34rem) {
354
+ .magnetic-field-puzzle__piece[data-field-piece-media] {
355
+ margin: 2px;
356
+ }
357
+ }
358
+
290
359
  @media (prefers-reduced-motion: no-preference) {
291
360
  .magnetic-field-puzzle__piece[data-piece-moved] {
292
361
  animation: magnetic-field-slide 260ms var(--nox-motion-easing-standard);
@@ -1,3 +1,7 @@
1
+ import type { DisplayMedia } from '../../display/contracts';
2
+
3
+ export type MagneticFieldPuzzleMedia = Readonly<Record<string, DisplayMedia>>;
4
+
1
5
  export type MagneticFieldDirection = 'north' | 'east' | 'south' | 'west';
2
6
  export type MagneticFieldPolarity = 'north' | 'south';
3
7
  export type MagneticFieldPupilPosition = 'upper-end' | 'center' | 'lower-start';
@@ -147,7 +147,14 @@ const {
147
147
  </svg>
148
148
  )}
149
149
  {hole && (
150
- <span class="sliding-puzzle__hole" aria-hidden="true" />
150
+ <svg
151
+ class="sliding-puzzle__hole"
152
+ viewBox="0 0 100 100"
153
+ aria-hidden="true"
154
+ focusable="false"
155
+ >
156
+ <circle cx="50" cy="50" r="46.5" />
157
+ </svg>
151
158
  )}
152
159
  {target && (
153
160
  <span class="sliding-puzzle__target" aria-hidden="true">
@@ -69,11 +69,11 @@
69
69
 
70
70
  .sliding-puzzle__hole {
71
71
  display: none;
72
- inline-size: 72%;
72
+ inline-size: 84%;
73
73
  aspect-ratio: 1;
74
- border: calc(var(--nox-border-width) * 2) solid var(--sliding-transfer-color);
75
- border-radius: var(--nox-radius-pill);
76
- background: color-mix(
74
+ stroke: var(--sliding-transfer-color);
75
+ stroke-width: 2.5;
76
+ fill: color-mix(
77
77
  in srgb,
78
78
  var(--nox-source-tone-neutral) 20%,
79
79
  var(--nox-mix-dark)
@@ -99,7 +99,7 @@
99
99
 
100
100
  .sliding-puzzle__target {
101
101
  display: grid;
102
- inline-size: 90%;
102
+ inline-size: 84%;
103
103
  aspect-ratio: 1;
104
104
  pointer-events: none;
105
105
  }
@@ -115,7 +115,7 @@
115
115
  margin: var(--nox-space-1);
116
116
  padding: 0;
117
117
  place-items: center;
118
- border: 0;
118
+ border: var(--nox-border-width) solid transparent;
119
119
  border-radius: var(--nox-radius-sm);
120
120
  background: transparent;
121
121
  cursor: grab;
@@ -125,7 +125,7 @@
125
125
 
126
126
  .sliding-puzzle__piece-media {
127
127
  display: grid;
128
- inline-size: 80%;
128
+ inline-size: 84%;
129
129
  aspect-ratio: 1;
130
130
  pointer-events: none;
131
131
  }
@@ -169,6 +169,12 @@
169
169
  cursor: default;
170
170
  }
171
171
 
172
+ @media (width < 34rem) {
173
+ .sliding-puzzle__piece {
174
+ margin: 2px;
175
+ }
176
+ }
177
+
172
178
  @media (prefers-reduced-motion: no-preference) {
173
179
  .sliding-puzzle__piece[data-piece-moved] {
174
180
  animation: sliding-puzzle-move 260ms var(--nox-motion-easing-standard);
@@ -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);