jspsych-tangram 0.0.1

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 (72) hide show
  1. package/README.md +25 -0
  2. package/dist/construct/index.browser.js +20431 -0
  3. package/dist/construct/index.browser.js.map +1 -0
  4. package/dist/construct/index.browser.min.js +42 -0
  5. package/dist/construct/index.browser.min.js.map +1 -0
  6. package/dist/construct/index.cjs +3720 -0
  7. package/dist/construct/index.cjs.map +1 -0
  8. package/dist/construct/index.d.ts +204 -0
  9. package/dist/construct/index.js +3718 -0
  10. package/dist/construct/index.js.map +1 -0
  11. package/dist/index.cjs +3920 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.d.ts +340 -0
  14. package/dist/index.js +3917 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/prep/index.browser.js +20455 -0
  17. package/dist/prep/index.browser.js.map +1 -0
  18. package/dist/prep/index.browser.min.js +42 -0
  19. package/dist/prep/index.browser.min.js.map +1 -0
  20. package/dist/prep/index.cjs +3744 -0
  21. package/dist/prep/index.cjs.map +1 -0
  22. package/dist/prep/index.d.ts +139 -0
  23. package/dist/prep/index.js +3742 -0
  24. package/dist/prep/index.js.map +1 -0
  25. package/package.json +77 -0
  26. package/src/core/components/README.md +249 -0
  27. package/src/core/components/board/BoardView.tsx +352 -0
  28. package/src/core/components/board/GameBoard.tsx +682 -0
  29. package/src/core/components/board/index.ts +70 -0
  30. package/src/core/components/board/useAnchorGrid.ts +110 -0
  31. package/src/core/components/board/useClickController.ts +436 -0
  32. package/src/core/components/board/useDragController.ts +1051 -0
  33. package/src/core/components/board/usePieceState.ts +178 -0
  34. package/src/core/components/board/utils.ts +76 -0
  35. package/src/core/components/index.ts +33 -0
  36. package/src/core/components/pieces/BlueprintRing.tsx +238 -0
  37. package/src/core/config/config.ts +85 -0
  38. package/src/core/domain/blueprints.ts +25 -0
  39. package/src/core/domain/layout.ts +159 -0
  40. package/src/core/domain/primitives.ts +159 -0
  41. package/src/core/domain/solve.ts +184 -0
  42. package/src/core/domain/types.ts +111 -0
  43. package/src/core/engine/collision/grid-snapping.ts +283 -0
  44. package/src/core/engine/collision/index.ts +4 -0
  45. package/src/core/engine/collision/sat-collision.ts +46 -0
  46. package/src/core/engine/collision/validation.ts +166 -0
  47. package/src/core/engine/geometry/bounds.ts +91 -0
  48. package/src/core/engine/geometry/collision.ts +64 -0
  49. package/src/core/engine/geometry/index.ts +19 -0
  50. package/src/core/engine/geometry/math.ts +101 -0
  51. package/src/core/engine/geometry/pieces.ts +290 -0
  52. package/src/core/engine/geometry/polygons.ts +43 -0
  53. package/src/core/engine/state/BaseGameController.ts +368 -0
  54. package/src/core/engine/validation/border-rendering.ts +318 -0
  55. package/src/core/engine/validation/complete.ts +102 -0
  56. package/src/core/engine/validation/face-to-face.ts +217 -0
  57. package/src/core/index.ts +3 -0
  58. package/src/core/io/InteractionTracker.ts +742 -0
  59. package/src/core/io/data-tracking.ts +271 -0
  60. package/src/core/io/json-to-tangram-spec.ts +110 -0
  61. package/src/core/io/quickstash.ts +141 -0
  62. package/src/core/io/stims.ts +110 -0
  63. package/src/core/types/index.ts +5 -0
  64. package/src/core/types/plugin-interfaces.ts +101 -0
  65. package/src/index.spec.ts +19 -0
  66. package/src/index.ts +2 -0
  67. package/src/plugins/tangram-construct/ConstructionApp.tsx +105 -0
  68. package/src/plugins/tangram-construct/index.ts +156 -0
  69. package/src/plugins/tangram-prep/PrepApp.tsx +182 -0
  70. package/src/plugins/tangram-prep/index.ts +122 -0
  71. package/tangram-construct.min.js +42 -0
  72. package/tangram-prep.min.js +42 -0
