jspsych-tangram 0.0.7 → 0.0.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.
- package/dist/construct/index.browser.js +187 -214
- package/dist/construct/index.browser.js.map +1 -1
- package/dist/construct/index.browser.min.js +13 -10
- package/dist/construct/index.browser.min.js.map +1 -1
- package/dist/construct/index.cjs +187 -214
- package/dist/construct/index.cjs.map +1 -1
- package/dist/construct/index.d.ts +24 -0
- package/dist/construct/index.js +187 -214
- package/dist/construct/index.js.map +1 -1
- package/dist/index.cjs +198 -215
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +36 -0
- package/dist/index.js +198 -215
- package/dist/index.js.map +1 -1
- package/dist/prep/index.browser.js +173 -213
- package/dist/prep/index.browser.js.map +1 -1
- package/dist/prep/index.browser.min.js +14 -11
- package/dist/prep/index.browser.min.js.map +1 -1
- package/dist/prep/index.cjs +173 -213
- package/dist/prep/index.cjs.map +1 -1
- package/dist/prep/index.d.ts +12 -0
- package/dist/prep/index.js +173 -213
- package/dist/prep/index.js.map +1 -1
- package/package.json +1 -1
- package/src/assets/README.md +6 -0
- package/src/assets/images.d.ts +19 -0
- package/src/assets/locked.png +0 -0
- package/src/assets/unlocked.png +0 -0
- package/src/core/components/board/BoardView.tsx +121 -39
- package/src/core/components/board/GameBoard.tsx +123 -7
- package/src/core/config/config.ts +8 -4
- package/src/core/domain/types.ts +1 -0
- package/src/core/io/InteractionTracker.ts +23 -24
- package/src/core/io/data-tracking.ts +6 -7
- package/src/plugins/tangram-construct/ConstructionApp.tsx +16 -1
- package/src/plugins/tangram-construct/index.ts +14 -0
- package/src/plugins/tangram-prep/PrepApp.tsx +6 -0
- package/src/plugins/tangram-prep/index.ts +7 -0
- package/tangram-construct.min.js +13 -10
- package/tangram-prep.min.js +14 -11
package/package.json
CHANGED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Assets Directory
|
|
2
|
+
|
|
3
|
+
- `locked.png` - Icon displayed when quickstash view is active (locked to quickstash only)
|
|
4
|
+
- `unlocked.png` - Icon displayed when primitives view is active (unlocked/full inventory)
|
|
5
|
+
|
|
6
|
+
These icons appear in the center badge button that switches between blueprint views.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
declare module "*.png" {
|
|
2
|
+
const value: string;
|
|
3
|
+
export default value;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
declare module "*.jpg" {
|
|
7
|
+
const value: string;
|
|
8
|
+
export default value;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
declare module "*.jpeg" {
|
|
12
|
+
const value: string;
|
|
13
|
+
export default value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
declare module "*.svg" {
|
|
17
|
+
const value: string;
|
|
18
|
+
export default value;
|
|
19
|
+
}
|
|
Binary file
|
|
Binary file
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import type { Poly, TanKind, Blueprint, PrimitiveBlueprint } from "@/core/domain/types";
|
|
3
|
-
import { wedgePath, type CircleLayout } from "@/core/domain/layout";
|
|
4
|
-
import { boundsOfBlueprint } from "@/core/engine/geometry";
|
|
3
|
+
import { wedgePath, rectForBand, type CircleLayout } from "@/core/domain/layout";
|
|
4
|
+
import { boundsOfBlueprint, placeSilhouetteGridAlignedAsPolys } from "@/core/engine/geometry";
|
|
5
5
|
import { CONFIG } from "@/core/config/config";
|
|
6
6
|
import { generateEdgeStrokePaths, shouldUseSelectiveBorders, shouldShowBorders, getHiddenEdgesForPolygon } from "@/core/engine/validation/border-rendering";
|
|
7
7
|
|
|
8
|
+
// Import lock/unlock icons
|
|
9
|
+
import lockedIcon from "@/assets/locked.png";
|
|
10
|
+
import unlockedIcon from "@/assets/unlocked.png";
|
|
11
|
+
|
|
8
12
|
export type PieceView = { id: string; blueprintId: string; x: number; y: number; sectorId?: string };
|
|
9
13
|
export type AnchorDots = { sectorId: string; valid: {x:number;y:number}[]; invalid: {x:number;y:number}[] };
|
|
10
14
|
|
|
@@ -21,6 +25,7 @@ export type BoardViewProps = {
|
|
|
21
25
|
placedSilBySector: Map<string, Poly[]>;
|
|
22
26
|
anchorDots: AnchorDots[];
|
|
23
27
|
pieces: PieceView[];
|
|
28
|
+
scaleS: number; // scaling factor for silhouettes
|
|
24
29
|
|
|
25
30
|
// UI state flags
|
|
26
31
|
clickMode: boolean;
|
|
@@ -28,6 +33,7 @@ export type BoardViewProps = {
|
|
|
28
33
|
selectedPieceId: string | null;
|
|
29
34
|
dragInvalid: boolean;
|
|
30
35
|
lockedPieceId: string | null;
|
|
36
|
+
showTangramDecomposition?: boolean; // whether to show tangram decomposed into primitives
|
|
31
37
|
|
|
32
38
|
// refs + handlers wired by the container
|
|
33
39
|
svgRef: React.RefObject<SVGSVGElement>;
|
|
@@ -55,8 +61,8 @@ export default function BoardView(props: BoardViewProps) {
|
|
|
55
61
|
const {
|
|
56
62
|
controller, layout, viewBox, width, height,
|
|
57
63
|
badgeR, badgeCenter,
|
|
58
|
-
placedSilBySector, anchorDots, pieces,
|
|
59
|
-
clickMode, draggingId, selectedPieceId, dragInvalid, lockedPieceId,
|
|
64
|
+
placedSilBySector, anchorDots, pieces, scaleS,
|
|
65
|
+
clickMode, draggingId, selectedPieceId, dragInvalid, lockedPieceId, showTangramDecomposition,
|
|
60
66
|
svgRef, setPieceRef,
|
|
61
67
|
onPiecePointerDown, onBlueprintPointerDown, onRootPointerDown, onPointerMove, onPointerUp, onCenterBadgePointerDown,
|
|
62
68
|
} = props;
|
|
@@ -87,12 +93,12 @@ export default function BoardView(props: BoardViewProps) {
|
|
|
87
93
|
: CONFIG.layout.constraints.quickstashDiamAnchors;
|
|
88
94
|
const D_slot = anchorsDiameterToPx(reqAnchors);
|
|
89
95
|
const R_needed = D_slot / (2 * Math.max(1e-9, Math.sin(delta / 2)));
|
|
90
|
-
|
|
96
|
+
|
|
91
97
|
// Minimum radius to avoid overlapping with badge
|
|
92
98
|
// Badge takes up: badgeR + margin, plus we need half the slot diameter for clearance
|
|
93
99
|
const R_min = badgeR + CONFIG.size.centerBadge.marginPx + D_slot / 2;
|
|
94
100
|
const ringMax = layout.innerR - (badgeR + CONFIG.size.centerBadge.marginPx);
|
|
95
|
-
|
|
101
|
+
|
|
96
102
|
// Clamp to [R_min, ringMax]
|
|
97
103
|
const blueprintRingR = Math.min(Math.max(R_needed, R_min), ringMax);
|
|
98
104
|
|
|
@@ -136,7 +142,7 @@ export default function BoardView(props: BoardViewProps) {
|
|
|
136
142
|
onPointerDown={(e) => {
|
|
137
143
|
onRootPointerDown(e);
|
|
138
144
|
}}
|
|
139
|
-
style={{ background: "#
|
|
145
|
+
style={{ background: "#f5f5f5", touchAction: "none", userSelect: "none" }}
|
|
140
146
|
>
|
|
141
147
|
{/* bands as wedges */}
|
|
142
148
|
{layout.sectors.map((s, i) => {
|
|
@@ -193,7 +199,7 @@ export default function BoardView(props: BoardViewProps) {
|
|
|
193
199
|
stroke="none"
|
|
194
200
|
onPointerDown={(e) => onPiecePointerDown(e, p)}
|
|
195
201
|
/>
|
|
196
|
-
|
|
202
|
+
|
|
197
203
|
{/* Border paths - only rendered if borders are enabled */}
|
|
198
204
|
{showBorders && (
|
|
199
205
|
useSelectiveBorders ? (
|
|
@@ -203,32 +209,32 @@ export default function BoardView(props: BoardViewProps) {
|
|
|
203
209
|
const pieceAsPiece = { ...p, pos: { x: p.x, y: p.y } };
|
|
204
210
|
const allPiecesAsPieces = allPiecesInSector.map(piece => ({ ...piece, pos: { x: piece.x, y: piece.y } }));
|
|
205
211
|
const hiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, allPiecesAsPieces, (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
|
|
206
|
-
|
|
212
|
+
|
|
207
213
|
// Check if this piece's edges were hidden due to touching the dragged piece
|
|
208
214
|
const draggedPiece = draggingId ? allPiecesInSector.find(piece => piece.id === draggingId) : null;
|
|
209
|
-
|
|
215
|
+
|
|
210
216
|
// For composites, we need to distinguish between internal edges (always hidden) and external edges (shown when dragging)
|
|
211
217
|
let wasTouchingDraggedPiece: boolean[];
|
|
212
218
|
if (p.blueprintId.startsWith('comp:')) {
|
|
213
219
|
// For composites, check internal edges separately from external edges
|
|
214
220
|
const internalHiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, [], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
|
|
215
|
-
const externalHiddenEdges = draggedPiece ?
|
|
216
|
-
getHiddenEdgesForPolygon(pieceAsPiece, idx, [{ ...draggedPiece, pos: { x: draggedPiece.x, y: draggedPiece.y } }], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind)) :
|
|
221
|
+
const externalHiddenEdges = draggedPiece ?
|
|
222
|
+
getHiddenEdgesForPolygon(pieceAsPiece, idx, [{ ...draggedPiece, pos: { x: draggedPiece.x, y: draggedPiece.y } }], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind)) :
|
|
217
223
|
new Array(hiddenEdges.length).fill(false);
|
|
218
|
-
|
|
224
|
+
|
|
219
225
|
// Only consider external edges as "touching dragged piece"
|
|
220
226
|
wasTouchingDraggedPiece = externalHiddenEdges.map((external, i) => external && !internalHiddenEdges[i]);
|
|
221
227
|
} else {
|
|
222
228
|
// For primitives, all hidden edges are external
|
|
223
|
-
wasTouchingDraggedPiece = draggedPiece ?
|
|
224
|
-
getHiddenEdgesForPolygon(pieceAsPiece, idx, [{ ...draggedPiece, pos: { x: draggedPiece.x, y: draggedPiece.y } }], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind)) :
|
|
229
|
+
wasTouchingDraggedPiece = draggedPiece ?
|
|
230
|
+
getHiddenEdgesForPolygon(pieceAsPiece, idx, [{ ...draggedPiece, pos: { x: draggedPiece.x, y: draggedPiece.y } }], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind)) :
|
|
225
231
|
new Array(hiddenEdges.length).fill(false);
|
|
226
232
|
}
|
|
227
|
-
|
|
233
|
+
|
|
228
234
|
return generateEdgeStrokePaths(poly).map((strokePath, strokeIdx) => {
|
|
229
235
|
// Show border if: 1) it wasn't hidden originally, OR 2) it was hidden due to touching the dragged piece
|
|
230
236
|
const wasHiddenDueToDraggedPiece = wasTouchingDraggedPiece[strokeIdx] || false;
|
|
231
|
-
|
|
237
|
+
|
|
232
238
|
// Special case: if this piece is being dragged and it's a composite, keep internal edges hidden
|
|
233
239
|
let isHidden: boolean;
|
|
234
240
|
if (isDragging && p.blueprintId.startsWith('comp:')) {
|
|
@@ -239,7 +245,7 @@ export default function BoardView(props: BoardViewProps) {
|
|
|
239
245
|
// Normal logic for non-dragged pieces or primitives
|
|
240
246
|
isHidden = isDragging ? false : ((hiddenEdges[strokeIdx] || false) && !wasHiddenDueToDraggedPiece);
|
|
241
247
|
}
|
|
242
|
-
|
|
248
|
+
|
|
243
249
|
return (
|
|
244
250
|
<path
|
|
245
251
|
key={`stroke-${idx}-${strokeIdx}`}
|
|
@@ -272,15 +278,52 @@ export default function BoardView(props: BoardViewProps) {
|
|
|
272
278
|
|
|
273
279
|
{/* silhouettes (use pre-placed polys) */}
|
|
274
280
|
{layout.sectors.map((s) => {
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
281
|
+
const sectorCfg = controller.state.cfg.sectors.find((ss: any) => ss.id === s.id);
|
|
282
|
+
|
|
283
|
+
// Check if we should render decomposed primitives
|
|
284
|
+
if (showTangramDecomposition && sectorCfg?.silhouette.primitiveDecomposition) {
|
|
285
|
+
const primitiveDecomposition = sectorCfg.silhouette.primitiveDecomposition;
|
|
286
|
+
|
|
287
|
+
// Use same positioning logic as unified silhouette
|
|
288
|
+
const rect = rectForBand(layout, s, "silhouette", 1.0);
|
|
289
|
+
const rawPolys = primitiveDecomposition.map((primInfo: any) => primInfo.polygon);
|
|
290
|
+
const placedPolys = placeSilhouetteGridAlignedAsPolys(rawPolys, scaleS, { cx: rect.cx, cy: rect.cy });
|
|
291
|
+
|
|
292
|
+
return (
|
|
293
|
+
<g key={`sil-decomposed-${s.id}`} pointerEvents="none">
|
|
294
|
+
{placedPolys.map((scaledPoly, i) => (
|
|
295
|
+
<React.Fragment key={`prim-${i}`}>
|
|
296
|
+
{/* Fill path */}
|
|
297
|
+
<path
|
|
298
|
+
d={pathD(scaledPoly)}
|
|
299
|
+
fill={CONFIG.color.silhouetteMask}
|
|
300
|
+
opacity={CONFIG.opacity.silhouetteMask}
|
|
301
|
+
stroke="none"
|
|
302
|
+
/>
|
|
303
|
+
|
|
304
|
+
{/* Full perimeter border */}
|
|
305
|
+
<path
|
|
306
|
+
d={pathD(scaledPoly)}
|
|
307
|
+
fill="none"
|
|
308
|
+
stroke={CONFIG.color.tangramDecomposition.stroke}
|
|
309
|
+
strokeWidth={CONFIG.size.stroke.tangramDecompositionPx}
|
|
310
|
+
/>
|
|
311
|
+
</React.Fragment>
|
|
312
|
+
))}
|
|
313
|
+
</g>
|
|
314
|
+
);
|
|
315
|
+
} else {
|
|
316
|
+
// Default: render unified silhouette
|
|
317
|
+
const placedPolys = placedSilBySector.get(s.id) ?? [];
|
|
318
|
+
if (!placedPolys.length) return null;
|
|
319
|
+
return (
|
|
320
|
+
<g key={`sil-${s.id}`} pointerEvents="none">
|
|
321
|
+
{placedPolys.map((poly, i) => (
|
|
322
|
+
<path key={i} d={pathD(poly)} fill={CONFIG.color.silhouetteMask} opacity={CONFIG.opacity.silhouetteMask} />
|
|
323
|
+
))}
|
|
324
|
+
</g>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
284
327
|
})}
|
|
285
328
|
|
|
286
329
|
{/* anchor grid */}
|
|
@@ -305,26 +348,65 @@ export default function BoardView(props: BoardViewProps) {
|
|
|
305
348
|
);
|
|
306
349
|
})}
|
|
307
350
|
|
|
351
|
+
{/* SVG filter for inverting icon colors to white */}
|
|
352
|
+
<defs>
|
|
353
|
+
<filter id="invert-to-white">
|
|
354
|
+
<feColorMatrix
|
|
355
|
+
type="matrix"
|
|
356
|
+
values="-1 0 0 0 1
|
|
357
|
+
0 -1 0 0 1
|
|
358
|
+
0 0 -1 0 1
|
|
359
|
+
0 0 0 1 0"
|
|
360
|
+
/>
|
|
361
|
+
</filter>
|
|
362
|
+
</defs>
|
|
363
|
+
|
|
308
364
|
{/* center badge */}
|
|
309
365
|
{(() => {
|
|
310
366
|
const isPrep = controller.state.cfg.mode === "prep";
|
|
311
367
|
const isSubmitEnabled = isPrep ? controller.isSubmitEnabled() : true;
|
|
312
368
|
const isClickable = !draggingId && (!isPrep || isSubmitEnabled);
|
|
313
|
-
|
|
369
|
+
const [imageError, setImageError] = React.useState(false);
|
|
370
|
+
|
|
371
|
+
// Size the image to fill most of the badge (80% of diameter)
|
|
372
|
+
const iconSize = badgeR * 1.6;
|
|
373
|
+
const iconOffset = iconSize / 2;
|
|
374
|
+
|
|
314
375
|
return (
|
|
315
|
-
<g transform={`translate(${badgeCenter.x}, ${badgeCenter.y})`}
|
|
316
|
-
style={{ cursor: isClickable ? "pointer" : "default" }}
|
|
376
|
+
<g transform={`translate(${badgeCenter.x}, ${badgeCenter.y})`}
|
|
377
|
+
style={{ cursor: isClickable ? "pointer" : "default" }}
|
|
317
378
|
onPointerDown={isClickable ? onCenterBadgePointerDown : undefined}>
|
|
318
|
-
<circle r={badgeR}
|
|
319
|
-
fill={isSubmitEnabled ? CONFIG.color.blueprint.badgeFill : "#ccc"}
|
|
379
|
+
<circle r={badgeR}
|
|
380
|
+
fill={isSubmitEnabled ? CONFIG.color.blueprint.badgeFill : "#ccc"}
|
|
320
381
|
opacity={isSubmitEnabled ? 1.0 : 0.5} />
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
382
|
+
{isPrep ? (
|
|
383
|
+
<text textAnchor="middle"
|
|
384
|
+
dominantBaseline="middle"
|
|
385
|
+
fontSize={CONFIG.size.badgeFontPx}
|
|
386
|
+
fill={isSubmitEnabled ? CONFIG.color.blueprint.labelFill : "#888"}
|
|
387
|
+
pointerEvents="none">
|
|
388
|
+
Submit
|
|
389
|
+
</text>
|
|
390
|
+
) : imageError ? (
|
|
391
|
+
<text textAnchor="middle"
|
|
392
|
+
dominantBaseline="middle"
|
|
393
|
+
fontSize={CONFIG.size.badgeFontPx}
|
|
394
|
+
fill={CONFIG.color.blueprint.labelFill}
|
|
395
|
+
pointerEvents="none">
|
|
396
|
+
inventory
|
|
397
|
+
</text>
|
|
398
|
+
) : (
|
|
399
|
+
<image
|
|
400
|
+
href={controller.state.blueprintView === "quickstash" ? lockedIcon : unlockedIcon}
|
|
401
|
+
x={-iconOffset}
|
|
402
|
+
y={-iconOffset}
|
|
403
|
+
width={iconSize}
|
|
404
|
+
height={iconSize}
|
|
405
|
+
pointerEvents="none"
|
|
406
|
+
onError={() => setImageError(true)}
|
|
407
|
+
filter="url(#invert-to-white)"
|
|
408
|
+
/>
|
|
409
|
+
)}
|
|
328
410
|
</g>
|
|
329
411
|
);
|
|
330
412
|
})()}
|
|
@@ -149,6 +149,24 @@ export interface GameBoardConfig {
|
|
|
149
149
|
*/
|
|
150
150
|
requireAllSlots?: boolean;
|
|
151
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Whether to show tangram target shapes decomposed into individual primitives with borders
|
|
154
|
+
* Independent from showBorders/hideTouchingBorders (which control spawned pieces)
|
|
155
|
+
*/
|
|
156
|
+
showTangramDecomposition?: boolean;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* HTML content to display above the gameboard as instructions
|
|
160
|
+
* Allows rich formatting via HTML
|
|
161
|
+
*/
|
|
162
|
+
instructions?: string;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Cleaned jsPsych plugin parameters (excluding callbacks)
|
|
166
|
+
* Saved to trial data for analysis
|
|
167
|
+
*/
|
|
168
|
+
trialParams?: any;
|
|
169
|
+
|
|
152
170
|
/** Optional CSS width for the game board SVG */
|
|
153
171
|
width?: number;
|
|
154
172
|
|
|
@@ -238,6 +256,9 @@ export default function GameBoard(props: GameBoardProps) {
|
|
|
238
256
|
maxQuickstashSlots,
|
|
239
257
|
maxCompositeSize,
|
|
240
258
|
mode,
|
|
259
|
+
showTangramDecomposition,
|
|
260
|
+
instructions,
|
|
261
|
+
trialParams,
|
|
241
262
|
width: _width,
|
|
242
263
|
height: _height,
|
|
243
264
|
onSectorComplete,
|
|
@@ -248,6 +269,9 @@ export default function GameBoard(props: GameBoardProps) {
|
|
|
248
269
|
onControllerReady
|
|
249
270
|
} = props;
|
|
250
271
|
|
|
272
|
+
// Timer state for countdown
|
|
273
|
+
const [timeRemaining, setTimeRemaining] = React.useState(timeLimitMs > 0 ? Math.floor(timeLimitMs / 1000) : 0);
|
|
274
|
+
|
|
251
275
|
// Initialize game controller with injected data
|
|
252
276
|
const controller = React.useMemo(() => {
|
|
253
277
|
const gameConfig = {
|
|
@@ -279,8 +303,8 @@ export default function GameBoard(props: GameBoardProps) {
|
|
|
279
303
|
if (onInteraction) callbacks.onInteraction = onInteraction;
|
|
280
304
|
if (onTrialEnd) callbacks.onTrialEnd = onTrialEnd;
|
|
281
305
|
|
|
282
|
-
return new InteractionTracker(controller, callbacks);
|
|
283
|
-
}, [controller, onInteraction, onTrialEnd]);
|
|
306
|
+
return new InteractionTracker(controller, callbacks, trialParams);
|
|
307
|
+
}, [controller, onInteraction, onTrialEnd, trialParams]);
|
|
284
308
|
|
|
285
309
|
// Call onControllerReady when controller is ready
|
|
286
310
|
React.useEffect(() => {
|
|
@@ -387,7 +411,7 @@ export default function GameBoard(props: GameBoardProps) {
|
|
|
387
411
|
// Calculate sizing based on layout mode
|
|
388
412
|
const getGameboardStyle = () => {
|
|
389
413
|
const baseStyle = {
|
|
390
|
-
margin: '
|
|
414
|
+
margin: '10px',
|
|
391
415
|
display: 'flex',
|
|
392
416
|
alignItems: 'center',
|
|
393
417
|
justifyContent: 'center',
|
|
@@ -396,7 +420,7 @@ export default function GameBoard(props: GameBoardProps) {
|
|
|
396
420
|
|
|
397
421
|
if (layoutMode === 'circle') {
|
|
398
422
|
// Circle mode: square aspect ratio (height == width)
|
|
399
|
-
const size = Math.min(window.innerWidth *
|
|
423
|
+
const size = Math.min(window.innerWidth * CONFIG.layout.viewportScale, window.innerHeight * CONFIG.layout.viewportScale);
|
|
400
424
|
return {
|
|
401
425
|
...baseStyle,
|
|
402
426
|
width: `${size}px`,
|
|
@@ -404,8 +428,8 @@ export default function GameBoard(props: GameBoardProps) {
|
|
|
404
428
|
};
|
|
405
429
|
} else {
|
|
406
430
|
// Semicircle mode: width is 2x height
|
|
407
|
-
const maxWidth = Math.min(window.innerWidth *
|
|
408
|
-
const maxHeight = Math.min(window.innerWidth *
|
|
431
|
+
const maxWidth = Math.min(window.innerWidth * CONFIG.layout.viewportScale, window.innerHeight * CONFIG.layout.viewportScale * 2);
|
|
432
|
+
const maxHeight = Math.min(window.innerWidth * CONFIG.layout.viewportScale / 2, window.innerHeight * CONFIG.layout.viewportScale);
|
|
409
433
|
return {
|
|
410
434
|
...baseStyle,
|
|
411
435
|
width: `${maxWidth}px`,
|
|
@@ -613,8 +637,96 @@ export default function GameBoard(props: GameBoardProps) {
|
|
|
613
637
|
e.stopPropagation();
|
|
614
638
|
};
|
|
615
639
|
|
|
640
|
+
// Timer effect
|
|
641
|
+
React.useEffect(() => {
|
|
642
|
+
if (timeLimitMs === 0) return;
|
|
643
|
+
|
|
644
|
+
const interval = setInterval(() => {
|
|
645
|
+
setTimeRemaining(prev => {
|
|
646
|
+
if (prev <= 1) {
|
|
647
|
+
clearInterval(interval);
|
|
648
|
+
return 0;
|
|
649
|
+
}
|
|
650
|
+
return prev - 1;
|
|
651
|
+
});
|
|
652
|
+
}, 1000);
|
|
653
|
+
|
|
654
|
+
return () => clearInterval(interval);
|
|
655
|
+
}, [timeLimitMs]);
|
|
656
|
+
|
|
657
|
+
// Format time helper
|
|
658
|
+
const formatTime = (seconds: number): string => {
|
|
659
|
+
const mins = Math.floor(seconds / 60);
|
|
660
|
+
const secs = Math.floor(seconds % 60);
|
|
661
|
+
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
// Container style
|
|
665
|
+
const containerStyle: React.CSSProperties = {
|
|
666
|
+
display: 'flex',
|
|
667
|
+
flexDirection: 'column',
|
|
668
|
+
width: '100%',
|
|
669
|
+
// minHeight: '100vh'
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
// Header style
|
|
673
|
+
const headerStyle: React.CSSProperties = {
|
|
674
|
+
display: 'flex',
|
|
675
|
+
flexDirection: 'row',
|
|
676
|
+
justifyContent: 'space-between',
|
|
677
|
+
alignItems: 'center',
|
|
678
|
+
padding: '20px',
|
|
679
|
+
background: '#f5f5f5',
|
|
680
|
+
flex: '0 0 auto'
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
// Instructions style
|
|
684
|
+
const instructionsStyle: React.CSSProperties = {
|
|
685
|
+
flexGrow: 1,
|
|
686
|
+
fontSize: '20px',
|
|
687
|
+
lineHeight: 1.5,
|
|
688
|
+
marginRight: '20px'
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
// Timer style
|
|
692
|
+
const timerStyle: React.CSSProperties = {
|
|
693
|
+
fontSize: '24px',
|
|
694
|
+
fontWeight: 'bold',
|
|
695
|
+
fontFamily: 'monospace',
|
|
696
|
+
color: '#333',
|
|
697
|
+
minWidth: '80px',
|
|
698
|
+
textAlign: 'right'
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
// Gameboard wrapper style (to center the gameboard in remaining space)
|
|
702
|
+
const gameboardWrapperStyle: React.CSSProperties = {
|
|
703
|
+
flex: '1 1 auto',
|
|
704
|
+
display: 'flex',
|
|
705
|
+
alignItems: 'center',
|
|
706
|
+
justifyContent: 'center',
|
|
707
|
+
overflow: 'hidden'
|
|
708
|
+
};
|
|
709
|
+
|
|
616
710
|
return (
|
|
617
|
-
<div className="tangram-
|
|
711
|
+
<div className="tangram-trial-container" style={containerStyle}>
|
|
712
|
+
{(instructions || timeLimitMs > 0) && (
|
|
713
|
+
<div className="tangram-header" style={headerStyle}>
|
|
714
|
+
{instructions && (
|
|
715
|
+
<div
|
|
716
|
+
className="tangram-instructions"
|
|
717
|
+
style={instructionsStyle}
|
|
718
|
+
dangerouslySetInnerHTML={{ __html: instructions }}
|
|
719
|
+
/>
|
|
720
|
+
)}
|
|
721
|
+
{timeLimitMs > 0 && (
|
|
722
|
+
<div className="tangram-timer" style={timerStyle}>
|
|
723
|
+
{formatTime(timeRemaining)}
|
|
724
|
+
</div>
|
|
725
|
+
)}
|
|
726
|
+
</div>
|
|
727
|
+
)}
|
|
728
|
+
<div className="tangram-gameboard-wrapper" style={gameboardWrapperStyle}>
|
|
729
|
+
<div className="tangram-gameboard" style={getGameboardStyle()}>
|
|
618
730
|
<BoardView
|
|
619
731
|
controller={controller}
|
|
620
732
|
layout={layout}
|
|
@@ -639,8 +751,12 @@ export default function GameBoard(props: GameBoardProps) {
|
|
|
639
751
|
onPointerMove={onPointerMove}
|
|
640
752
|
onPointerUp={onPointerUp}
|
|
641
753
|
onCenterBadgePointerDown={onCenterBadgePointerDown}
|
|
754
|
+
showTangramDecomposition={showTangramDecomposition ?? false}
|
|
755
|
+
scaleS={scaleS}
|
|
642
756
|
{...eventCallbacks}
|
|
643
757
|
/>
|
|
758
|
+
</div>
|
|
759
|
+
</div>
|
|
644
760
|
</div>
|
|
645
761
|
);
|
|
646
762
|
}
|
|
@@ -11,6 +11,7 @@ export type Config = {
|
|
|
11
11
|
piece: { draggingFill: string; validFill: string; invalidFill: string; invalidStroke: string; selectedStroke: string; allGreenStroke: string; borderStroke: string };
|
|
12
12
|
ui: { light: string; dark: string };
|
|
13
13
|
blueprint: { fill: string; selectedStroke: string; badgeFill: string; labelFill: string };
|
|
14
|
+
tangramDecomposition: { stroke: string };
|
|
14
15
|
};
|
|
15
16
|
opacity: {
|
|
16
17
|
blueprint: number;
|
|
@@ -19,7 +20,7 @@ export type Config = {
|
|
|
19
20
|
piece: { invalid: number; dragging: number; locked: number; normal: number };
|
|
20
21
|
};
|
|
21
22
|
size: {
|
|
22
|
-
stroke: { bandPx: number; pieceSelectedPx: number; allGreenStrokePx: number; pieceBorderPx: number };
|
|
23
|
+
stroke: { bandPx: number; pieceSelectedPx: number; allGreenStrokePx: number; pieceBorderPx: number; tangramDecompositionPx: number };
|
|
23
24
|
anchorRadiusPx: { valid: number; invalid: number };
|
|
24
25
|
badgeFontPx: number;
|
|
25
26
|
centerBadge: { fractionOfOuterR: number; minPx: number; marginPx: number };
|
|
@@ -27,6 +28,7 @@ export type Config = {
|
|
|
27
28
|
layout: {
|
|
28
29
|
grid: { stepPx: number; unitPx: number };
|
|
29
30
|
paddingPx: number;
|
|
31
|
+
viewportScale: number;
|
|
30
32
|
/** renamed from capacity → constraints */
|
|
31
33
|
constraints: {
|
|
32
34
|
workspaceDiamAnchors: number;
|
|
@@ -53,7 +55,8 @@ export const CONFIG: Config = {
|
|
|
53
55
|
anchors: { invalid: "#7dd3fc", valid: "#475569" },
|
|
54
56
|
piece: { draggingFill: "#8e7cc3ff", validFill: "#8e7cc3ff", invalidFill: "#ef4444", invalidStroke: "#dc2626", selectedStroke: "#674ea7", allGreenStroke: "#86efac", borderStroke: "#674ea7" },
|
|
55
57
|
ui: { light: "#60a5fa", dark: "#1d4ed8" },
|
|
56
|
-
blueprint: { fill: "#374151", selectedStroke: "#111827", badgeFill: "#000000", labelFill: "#ffffff" }
|
|
58
|
+
blueprint: { fill: "#374151", selectedStroke: "#111827", badgeFill: "#000000", labelFill: "#ffffff" },
|
|
59
|
+
tangramDecomposition: { stroke: "#fef2cc" }
|
|
57
60
|
},
|
|
58
61
|
opacity: {
|
|
59
62
|
blueprint: 0.4,
|
|
@@ -63,7 +66,7 @@ export const CONFIG: Config = {
|
|
|
63
66
|
piece: { invalid: 0.35, dragging: 0.75, locked: 1, normal: 1 },
|
|
64
67
|
},
|
|
65
68
|
size: {
|
|
66
|
-
stroke: { bandPx: 5, pieceSelectedPx: 3, allGreenStrokePx: 10, pieceBorderPx: 2 },
|
|
69
|
+
stroke: { bandPx: 5, pieceSelectedPx: 3, allGreenStrokePx: 10, pieceBorderPx: 2, tangramDecompositionPx: 1 },
|
|
67
70
|
anchorRadiusPx: { valid: 1.0, invalid: 1.0 },
|
|
68
71
|
badgeFontPx: 16,
|
|
69
72
|
centerBadge: { fractionOfOuterR: 0.15, minPx: 20, marginPx: 4 }
|
|
@@ -71,6 +74,7 @@ export const CONFIG: Config = {
|
|
|
71
74
|
layout: {
|
|
72
75
|
grid: { stepPx: 20, unitPx: 40 },
|
|
73
76
|
paddingPx: 1,
|
|
77
|
+
viewportScale: 0.8,
|
|
74
78
|
constraints: {
|
|
75
79
|
workspaceDiamAnchors: 10, // num anchors req'd to be on diagonal
|
|
76
80
|
quickstashDiamAnchors: 7, // num anchors req'd to be in single quickstash slot
|
|
@@ -80,7 +84,7 @@ export const CONFIG: Config = {
|
|
|
80
84
|
},
|
|
81
85
|
game: {
|
|
82
86
|
snapRadiusPx: 15,
|
|
83
|
-
showBorders:
|
|
87
|
+
showBorders: false,
|
|
84
88
|
hideTouchingBorders: true
|
|
85
89
|
}
|
|
86
90
|
};
|
package/src/core/domain/types.ts
CHANGED
|
@@ -51,6 +51,7 @@ export type SilhouetteSpec = {
|
|
|
51
51
|
id: string;
|
|
52
52
|
anchors?: Anchor[];
|
|
53
53
|
mask?: Poly[]; // polygon masks for silhouette matching
|
|
54
|
+
primitiveDecomposition?: Array<{ kind: TanKind; polygon: Poly }>; // individual primitive pieces for decomposition view
|
|
54
55
|
};
|
|
55
56
|
|
|
56
57
|
export type Sector = {
|