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,178 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { BaseGameController } from "@/core/engine/state/BaseGameController";
|
|
3
|
+
import type { Poly } from "@/core/domain/types";
|
|
4
|
+
import { boundsOfBlueprint, piecePolysAt } from "@/core/engine/geometry";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Piece data interface used throughout the hook system
|
|
8
|
+
*
|
|
9
|
+
* Represents a tangram piece instance with position and blueprint information.
|
|
10
|
+
* This is the normalized interface used across all board components.
|
|
11
|
+
*/
|
|
12
|
+
export interface PieceData {
|
|
13
|
+
/** Unique identifier for this piece instance */
|
|
14
|
+
id: string;
|
|
15
|
+
|
|
16
|
+
/** ID of the blueprint that defines this piece's shape */
|
|
17
|
+
blueprintId: string;
|
|
18
|
+
|
|
19
|
+
/** X coordinate of the piece's top-left position in SVG space */
|
|
20
|
+
x: number;
|
|
21
|
+
|
|
22
|
+
/** Y coordinate of the piece's top-left position in SVG space */
|
|
23
|
+
y: number;
|
|
24
|
+
|
|
25
|
+
/** ID of the sector containing this piece (undefined if floating) */
|
|
26
|
+
sectorId?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Return interface for usePieceState hook
|
|
31
|
+
*
|
|
32
|
+
* Provides normalized piece data and computed polygon functions for collision
|
|
33
|
+
* detection and rendering. All functions are memoized for optimal performance.
|
|
34
|
+
*/
|
|
35
|
+
export interface PieceStateHook {
|
|
36
|
+
/** Array of all pieces from all sectors plus floating pieces */
|
|
37
|
+
pieces: PieceData[];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Lookup function to find a piece by its ID
|
|
41
|
+
* @param id - Piece ID to lookup (null-safe)
|
|
42
|
+
* @returns PieceData or null if not found
|
|
43
|
+
*/
|
|
44
|
+
pieceById: (id: string | null) => PieceData | null;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Compute world polygons for all pieces in a sector (live/no cache)
|
|
48
|
+
* @param sectorId - Target sector ID
|
|
49
|
+
* @returns Array of world-space polygons
|
|
50
|
+
*/
|
|
51
|
+
sectorPiecePolysLive: (sectorId: string) => Poly[];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Cached version of sectorPiecePolysLive with signature-based invalidation
|
|
55
|
+
* @param sectorId - Target sector ID
|
|
56
|
+
* @returns Cached array of world-space polygons
|
|
57
|
+
*/
|
|
58
|
+
getSectorPiecePolysCached: (sectorId: string) => Poly[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Hook for managing piece data and computed polygon states
|
|
63
|
+
*
|
|
64
|
+
* This hook provides normalized access to piece data from the BaseGameController,
|
|
65
|
+
* combining pieces from sectors and floating state into a unified interface.
|
|
66
|
+
* It also provides optimized polygon computation functions for collision detection.
|
|
67
|
+
*
|
|
68
|
+
* ## Key Features
|
|
69
|
+
* - **Unified piece access**: Combines sector and floating pieces
|
|
70
|
+
* - **Polygon caching**: Optimized collision detection with signature-based cache
|
|
71
|
+
* - **Live updates**: Reactively updates when controller state changes
|
|
72
|
+
* - **Null-safe lookups**: Safe piece retrieval by ID
|
|
73
|
+
*
|
|
74
|
+
* ## Performance
|
|
75
|
+
* - Pieces array is memoized based on controller.updateCount
|
|
76
|
+
* - Polygon cache uses sector "signature" (piece positions) for invalidation
|
|
77
|
+
* - Live polygon computation bypasses cache for real-time operations
|
|
78
|
+
*
|
|
79
|
+
* @param controller - BaseGameController instance managing game state
|
|
80
|
+
* @returns Hook interface with piece data and polygon functions
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```typescript
|
|
84
|
+
* const pieceState = usePieceState(controller);
|
|
85
|
+
* const allPieces = pieceState.pieces;
|
|
86
|
+
* const piece = pieceState.pieceById("piece_123");
|
|
87
|
+
* const sectorPolygons = pieceState.sectorPiecePolysLive("sector_1");
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
export function usePieceState(controller: BaseGameController): PieceStateHook {
|
|
91
|
+
// --- pieces ---------------------------------------------------------------
|
|
92
|
+
const pieces = React.useMemo(() => {
|
|
93
|
+
const out: PieceData[] = [];
|
|
94
|
+
for (const s of Object.values(controller.state.sectors)) {
|
|
95
|
+
for (const p of s.pieces) {
|
|
96
|
+
out.push({
|
|
97
|
+
id: p.id,
|
|
98
|
+
blueprintId: p.blueprintId,
|
|
99
|
+
x: p.pos.x,
|
|
100
|
+
y: p.pos.y,
|
|
101
|
+
sectorId: s.sectorId
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const floating: any = (controller as any)._floating;
|
|
106
|
+
if (floating?.pieces) {
|
|
107
|
+
for (const p of floating.pieces) {
|
|
108
|
+
out.push({
|
|
109
|
+
id: p.id,
|
|
110
|
+
blueprintId: p.blueprintId,
|
|
111
|
+
x: p.pos.x,
|
|
112
|
+
y: p.pos.y
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}, [controller.state.sectors, (controller as any)._floating, controller.updateCount]);
|
|
118
|
+
|
|
119
|
+
const pieceById = React.useCallback(
|
|
120
|
+
(id: string | null) => (id ? pieces.find(p => p.id === id) ?? null : null),
|
|
121
|
+
[pieces]
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// --- LIVE sector polys (reads controller.state directly) -----------------
|
|
125
|
+
const sectorPiecePolysLive = React.useCallback(
|
|
126
|
+
(sectorId: string): Poly[] => {
|
|
127
|
+
const out: Poly[] = [];
|
|
128
|
+
const ss = controller.state.sectors[sectorId];
|
|
129
|
+
if (!ss) return out;
|
|
130
|
+
for (const p of ss.pieces) {
|
|
131
|
+
const bp = controller.getBlueprint(p.blueprintId)!;
|
|
132
|
+
const bb = boundsOfBlueprint(bp, controller.getPrimitive);
|
|
133
|
+
out.push(...piecePolysAt(bp, bb, { x: p.pos.x, y: p.pos.y }));
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
},
|
|
137
|
+
[controller]
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// --- OPTIONAL: cached variant keyed by a sector "signature" --------------
|
|
141
|
+
// Sig = pieceId@x,y;... (order-insensitive)
|
|
142
|
+
const polyCacheRef = React.useRef(
|
|
143
|
+
new Map<string, { sig: string; polys: Poly[] }>()
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const sectorSignature = React.useCallback(
|
|
147
|
+
(sectorId: string): string => {
|
|
148
|
+
const ss = controller.state.sectors[sectorId];
|
|
149
|
+
if (!ss) return "";
|
|
150
|
+
// stable order by id
|
|
151
|
+
const parts = ss.pieces
|
|
152
|
+
.slice()
|
|
153
|
+
.sort((a, b) => (a.id < b.id ? -1 : 1))
|
|
154
|
+
.map(p => `${p.id}@${p.pos.x},${p.pos.y}`);
|
|
155
|
+
return parts.join("|");
|
|
156
|
+
},
|
|
157
|
+
[controller.state.sectors]
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const getSectorPiecePolysCached = React.useCallback(
|
|
161
|
+
(sectorId: string): Poly[] => {
|
|
162
|
+
const sig = sectorSignature(sectorId);
|
|
163
|
+
const hit = polyCacheRef.current.get(sectorId);
|
|
164
|
+
if (hit && hit.sig === sig) return hit.polys;
|
|
165
|
+
const polys = sectorPiecePolysLive(sectorId);
|
|
166
|
+
polyCacheRef.current.set(sectorId, { sig, polys });
|
|
167
|
+
return polys;
|
|
168
|
+
},
|
|
169
|
+
[sectorSignature, sectorPiecePolysLive]
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
pieces,
|
|
174
|
+
pieceById,
|
|
175
|
+
sectorPiecePolysLive,
|
|
176
|
+
getSectorPiecePolysCached
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Poly } from "@/core/domain/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Board Component Utilities
|
|
5
|
+
*
|
|
6
|
+
* This module provides utility functions used by board interaction hooks.
|
|
7
|
+
* These functions handle coordinate transformations and geometric operations
|
|
8
|
+
* needed for blueprint interaction and polygon scaling.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Convert world coordinates to blueprint local coordinates
|
|
13
|
+
*
|
|
14
|
+
* When a user clicks on a blueprint glyph in the blueprint ring, this function
|
|
15
|
+
* converts the world click coordinates to local coordinates within the blueprint's
|
|
16
|
+
* coordinate system. This is needed to maintain the clicked point under the cursor
|
|
17
|
+
* when spawning and dragging new pieces.
|
|
18
|
+
*
|
|
19
|
+
* @param px - World X coordinate (SVG space)
|
|
20
|
+
* @param py - World Y coordinate (SVG space)
|
|
21
|
+
* @param bpGeom - Blueprint geometry information
|
|
22
|
+
* @param bpGeom.bx - Blueprint center X in world space
|
|
23
|
+
* @param bpGeom.by - Blueprint center Y in world space
|
|
24
|
+
* @param bpGeom.cx - Blueprint local center X
|
|
25
|
+
* @param bpGeom.cy - Blueprint local center Y
|
|
26
|
+
* @returns Local coordinates within the blueprint's coordinate system
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```typescript
|
|
30
|
+
* const localPoint = blueprintLocalFromWorld(100, 200, {
|
|
31
|
+
* bx: 50, by: 50, // Blueprint is rendered at (50, 50) in world space
|
|
32
|
+
* cx: 25, cy: 25 // Blueprint's local center is at (25, 25)
|
|
33
|
+
* });
|
|
34
|
+
* // Result: { x: 75, y: 175 } - point in blueprint's local coordinate system
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function blueprintLocalFromWorld(
|
|
38
|
+
px: number,
|
|
39
|
+
py: number,
|
|
40
|
+
bpGeom: { bx: number; by: number; cx: number; cy: number }
|
|
41
|
+
) {
|
|
42
|
+
return { x: px - bpGeom.bx + bpGeom.cx, y: py - bpGeom.by + bpGeom.cy };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Scale polygons by factor S about the origin (no translation/snapping)
|
|
47
|
+
*
|
|
48
|
+
* This function scales polygon coordinates by a factor S around the origin (0,0).
|
|
49
|
+
* Used for scaling silhouette masks and other geometric shapes when computing
|
|
50
|
+
* layouts with different scaling factors. The scaling is uniform (same factor
|
|
51
|
+
* for both X and Y axes).
|
|
52
|
+
*
|
|
53
|
+
* @param polys - Array of polygons to scale
|
|
54
|
+
* @param S - Scale factor (1.0 = no change, 0.5 = half size, 2.0 = double size)
|
|
55
|
+
* @returns New array of scaled polygons (original polygons are not modified)
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```typescript
|
|
59
|
+
* const originalPolys = [[
|
|
60
|
+
* { x: 10, y: 10 },
|
|
61
|
+
* { x: 20, y: 10 },
|
|
62
|
+
* { x: 15, y: 20 }
|
|
63
|
+
* ]];
|
|
64
|
+
*
|
|
65
|
+
* const scaledPolys = scalePolys(originalPolys, 2.0);
|
|
66
|
+
* // Result: [[
|
|
67
|
+
* // { x: 20, y: 20 },
|
|
68
|
+
* // { x: 40, y: 20 },
|
|
69
|
+
* // { x: 30, y: 40 }
|
|
70
|
+
* // ]]
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export function scalePolys(polys: Poly[], S: number): Poly[] {
|
|
74
|
+
if (!polys || polys.length === 0) return [];
|
|
75
|
+
return polys.map(poly => poly.map(p => ({ x: S * p.x, y: S * p.y })));
|
|
76
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core Components - Plugin-ready exports
|
|
3
|
+
*
|
|
4
|
+
* This module provides the main reusable components for jsPsych plugins.
|
|
5
|
+
* All components use dependency injection and have no hardcoded defaults.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Core game board component
|
|
9
|
+
export { default as GameBoard, useGameBoard } from './board/GameBoard';
|
|
10
|
+
export type { GameBoardConfig, GameBoardProps } from './board/GameBoard';
|
|
11
|
+
|
|
12
|
+
// Blueprint ring component for plugin UI
|
|
13
|
+
export { default as BlueprintRing, useBlueprintRing } from './pieces/BlueprintRing';
|
|
14
|
+
export type { BlueprintRingProps } from './pieces/BlueprintRing';
|
|
15
|
+
|
|
16
|
+
// Legacy Board components (deprecated - use GameBoard instead)
|
|
17
|
+
// export { default as Board } from '../../react/components/Board';
|
|
18
|
+
// export { default as BoardView } from '../../react/components/BoardView';
|
|
19
|
+
|
|
20
|
+
// Re-export hooks for custom implementations
|
|
21
|
+
export { usePieceState } from './board/usePieceState';
|
|
22
|
+
export { useAnchorGrid } from './board/useAnchorGrid';
|
|
23
|
+
export { useDragController } from './board/useDragController';
|
|
24
|
+
export { useClickController } from './board/useClickController';
|
|
25
|
+
|
|
26
|
+
// Re-export types for plugin development
|
|
27
|
+
export type {
|
|
28
|
+
PieceData,
|
|
29
|
+
PieceStateHook,
|
|
30
|
+
AnchorDots,
|
|
31
|
+
DragControllerHook,
|
|
32
|
+
ClickControllerHook
|
|
33
|
+
} from './board';
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BlueprintRing - Reusable blueprint selection interface component
|
|
3
|
+
*
|
|
4
|
+
* This component provides the central blueprint selection interface that displays
|
|
5
|
+
* available piece blueprints in a circular or semicircular arrangement around
|
|
6
|
+
* a center badge. It handles both primitive pieces and quickstash macros.
|
|
7
|
+
*
|
|
8
|
+
* ## Key Features
|
|
9
|
+
* - **Dual Mode Display**: Switches between primitives and quickstash collections
|
|
10
|
+
* - **Flexible Layout**: Adapts to circle and semicircle layout modes
|
|
11
|
+
* - **Interactive Badge**: Center button to toggle between piece collections
|
|
12
|
+
* - **Responsive Geometry**: Automatically calculates ring radius based on content
|
|
13
|
+
* - **Event Integration**: Provides structured callbacks for piece selection
|
|
14
|
+
*
|
|
15
|
+
* ## Architecture
|
|
16
|
+
* - Pure presentation component (no internal state management)
|
|
17
|
+
* - Receives all data and callbacks via props (dependency injection)
|
|
18
|
+
* - Computes ring geometry based on layout constraints
|
|
19
|
+
* - Renders SVG blueprint glyphs with proper scaling and positioning
|
|
20
|
+
*
|
|
21
|
+
* ## Usage
|
|
22
|
+
* Typically used within GameBoard or plugin components:
|
|
23
|
+
* ```typescript
|
|
24
|
+
* <BlueprintRing
|
|
25
|
+
* primitives={primitiveBlueprints}
|
|
26
|
+
* quickstash={macroBlueprints}
|
|
27
|
+
* currentView="primitives"
|
|
28
|
+
* layout={computedLayout}
|
|
29
|
+
* onBlueprintPointerDown={handleBlueprintSelection}
|
|
30
|
+
* onCenterBadgePointerDown={handleViewToggle}
|
|
31
|
+
* />
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* @see {@link GameBoard} Primary container that uses this component
|
|
35
|
+
* @see {@link useBlueprintRing} Hook for managing blueprint ring state
|
|
36
|
+
* @since Phase 3.3 - Extracted from monolithic Board component
|
|
37
|
+
* @author Claude Code Assistant
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import React from "react";
|
|
41
|
+
import type { Blueprint, TanKind, PrimitiveBlueprint } from "@/core/domain/types";
|
|
42
|
+
import type { CircleLayout } from "@/core/domain/layout";
|
|
43
|
+
import { boundsOfBlueprint } from "@/core/engine/geometry";
|
|
44
|
+
import { CONFIG } from "@/core/config/config";
|
|
45
|
+
|
|
46
|
+
function pathD(poly: any[]) {
|
|
47
|
+
return `M ${poly.map((pt: any) => `${pt.x} ${pt.y}`).join(" L ")} Z`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Props interface for BlueprintRing component
|
|
52
|
+
*
|
|
53
|
+
* This interface defines all the data and callbacks needed to render
|
|
54
|
+
* the blueprint selection interface. All props use dependency injection.
|
|
55
|
+
*/
|
|
56
|
+
export interface BlueprintRingProps {
|
|
57
|
+
/** Array of primitive tangram piece blueprints */
|
|
58
|
+
primitives: PrimitiveBlueprint[];
|
|
59
|
+
|
|
60
|
+
/** Array of quickstash/macro piece blueprints */
|
|
61
|
+
quickstash: Blueprint[];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Current view mode for blueprint ring
|
|
65
|
+
* - "primitives": Show primitive tangram pieces
|
|
66
|
+
* - "quickstash": Show macro/composite pieces
|
|
67
|
+
*/
|
|
68
|
+
currentView: "primitives" | "quickstash";
|
|
69
|
+
|
|
70
|
+
/** Computed circle layout containing geometry information */
|
|
71
|
+
layout: CircleLayout;
|
|
72
|
+
|
|
73
|
+
/** Radius of the center toggle badge in pixels */
|
|
74
|
+
badgeR: number;
|
|
75
|
+
|
|
76
|
+
/** Center position of the toggle badge */
|
|
77
|
+
badgeCenter: { x: number; y: number };
|
|
78
|
+
|
|
79
|
+
/** Maximum number of quickstash slots to display */
|
|
80
|
+
maxQuickstashSlots: number;
|
|
81
|
+
|
|
82
|
+
/** ID of currently dragging piece (null if none) - used for interaction state */
|
|
83
|
+
draggingId: string | null;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Callback fired when user starts interacting with a blueprint
|
|
87
|
+
* @param e - Pointer event from the interaction
|
|
88
|
+
* @param bp - Blueprint that was selected
|
|
89
|
+
* @param bpGeom - Geometry information for the selected blueprint
|
|
90
|
+
*/
|
|
91
|
+
onBlueprintPointerDown: (
|
|
92
|
+
e: React.PointerEvent,
|
|
93
|
+
bp: Blueprint,
|
|
94
|
+
bpGeom: { bx: number; by: number; cx: number; cy: number }
|
|
95
|
+
) => void;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Callback fired when user clicks the center badge to toggle views
|
|
99
|
+
* @param e - Pointer event from the badge click
|
|
100
|
+
*/
|
|
101
|
+
onCenterBadgePointerDown: (e: React.PointerEvent) => void;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Helper function to lookup primitive blueprints by kind
|
|
105
|
+
* Used for rendering composite pieces that reference primitives
|
|
106
|
+
*/
|
|
107
|
+
getPrimitive: (kind: TanKind | string) => PrimitiveBlueprint | undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export default function BlueprintRing(props: BlueprintRingProps) {
|
|
111
|
+
const {
|
|
112
|
+
primitives,
|
|
113
|
+
quickstash,
|
|
114
|
+
currentView,
|
|
115
|
+
layout,
|
|
116
|
+
badgeR,
|
|
117
|
+
badgeCenter,
|
|
118
|
+
maxQuickstashSlots,
|
|
119
|
+
draggingId,
|
|
120
|
+
onBlueprintPointerDown,
|
|
121
|
+
onCenterBadgePointerDown,
|
|
122
|
+
getPrimitive
|
|
123
|
+
} = props;
|
|
124
|
+
|
|
125
|
+
// Determine which blueprints to show
|
|
126
|
+
const blueprints: Blueprint[] = currentView === "primitives" ? primitives : quickstash;
|
|
127
|
+
|
|
128
|
+
// Calculate ring geometry
|
|
129
|
+
const QS_SLOTS = maxQuickstashSlots;
|
|
130
|
+
const PRIM_SLOTS = primitives.length;
|
|
131
|
+
const slotsForView = currentView === "primitives" ? Math.max(1, PRIM_SLOTS) : Math.max(1, QS_SLOTS);
|
|
132
|
+
const sweep = layout.mode === "circle" ? Math.PI * 2 : Math.PI;
|
|
133
|
+
const delta = sweep / slotsForView;
|
|
134
|
+
|
|
135
|
+
const start = layout.mode === "circle" ? -Math.PI / 2 : Math.PI;
|
|
136
|
+
const blueprintTheta = (i: number) => start + (i + 0.5) * delta;
|
|
137
|
+
|
|
138
|
+
// Chord requirement calculation
|
|
139
|
+
const anchorsDiameterToPx = (anchorsDiag: number, gridPx: number = CONFIG.layout.grid.stepPx) =>
|
|
140
|
+
anchorsDiag * Math.SQRT2 * gridPx;
|
|
141
|
+
const reqAnchors = currentView === "primitives"
|
|
142
|
+
? CONFIG.layout.constraints.primitiveDiamAnchors
|
|
143
|
+
: CONFIG.layout.constraints.quickstashDiamAnchors;
|
|
144
|
+
const D_slot = anchorsDiameterToPx(reqAnchors);
|
|
145
|
+
const R_needed = D_slot / (2 * Math.max(1e-9, Math.sin(delta / 2)));
|
|
146
|
+
|
|
147
|
+
// Minimum radius to avoid overlapping with badge
|
|
148
|
+
// Badge takes up: badgeR + margin, plus we need half the slot diameter for clearance
|
|
149
|
+
const R_min = badgeR + CONFIG.size.centerBadge.marginPx + D_slot / 2;
|
|
150
|
+
const ringMax = layout.innerR - (badgeR + CONFIG.size.centerBadge.marginPx);
|
|
151
|
+
|
|
152
|
+
// Clamp to [R_min, ringMax]
|
|
153
|
+
const blueprintRingR = Math.min(Math.max(R_needed, R_min), ringMax);
|
|
154
|
+
|
|
155
|
+
// Blueprint glyph renderer
|
|
156
|
+
const renderBlueprintGlyph = (bp: Blueprint, bx: number, by: number) => {
|
|
157
|
+
const bb = boundsOfBlueprint(bp, (k: string) => getPrimitive(k as TanKind)!);
|
|
158
|
+
const cx = bb.min.x + bb.width / 2;
|
|
159
|
+
const cy = bb.min.y + bb.height / 2;
|
|
160
|
+
const selected = false; // Future enhancement: add selection highlighting
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<g
|
|
164
|
+
key={bp.id}
|
|
165
|
+
transform={`translate(${bx}, ${by}) scale(1) translate(${-cx}, ${-cy})`}
|
|
166
|
+
>
|
|
167
|
+
{("shape" in bp ? bp.shape : []).map((poly, idx) => (
|
|
168
|
+
<path
|
|
169
|
+
key={idx}
|
|
170
|
+
d={pathD(poly)}
|
|
171
|
+
fill={CONFIG.color.blueprint.fill}
|
|
172
|
+
opacity={CONFIG.opacity.blueprint}
|
|
173
|
+
stroke={selected ? CONFIG.color.blueprint.selectedStroke : "none"}
|
|
174
|
+
strokeWidth={selected ? 2 : 0}
|
|
175
|
+
pointerEvents="visiblePainted"
|
|
176
|
+
style={{ cursor: "pointer" }}
|
|
177
|
+
onPointerDown={(e) => onBlueprintPointerDown(e as any, bp, { bx, by, cx, cy })}
|
|
178
|
+
/>
|
|
179
|
+
))}
|
|
180
|
+
</g>
|
|
181
|
+
);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<g className="blueprint-ring">
|
|
186
|
+
{/* Center badge */}
|
|
187
|
+
<g
|
|
188
|
+
transform={`translate(${badgeCenter.x}, ${badgeCenter.y})`}
|
|
189
|
+
style={{ cursor: draggingId ? "default" : "pointer" }}
|
|
190
|
+
onPointerDown={onCenterBadgePointerDown}
|
|
191
|
+
>
|
|
192
|
+
<circle r={badgeR} fill={CONFIG.color.blueprint.badgeFill} />
|
|
193
|
+
<text
|
|
194
|
+
textAnchor="middle"
|
|
195
|
+
dominantBaseline="middle"
|
|
196
|
+
fontSize={CONFIG.size.badgeFontPx}
|
|
197
|
+
fill={CONFIG.color.blueprint.labelFill}
|
|
198
|
+
pointerEvents="none"
|
|
199
|
+
>
|
|
200
|
+
{currentView}
|
|
201
|
+
</text>
|
|
202
|
+
</g>
|
|
203
|
+
|
|
204
|
+
{/* Blueprint glyphs in ring formation */}
|
|
205
|
+
{blueprints.map((bp: Blueprint, i: number) => {
|
|
206
|
+
const theta = blueprintTheta(i);
|
|
207
|
+
const bx = layout.cx + blueprintRingR * Math.cos(theta);
|
|
208
|
+
const by = layout.cy + blueprintRingR * Math.sin(theta);
|
|
209
|
+
return renderBlueprintGlyph(bp, bx, by);
|
|
210
|
+
})}
|
|
211
|
+
</g>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Hook for managing blueprint ring state
|
|
217
|
+
* Useful for components that need more control over blueprint ring behavior
|
|
218
|
+
*/
|
|
219
|
+
export function useBlueprintRing(
|
|
220
|
+
primitives: PrimitiveBlueprint[],
|
|
221
|
+
quickstash: Blueprint[],
|
|
222
|
+
initialView: "primitives" | "quickstash" = "quickstash"
|
|
223
|
+
) {
|
|
224
|
+
const [currentView, setCurrentView] = React.useState<"primitives" | "quickstash">(initialView);
|
|
225
|
+
|
|
226
|
+
const switchView = React.useCallback(() => {
|
|
227
|
+
setCurrentView(prev => prev === "primitives" ? "quickstash" : "primitives");
|
|
228
|
+
}, []);
|
|
229
|
+
|
|
230
|
+
const blueprints = currentView === "primitives" ? primitives : quickstash;
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
currentView,
|
|
234
|
+
blueprints,
|
|
235
|
+
switchView,
|
|
236
|
+
setCurrentView
|
|
237
|
+
};
|
|
238
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// src/core/config/config.ts
|
|
2
|
+
export type Config = {
|
|
3
|
+
color: {
|
|
4
|
+
bands: {
|
|
5
|
+
silhouette: { fillEven: string; fillOdd: string; stroke: string };
|
|
6
|
+
workspace: { fillEven: string; fillOdd: string; stroke: string };
|
|
7
|
+
};
|
|
8
|
+
completion: { fill: string; stroke: string };
|
|
9
|
+
silhouetteMask: string;
|
|
10
|
+
anchors: { invalid: string; valid: string };
|
|
11
|
+
piece: { draggingFill: string; validFill: string; invalidFill: string; invalidStroke: string; selectedStroke: string; allGreenStroke: string; borderStroke: string };
|
|
12
|
+
ui: { light: string; dark: string };
|
|
13
|
+
blueprint: { fill: string; selectedStroke: string; badgeFill: string; labelFill: string };
|
|
14
|
+
};
|
|
15
|
+
opacity: {
|
|
16
|
+
blueprint: number;
|
|
17
|
+
silhouetteMask: number;
|
|
18
|
+
anchors: { invalid: number; valid: number };
|
|
19
|
+
piece: { invalid: number; dragging: number; locked: number; normal: number };
|
|
20
|
+
};
|
|
21
|
+
size: {
|
|
22
|
+
stroke: { bandPx: number; pieceSelectedPx: number; allGreenStrokePx: number; pieceBorderPx: number };
|
|
23
|
+
anchorRadiusPx: { valid: number; invalid: number };
|
|
24
|
+
badgeFontPx: number;
|
|
25
|
+
centerBadge: { fractionOfOuterR: number; minPx: number; marginPx: number };
|
|
26
|
+
};
|
|
27
|
+
layout: {
|
|
28
|
+
grid: { stepPx: number; unitPx: number };
|
|
29
|
+
paddingPx: number;
|
|
30
|
+
/** renamed from capacity → constraints */
|
|
31
|
+
constraints: {
|
|
32
|
+
workspaceDiamAnchors: number;
|
|
33
|
+
quickstashDiamAnchors: number;
|
|
34
|
+
primitiveDiamAnchors: number;
|
|
35
|
+
};
|
|
36
|
+
defaults: { maxQuickstashSlots: number };
|
|
37
|
+
};
|
|
38
|
+
game: {
|
|
39
|
+
snapRadiusPx: number;
|
|
40
|
+
showBorders: boolean;
|
|
41
|
+
hideTouchingBorders: boolean;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const CONFIG: Config = {
|
|
46
|
+
color: {
|
|
47
|
+
bands: {
|
|
48
|
+
silhouette: { fillEven: "#eef2ff", fillOdd: "#f6f7fb", stroke: "#c7d2fe" },
|
|
49
|
+
workspace: { fillEven: "#f3f4f6", fillOdd: "#f9fafb", stroke: "#e5e7eb" }
|
|
50
|
+
},
|
|
51
|
+
completion: { fill: "#dcfce7", stroke: "#86efac" },
|
|
52
|
+
silhouetteMask: "#94a3b8",
|
|
53
|
+
anchors: { invalid: "#7dd3fc", valid: "#475569" },
|
|
54
|
+
piece: { draggingFill: "#1d4ed8", validFill: "#60a5fa", invalidFill: "#ef4444", invalidStroke: "#dc2626", selectedStroke: "#111827", allGreenStroke: "#86efac", borderStroke: "#374151" },
|
|
55
|
+
ui: { light: "#60a5fa", dark: "#1d4ed8" },
|
|
56
|
+
blueprint: { fill: "#374151", selectedStroke: "#111827", badgeFill: "#eef2ff", labelFill: "#374151" }
|
|
57
|
+
},
|
|
58
|
+
opacity: {
|
|
59
|
+
blueprint: 0.95,
|
|
60
|
+
silhouetteMask: 0.45,
|
|
61
|
+
anchors: { valid: 0.80, invalid: 0.50 },
|
|
62
|
+
piece: { invalid: 0.35, dragging: 0.60, locked: 0.70, normal: 0.95 },
|
|
63
|
+
},
|
|
64
|
+
size: {
|
|
65
|
+
stroke: { bandPx: 1, pieceSelectedPx: 1.5, allGreenStrokePx: 6, pieceBorderPx: 1 },
|
|
66
|
+
anchorRadiusPx: { valid: 1.0, invalid: 1.0 },
|
|
67
|
+
badgeFontPx: 12,
|
|
68
|
+
centerBadge: { fractionOfOuterR: 0.15, minPx: 20, marginPx: 4 }
|
|
69
|
+
},
|
|
70
|
+
layout: {
|
|
71
|
+
grid: { stepPx: 20, unitPx: 40 },
|
|
72
|
+
paddingPx: 1,
|
|
73
|
+
constraints: {
|
|
74
|
+
workspaceDiamAnchors: 10, // num anchors req'd to be on diagonal
|
|
75
|
+
quickstashDiamAnchors: 7, // num anchors req'd to be in single quickstash slot
|
|
76
|
+
primitiveDiamAnchors: 5,
|
|
77
|
+
},
|
|
78
|
+
defaults: { maxQuickstashSlots: 1 }
|
|
79
|
+
},
|
|
80
|
+
game: {
|
|
81
|
+
snapRadiusPx: 15,
|
|
82
|
+
showBorders: true,
|
|
83
|
+
hideTouchingBorders: true
|
|
84
|
+
}
|
|
85
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Blueprint, PrimitiveBlueprint, CompositeBlueprint, TanKind
|
|
3
|
+
} from "./types";
|
|
4
|
+
|
|
5
|
+
export function isComposite(bp: Blueprint): bp is CompositeBlueprint {
|
|
6
|
+
return (bp as CompositeBlueprint).parts !== undefined;
|
|
7
|
+
}
|
|
8
|
+
export function isPrimitive(bp: Blueprint): bp is PrimitiveBlueprint {
|
|
9
|
+
return !isComposite(bp);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class BlueprintRegistry {
|
|
13
|
+
private byId = new Map<string, Blueprint>();
|
|
14
|
+
private primitivesByKind = new Map<TanKind, PrimitiveBlueprint>();
|
|
15
|
+
|
|
16
|
+
registerAll(bps: Blueprint[]) {
|
|
17
|
+
for (const bp of bps) this.register(bp);
|
|
18
|
+
}
|
|
19
|
+
register(bp: Blueprint) {
|
|
20
|
+
this.byId.set(bp.id, bp);
|
|
21
|
+
if (isPrimitive(bp)) this.primitivesByKind.set(bp.kind, bp);
|
|
22
|
+
}
|
|
23
|
+
get(id: string) { return this.byId.get(id); }
|
|
24
|
+
getPrimitive(kind: TanKind | string) { return this.primitivesByKind.get(kind as TanKind); }
|
|
25
|
+
}
|