@@ -0,0 +1,682 @@
1
+ /**
2
+ * GameBoard - Core reusable tangram game board component
3
+ *
4
+ * This component provides the complete tangram game functionality that can be
5
+ * shared between jsPsych plugins. It handles both construction and preparation
6
+ * game modes with full interaction support.
7
+ *
8
+ * ## Key Features
9
+ * - **Dependency Injection**: All game content provided via props (no hardcoded data)
10
+ * - **Multiple Input Modes**: Supports both click and drag interactions
11
+ * - **Layout Flexibility**: Circle and semicircle layout modes
12
+ * - **Event Tracking**: Comprehensive data collection for research
13
+ * - **State Management**: Uses BaseGameController for robust state handling
14
+ *
15
+ * ## Architecture
16
+ * - Uses modern React patterns with hooks
17
+ * - Integrates with BaseGameController for state management
18
+ * - Renders via Board component with computed layout
19
+ * - Handles completion events and data collection
20
+ *
21
+ * ## Usage in Plugins
22
+ * ```typescript
23
+ * // In jsPsych plugin trial method:
24
+ * const root = createRoot(display_element);
25
+ * root.render(React.createElement(GameBoard, {
26
+ * sectors: convertedSectors,
27
+ * quickstash: convertedMacros,
28
+ * primitives: PRIMITIVE_BLUEPRINTS,
29
+ * layout: "semicircle",
30
+ * target: "silhouette",
31
+ * input: "drag",
32
+ * onComplete: handleTrialComplete
33
+ * }));
34
+ * ```
35
+ *
36
+ * @see {@link BaseGameController} For state management details
37
+ * @see {@link Board} For rendering implementation
38
+ * @since Phase 3.3 - Extracted from monolithic Board component
39
+ * @author Claude Code Assistant
40
+ */
41
+
42
+ import React from "react";
43
+ import { BaseGameController } from "@/core/engine/state/BaseGameController";
44
+ import type {
45
+ Sector,
46
+ Blueprint,
47
+ PrimitiveBlueprint,
48
+ LayoutMode,
49
+ PlacementTarget,
50
+ InputMode
51
+ } from "@/core/domain/types";
52
+ import { computeCircleLayout, rectForBand } from "@/core/domain/layout";
53
+ import { solveLogicalBox } from "@/core/domain/solve";
54
+ import type { Poly } from "@/core/domain/types";
55
+ import { CONFIG } from "@/core/config/config";
56
+ import BoardView, { type AnchorDots as DotsType } from "./BoardView";
57
+ import {
58
+ inferUnitFromPolys,
59
+ placeSilhouetteGridAlignedAsPolys } from "@/core/engine/geometry";
60
+ import { anchorsSilhouetteComplete, anchorsWorkspaceComplete } from "@/core/engine/validation/complete";
61
+ import { scalePolys } from "@/core/components/board/utils";
62
+ import { usePieceState } from "@/core/components/board/usePieceState";
63
+ import { useAnchorGrid } from "@/core/components/board/useAnchorGrid";
64
+ import { useDragController } from "@/core/components/board/useDragController";
65
+ import { useClickController } from "@/core/components/board/useClickController";
66
+ import { InteractionTracker } from "@/core/io/InteractionTracker";
67
+
68
+ /**
69
+ * Configuration interface for GameBoard component
70
+ *
71
+ * This interface defines all the required game content and behavior settings
72
+ * that must be provided to the GameBoard component. All properties use
73
+ * dependency injection - no defaults are provided.
74
+ */
75
+ export interface GameBoardConfig {
76
+ /**
77
+ * Array of sector definitions containing target silhouettes to construct
78
+ * Each sector represents one puzzle to solve
79
+ */
80
+ sectors: Sector[];
81
+
82
+ /**
83
+ * Array of pre-made composite piece blueprints (macros)
84
+ * Usually created in prep trials and passed to construction trials
85
+ */
86
+ quickstash: Blueprint[];
87
+
88
+ /**
89
+ * Array of primitive tangram piece definitions
90
+ * These are the basic 7 tangram shapes that never change
91
+ */
92
+ primitives: PrimitiveBlueprint[];
93
+
94
+ /**
95
+ * Layout mode for the game board
96
+ * - "circle": Full circular layout with pieces arranged in complete circle
97
+ * - "semicircle": Half-circle layout with pieces in arc above workspace
98
+ */
99
+ layout: LayoutMode;
100
+
101
+ /**
102
+ * Target placement mode for piece validation
103
+ * - "workspace": Pieces can be placed freely in workspace area
104
+ * - "silhouette": Pieces must be placed within target silhouette bounds
105
+ */
106
+ target: PlacementTarget;
107
+
108
+ /**
109
+ * Input interaction mode
110
+ * - "click": Click to select pieces, click to place (better for touch)
111
+ * - "drag": Drag pieces directly with mouse/touch
112
+ */
113
+ input: InputMode;
114
+
115
+ /**
116
+ * Time limit for the trial in milliseconds
117
+ * Set to 0 for no time limit
118
+ */
119
+ timeLimitMs: number;
120
+
121
+ /**
122
+ * Maximum number of quickstash/macro slots to display
123
+ * Controls size of the macro selection ring
124
+ */
125
+ maxQuickstashSlots: number;
126
+
127
+ /**
128
+ * Maximum number of primitive pieces allowed in a composite
129
+ * Used for validation when creating macros (prep mode only)
130
+ */
131
+ maxCompositeSize?: number;
132
+
133
+ /**
134
+ * Game mode to determine behavior differences
135
+ * - "construction": Standard puzzle-solving mode with toggle
136
+ * - "prep": Macro creation mode with primitives-only center
137
+ */
138
+ mode?: "construction" | "prep";
139
+
140
+ /**
141
+ * Minimum pieces required per macro (prep mode only)
142
+ * Used for submit button validation
143
+ */
144
+ minPiecesPerMacro?: number;
145
+
146
+ /**
147
+ * Whether all slots must be filled to complete trial (prep mode only)
148
+ * Used for submit button validation
149
+ */
150
+ requireAllSlots?: boolean;
151
+
152
+ /** Optional CSS width for the game board SVG */
153
+ width?: number;
154
+
155
+ /** Optional CSS height for the game board SVG */
156
+ height?: number;
157
+ }
158
+
159
+ /**
160
+ * Props interface for GameBoard component, extending configuration with event callbacks
161
+ *
162
+ * This interface includes all configuration options plus optional event handlers
163
+ * that plugins can use to respond to game events and collect data.
164
+ */
165
+ export interface GameBoardProps extends GameBoardConfig {
166
+ /**
167
+ * Called when an individual sector is completed
168
+ * Useful for tracking progress through multi-sector trials
169
+ */
170
+ onSectorComplete?: (sectorId: string, snapshot: any) => void;
171
+
172
+ /**
173
+ * Called when a piece is successfully placed in a sector
174
+ * Useful for real-time interaction tracking
175
+ */
176
+ onPiecePlace?: (pieceId: string, sectorId: string) => void;
177
+
178
+ /**
179
+ * Called when a piece is removed from a sector
180
+ * Useful for tracking piece manipulation patterns
181
+ */
182
+ onPieceRemove?: (pieceId: string) => void;
183
+
184
+ /**
185
+ * Called immediately after each complete interaction (piece pickup + placedown)
186
+ * Use for incremental data saving (e.g., socket.emit())
187
+ */
188
+ onInteraction?: (event: any) => void;
189
+
190
+ /**
191
+ * Called once when trial ends
192
+ * Contains complete interaction history + final state
193
+ * This is the primary mechanism for trial completion - plugins should handle
194
+ * React cleanup and jsPsych.finishTrial() in their wrapper for this callback
195
+ */
196
+ onTrialEnd?: (data: any) => void;
197
+
198
+ /**
199
+ * Called when the game controller is ready
200
+ * Useful for spawning initial pieces or setting up state
201
+ */
202
+ onControllerReady?: (controller: BaseGameController, layout?: any, force?: () => void) => void;
203
+ }
204
+
205
+ /**
206
+ * Core GameBoard component that encapsulates all tangram game functionality
207
+ *
208
+ * This is the main reusable component that provides complete tangram game
209
+ * functionality for jsPsych plugins. It handles all aspects of the game
210
+ * including piece interaction, collision detection, completion validation,
211
+ * and data collection.
212
+ *
213
+ * ## Key Responsibilities
214
+ * - Creates and manages BaseGameController for state management
215
+ * - Computes layout geometry for board rendering
216
+ * - Handles event callbacks for plugin integration
217
+ * - Manages CSS sizing for responsive display
218
+ *
219
+ * ## State Management
220
+ * Uses BaseGameController internally which provides:
221
+ * - Piece placement and validation
222
+ * - Sector completion tracking
223
+ * - Event logging for data collection
224
+ * - Collision detection and snapping
225
+ *
226
+ * @param props - Configuration and event callbacks for the game
227
+ * @returns React component rendering the complete tangram game interface
228
+ */
229
+ export default function GameBoard(props: GameBoardProps) {
230
+ const {
231
+ sectors,
232
+ quickstash,
233
+ primitives,
234
+ layout: layoutMode,
235
+ target,
236
+ input,
237
+ timeLimitMs,
238
+ maxQuickstashSlots,
239
+ maxCompositeSize,
240
+ mode,
241
+ width: _width,
242
+ height: _height,
243
+ onSectorComplete,
244
+ onPiecePlace,
245
+ onPieceRemove,
246
+ onInteraction,
247
+ onTrialEnd,
248
+ onControllerReady
249
+ } = props;
250
+
251
+ // Initialize game controller with injected data
252
+ const controller = React.useMemo(() => {
253
+ const gameConfig = {
254
+ n: sectors.length,
255
+ layout: layoutMode,
256
+ target,
257
+ input,
258
+ timeLimitMs,
259
+ maxQuickstashSlots,
260
+ ...(maxCompositeSize !== undefined && { maxCompositeSize }),
261
+ mode: mode || "construction", // Default to construction mode for backwards compatibility
262
+ ...(props.minPiecesPerMacro !== undefined && { minPiecesPerMacro: props.minPiecesPerMacro }),
263
+ ...(props.requireAllSlots !== undefined && { requireAllSlots: props.requireAllSlots })
264
+ };
265
+
266
+ return new BaseGameController(
267
+ sectors,
268
+ quickstash,
269
+ primitives,
270
+ gameConfig
271
+ );
272
+ }, [sectors, quickstash, primitives, layoutMode, target, input, timeLimitMs, maxQuickstashSlots, maxCompositeSize, mode, props.minPiecesPerMacro, props.requireAllSlots]);
273
+
274
+ // Initialize interaction tracker for data collection (if callbacks provided)
275
+ const tracker = React.useMemo(() => {
276
+ if (!onInteraction && !onTrialEnd) return null;
277
+
278
+ const callbacks: { onInteraction?: (event: any) => void; onTrialEnd?: (data: any) => void } = {};
279
+ if (onInteraction) callbacks.onInteraction = onInteraction;
280
+ if (onTrialEnd) callbacks.onTrialEnd = onTrialEnd;
281
+
282
+ return new InteractionTracker(controller, callbacks);
283
+ }, [controller, onInteraction, onTrialEnd]);
284
+
285
+ // Call onControllerReady when controller is ready
286
+ React.useEffect(() => {
287
+ if (onControllerReady) {
288
+ onControllerReady(controller);
289
+ }
290
+ }, [controller, onControllerReady]);
291
+
292
+ // Cleanup tracker on unmount
293
+ React.useEffect(() => {
294
+ return () => {
295
+ if (tracker) {
296
+ tracker.dispose();
297
+ }
298
+ };
299
+ }, [tracker]);
300
+
301
+ // Compute layout geometry
302
+ const { layout, viewBox } = React.useMemo(() => {
303
+ const nSectors = sectors.length;
304
+ const sectorIds = sectors.map(s => s.id);
305
+ const masksPerSector: Poly[][] = sectors.map(s => s.silhouette.mask ?? []);
306
+
307
+ const logicalBox = solveLogicalBox({
308
+ n: nSectors,
309
+ layoutMode,
310
+ target,
311
+ qsMaxSlots: maxQuickstashSlots,
312
+ primitivesSlots: primitives.length,
313
+ layoutPadPx: CONFIG.layout.paddingPx,
314
+ masks: masksPerSector,
315
+ });
316
+
317
+ const layout = computeCircleLayout(
318
+ { w: logicalBox.LOGICAL_W, h: logicalBox.LOGICAL_H },
319
+ nSectors,
320
+ sectorIds,
321
+ layoutMode,
322
+ target,
323
+ {
324
+ qsMaxSlots: maxQuickstashSlots,
325
+ primitivesSlots: primitives.length,
326
+ masks: masksPerSector,
327
+ }
328
+ );
329
+
330
+ return {
331
+ layout,
332
+ viewBox: { w: logicalBox.LOGICAL_W, h: logicalBox.LOGICAL_H }
333
+ };
334
+ }, [sectors, layoutMode, target, maxQuickstashSlots, primitives.length]);
335
+
336
+ // Force re-render utility
337
+ const [, force] = React.useReducer((x) => x + 1, 0);
338
+
339
+ // Call onControllerReady when controller and layout are ready
340
+ React.useEffect(() => {
341
+ if (onControllerReady) {
342
+ onControllerReady(controller, layout, force);
343
+ }
344
+ }, [controller, layout, onControllerReady, force]);
345
+
346
+ // Game completion tracking
347
+ const [gameCompleted, setGameCompleted] = React.useState(false);
348
+
349
+ // Watch for game completion
350
+ React.useEffect(() => {
351
+ const checkGameCompletion = () => {
352
+ const allSectorsComplete = sectors.every(s =>
353
+ controller.isSectorCompleted(s.id)
354
+ );
355
+
356
+ if (allSectorsComplete && !gameCompleted) {
357
+ setGameCompleted(true);
358
+
359
+ // Finalize trial data tracking (which calls onTrialEnd)
360
+ if (tracker) {
361
+ tracker.finalizeTrial('auto_complete');
362
+ }
363
+ }
364
+ };
365
+
366
+ // Check completion whenever controller state updates
367
+ checkGameCompletion();
368
+ }, [controller.updateCount, sectors, gameCompleted, controller, tracker]);
369
+
370
+ // Sector completion callback
371
+ const handleSectorComplete = React.useCallback((sectorId: string) => {
372
+ if (onSectorComplete) {
373
+ onSectorComplete(sectorId, controller.snapshot());
374
+ }
375
+ }, [onSectorComplete, controller]);
376
+
377
+ // Event callbacks for plugin integration
378
+ const eventCallbacks = React.useMemo(() => ({
379
+ onSectorComplete: handleSectorComplete,
380
+ onPiecePlace: onPiecePlace || (() => {}),
381
+ onPieceRemove: onPieceRemove || (() => {})
382
+ }), [handleSectorComplete, onPiecePlace, onPieceRemove]);
383
+
384
+ // Calculate sizing based on layout mode
385
+ const getGameboardStyle = () => {
386
+ const baseStyle = {
387
+ margin: '0 auto',
388
+ display: 'flex',
389
+ alignItems: 'center',
390
+ justifyContent: 'center',
391
+ position: 'relative' as const,
392
+ };
393
+
394
+ if (layoutMode === 'circle') {
395
+ // Circle mode: square aspect ratio (height == width)
396
+ const size = Math.min(window.innerWidth * 0.96, window.innerHeight * 0.96);
397
+ return {
398
+ ...baseStyle,
399
+ width: `${size}px`,
400
+ height: `${size}px`,
401
+ };
402
+ } else {
403
+ // Semicircle mode: width is 2x height
404
+ const maxWidth = Math.min(window.innerWidth * 0.96, window.innerHeight * 0.96 * 2);
405
+ const maxHeight = Math.min(window.innerWidth * 0.96 / 2, window.innerHeight * 0.96);
406
+ return {
407
+ ...baseStyle,
408
+ width: `${maxWidth}px`,
409
+ height: `${maxHeight}px`,
410
+ };
411
+ }
412
+ };
413
+
414
+ // Calculate SVG dimensions to pass to Board component
415
+ const getSvgDimensions = () => {
416
+ if (layoutMode === 'circle') {
417
+ // Circle mode: square aspect ratio (height == width)
418
+ const size = Math.min(window.innerWidth * 0.96, window.innerHeight * 0.96);
419
+ return { width: size, height: size };
420
+ } else {
421
+ // Semicircle mode: width is 2x height
422
+ const maxWidth = Math.min(window.innerWidth * 0.96, window.innerHeight * 0.96 * 2);
423
+ const maxHeight = Math.min(window.innerWidth * 0.96 / 2, window.innerHeight * 0.96);
424
+ return { width: maxWidth, height: maxHeight };
425
+ }
426
+ };
427
+
428
+ const svgDimensions = getSvgDimensions();
429
+
430
+ React.useEffect(() => {
431
+ if (controller.state.cfg.mode !== "construction") return;
432
+ if (!timeLimitMs || timeLimitMs <= 0) return;
433
+ if (gameCompleted) return;
434
+
435
+ const timeoutId = window.setTimeout(() => {
436
+ if (gameCompleted) return;
437
+
438
+ if (!controller.state.endedAt) {
439
+ controller.state.endedAt = performance.now();
440
+ force();
441
+ }
442
+
443
+ tracker?.finalizeTrial('timeout');
444
+ setGameCompleted(true);
445
+ }, timeLimitMs);
446
+
447
+ return () => {
448
+ window.clearTimeout(timeoutId);
449
+ };
450
+ }, [controller, timeLimitMs, gameCompleted, tracker, force]);
451
+
452
+ const isSectorLocked = (id: string) => controller.isSectorCompleted(id);
453
+
454
+ // Extract piece management to custom hook
455
+ const { pieces, pieceById, getSectorPiecePolysCached } = usePieceState(controller);
456
+
457
+ const clickMode = controller.state.cfg.input === "click";
458
+
459
+ // Center badge geometry (visual + click target are identical)
460
+ const badgeR = Math.max(CONFIG.size.centerBadge.minPx, layout.innerR * CONFIG.size.centerBadge.fractionOfOuterR);
461
+ const badgeCenter = (controller.state.cfg.layout === "semicircle")
462
+ ? { x: layout.cx, y: layout.cy - badgeR - CONFIG.size.centerBadge.marginPx }
463
+ : { x: layout.cx, y: layout.cy };
464
+ const isInsideBadge = (px: number, py: number) =>
465
+ Math.hypot(px - badgeCenter.x, py - badgeCenter.y) <= badgeR;
466
+
467
+ // All sector masks (raw)
468
+ const allMasks: Poly[][] = React.useMemo(() => {
469
+ return controller.state.cfg.sectors.map(sec => (sec.silhouette.mask ?? []));
470
+ }, [controller.state.cfg.sectors]);
471
+
472
+ // Canonical lattice scale: 1 raw UNIT → CONFIG.layout.grid.unitPx (so a unit edge = 2 grid nodes)
473
+ // Keep a single scale so lattice density stays invariant across devices/zoom levels.
474
+ const scaleS = React.useMemo(() => {
475
+ const u = inferUnitFromPolys(allMasks.flat());
476
+ return u ? (CONFIG.layout.grid.unitPx / u) : 1;
477
+ }, [allMasks]);
478
+
479
+ // Extract anchor grid logic to custom hook
480
+ const { anchorDots } = useAnchorGrid(controller, layout, scaleS);
481
+
482
+ // Placed silhouettes per sector (grid-aligned polys used for inclusion tests)
483
+ const placedSilBySector = React.useMemo(() => {
484
+ const m = new Map<string, Poly[]>();
485
+ for (const s of layout.sectors) {
486
+ const mask = controller.state.cfg.sectors.find(ss => ss.id === s.id)?.silhouette.mask ?? [];
487
+ if (!mask?.length) continue;
488
+ const rect = rectForBand(layout, s, "silhouette", 1.0);
489
+ const placed = placeSilhouetteGridAlignedAsPolys(mask, scaleS, { cx: rect.cx, cy: rect.cy });
490
+ m.set(s.id, placed);
491
+ }
492
+ return m;
493
+ // eslint-disable-next-line react-hooks/exhaustive-deps
494
+ }, [layout, scaleS, controller.state.cfg.sectors]);
495
+
496
+ const maybeCompleteSector = (secId: string): boolean => {
497
+ if (!secId) return false;
498
+ if (isSectorLocked(secId)) return false;
499
+
500
+ if (controller.state.cfg.target === "silhouette") {
501
+ const sil = placedSilBySector.get(secId) ?? [];
502
+ if (!sil.length) return false;
503
+ const piecePolys = getSectorPiecePolysCached(secId);
504
+ if (anchorsSilhouetteComplete(sil, piecePolys)) {
505
+ controller.markSectorCompleted(secId);
506
+ return true;
507
+ }
508
+ } else {
509
+ const raw = controller.state.cfg.sectors.find(ss => ss.id === secId)?.silhouette.mask ?? [];
510
+ if (!raw.length) return false;
511
+ const scaledSil = scalePolys(raw, scaleS);
512
+ const piecePolys = getSectorPiecePolysCached(secId);
513
+ if (anchorsWorkspaceComplete(scaledSil, piecePolys)) {
514
+ controller.markSectorCompleted(secId);
515
+ return true;
516
+ }
517
+ }
518
+ return false;
519
+ };
520
+
521
+ // ---- drag (RAF) - Extracted to useDragController hook ---
522
+ const dragController = useDragController(
523
+ controller,
524
+ layout,
525
+ pieces,
526
+ pieceById,
527
+ anchorDots,
528
+ placedSilBySector,
529
+ isSectorLocked,
530
+ maybeCompleteSector,
531
+ force,
532
+ undefined, // clickController - will be set later
533
+ tracker // Pass tracker for data collection
534
+ );
535
+
536
+ const {
537
+ draggingId,
538
+ dragInvalid,
539
+ svgRef,
540
+ onPiecePointerDown,
541
+ onBlueprintPointerDown,
542
+ onPointerMove,
543
+ onPointerUp,
544
+ setPieceRef,
545
+ setDraggingId,
546
+ lockedPieceId,
547
+ dragRef // TODO: Remove once click mode is extracted
548
+ } = dragController;
549
+
550
+ // ---- CLICK controller - Extracted click mode logic ---
551
+ const svgPoint = (clientX: number, clientY: number) => {
552
+ const svg = svgRef.current!;
553
+ const pt = svg.createSVGPoint();
554
+ pt.x = clientX; pt.y = clientY;
555
+ const ctm = svg.getScreenCTM();
556
+ if (!ctm) return { x: 0, y: 0 };
557
+ const sp = pt.matrixTransform(ctm.inverse());
558
+ return { x: sp.x, y: sp.y };
559
+ };
560
+
561
+ const clickController = useClickController(
562
+ controller,
563
+ layout,
564
+ pieces,
565
+ clickMode,
566
+ draggingId,
567
+ setDraggingId,
568
+ dragRef,
569
+ isInsideBadge,
570
+ isSectorLocked,
571
+ maybeCompleteSector,
572
+ svgPoint,
573
+ force,
574
+ tracker // Pass tracker for data collection
575
+ );
576
+
577
+ const {
578
+ selectedPieceId,
579
+ onRootPointerDown
580
+ } = clickController;
581
+
582
+ const onCenterBadgePointerDown = (e: React.PointerEvent) => {
583
+ if (draggingId) return;
584
+ const { x, y } = svgPoint(e.clientX, e.clientY);
585
+
586
+ if (controller.state.cfg.mode === "prep") {
587
+ if (!controller.isSubmitEnabled() || gameCompleted) {
588
+ e.stopPropagation();
589
+ return;
590
+ }
591
+
592
+ setGameCompleted(true);
593
+ if (tracker) {
594
+ tracker.finalizeTrial('submit');
595
+ }
596
+ e.stopPropagation();
597
+ return;
598
+ }
599
+
600
+ // Construction mode: toggle blueprint view
601
+ if (tracker?.recordClickEvent) {
602
+ const from = controller.state.blueprintView;
603
+ const to = from === "primitives" ? "quickstash" : "primitives";
604
+ tracker.recordClickEvent({ x, y }, 'blueprint_view_switch', {
605
+ blueprintViewSwitch: { from, to }
606
+ });
607
+ }
608
+ controller.switchBlueprintView();
609
+ force();
610
+ e.stopPropagation();
611
+ };
612
+
613
+ return (
614
+ <div className="tangram-gameboard" style={getGameboardStyle()}>
615
+ <BoardView
616
+ controller={controller}
617
+ layout={layout}
618
+ viewBox={viewBox}
619
+ width={svgDimensions.width}
620
+ height={svgDimensions.height}
621
+ badgeR={badgeR}
622
+ badgeCenter={badgeCenter}
623
+ placedSilBySector={placedSilBySector}
624
+ anchorDots={anchorDots as DotsType[]}
625
+ pieces={pieces}
626
+ clickMode={clickMode}
627
+ draggingId={draggingId}
628
+ selectedPieceId={selectedPieceId}
629
+ dragInvalid={dragInvalid}
630
+ lockedPieceId={lockedPieceId}
631
+ svgRef={svgRef}
632
+ setPieceRef={setPieceRef}
633
+ onPiecePointerDown={onPiecePointerDown}
634
+ onBlueprintPointerDown={onBlueprintPointerDown}
635
+ onRootPointerDown={onRootPointerDown}
636
+ onPointerMove={onPointerMove}
637
+ onPointerUp={onPointerUp}
638
+ onCenterBadgePointerDown={onCenterBadgePointerDown}
639
+ {...eventCallbacks}
640
+ />
641
+ </div>
642
+ );
643
+ }
644
+
645
+ /**
646
+ * Hook for managing GameBoard state externally
647
+ * Useful for plugins that need direct access to game state
648
+ */
649
+ export function useGameBoard(config: GameBoardConfig) {
650
+ const controller = React.useMemo(() => {
651
+ const gameConfig = {
652
+ n: config.sectors.length,
653
+ layout: config.layout,
654
+ target: config.target,
655
+ input: config.input,
656
+ timeLimitMs: config.timeLimitMs,
657
+ maxQuickstashSlots: config.maxQuickstashSlots,
658
+ ...(config.maxCompositeSize !== undefined && { maxCompositeSize: config.maxCompositeSize }),
659
+ mode: config.mode || "construction",
660
+ ...(config.minPiecesPerMacro !== undefined && { minPiecesPerMacro: config.minPiecesPerMacro }),
661
+ ...(config.requireAllSlots !== undefined && { requireAllSlots: config.requireAllSlots })
662
+ };
663
+
664
+ return new BaseGameController(
665
+ config.sectors,
666
+ config.quickstash,
667
+ config.primitives,
668
+ gameConfig
669
+ );
670
+ }, [config]);
671
+
672
+ const snapshot = React.useMemo(() =>
673
+ controller.snapshot(),
674
+ [controller.updateCount]
675
+ );
676
+
677
+ return {
678
+ controller,
679
+ snapshot,
680
+ isComplete: !!controller.state.endedAt
681
+ };
682
+ }