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.
- package/README.md +25 -0
- package/dist/construct/index.browser.js +20431 -0
- package/dist/construct/index.browser.js.map +1 -0
- package/dist/construct/index.browser.min.js +42 -0
- package/dist/construct/index.browser.min.js.map +1 -0
- package/dist/construct/index.cjs +3720 -0
- package/dist/construct/index.cjs.map +1 -0
- package/dist/construct/index.d.ts +204 -0
- package/dist/construct/index.js +3718 -0
- package/dist/construct/index.js.map +1 -0
- package/dist/index.cjs +3920 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +340 -0
- package/dist/index.js +3917 -0
- package/dist/index.js.map +1 -0
- package/dist/prep/index.browser.js +20455 -0
- package/dist/prep/index.browser.js.map +1 -0
- package/dist/prep/index.browser.min.js +42 -0
- package/dist/prep/index.browser.min.js.map +1 -0
- package/dist/prep/index.cjs +3744 -0
- package/dist/prep/index.cjs.map +1 -0
- package/dist/prep/index.d.ts +139 -0
- package/dist/prep/index.js +3742 -0
- package/dist/prep/index.js.map +1 -0
- package/package.json +77 -0
- package/src/core/components/README.md +249 -0
- package/src/core/components/board/BoardView.tsx +352 -0
- package/src/core/components/board/GameBoard.tsx +682 -0
- package/src/core/components/board/index.ts +70 -0
- package/src/core/components/board/useAnchorGrid.ts +110 -0
- package/src/core/components/board/useClickController.ts +436 -0
- package/src/core/components/board/useDragController.ts +1051 -0
- package/src/core/components/board/usePieceState.ts +178 -0
- package/src/core/components/board/utils.ts +76 -0
- package/src/core/components/index.ts +33 -0
- package/src/core/components/pieces/BlueprintRing.tsx +238 -0
- package/src/core/config/config.ts +85 -0
- package/src/core/domain/blueprints.ts +25 -0
- package/src/core/domain/layout.ts +159 -0
- package/src/core/domain/primitives.ts +159 -0
- package/src/core/domain/solve.ts +184 -0
- package/src/core/domain/types.ts +111 -0
- package/src/core/engine/collision/grid-snapping.ts +283 -0
- package/src/core/engine/collision/index.ts +4 -0
- package/src/core/engine/collision/sat-collision.ts +46 -0
- package/src/core/engine/collision/validation.ts +166 -0
- package/src/core/engine/geometry/bounds.ts +91 -0
- package/src/core/engine/geometry/collision.ts +64 -0
- package/src/core/engine/geometry/index.ts +19 -0
- package/src/core/engine/geometry/math.ts +101 -0
- package/src/core/engine/geometry/pieces.ts +290 -0
- package/src/core/engine/geometry/polygons.ts +43 -0
- package/src/core/engine/state/BaseGameController.ts +368 -0
- package/src/core/engine/validation/border-rendering.ts +318 -0
- package/src/core/engine/validation/complete.ts +102 -0
- package/src/core/engine/validation/face-to-face.ts +217 -0
- package/src/core/index.ts +3 -0
- package/src/core/io/InteractionTracker.ts +742 -0
- package/src/core/io/data-tracking.ts +271 -0
- package/src/core/io/json-to-tangram-spec.ts +110 -0
- package/src/core/io/quickstash.ts +141 -0
- package/src/core/io/stims.ts +110 -0
- package/src/core/types/index.ts +5 -0
- package/src/core/types/plugin-interfaces.ts +101 -0
- package/src/index.spec.ts +19 -0
- package/src/index.ts +2 -0
- package/src/plugins/tangram-construct/ConstructionApp.tsx +105 -0
- package/src/plugins/tangram-construct/index.ts +156 -0
- package/src/plugins/tangram-prep/PrepApp.tsx +182 -0
- package/src/plugins/tangram-prep/index.ts +122 -0
- package/tangram-construct.min.js +42 -0
- 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
|
+
}
|