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,102 @@
|
|
|
1
|
+
// core/validate/complete.ts
|
|
2
|
+
import type { Poly, Vec } from "@/core/domain/types";
|
|
3
|
+
import { pointInPolygon } from "@/core/engine/geometry";
|
|
4
|
+
import { CONFIG } from "@/core/config/config";
|
|
5
|
+
|
|
6
|
+
/** AABB of one or more polys */
|
|
7
|
+
function aabb(polys: Poly[]) {
|
|
8
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
9
|
+
for (const poly of polys) for (const p of poly) {
|
|
10
|
+
if (p.x < minX) minX = p.x;
|
|
11
|
+
if (p.y < minY) minY = p.y;
|
|
12
|
+
if (p.x > maxX) maxX = p.x;
|
|
13
|
+
if (p.y > maxY) maxY = p.y;
|
|
14
|
+
}
|
|
15
|
+
return { minX, minY, maxX, maxY };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Cell centers over an AABB, aligned to GRID_PX centers (k + GRID_PX/2). */
|
|
19
|
+
function cellCentersInAABB(minX: number, minY: number, maxX: number, maxY: number): Vec[] {
|
|
20
|
+
const GRID_PX = CONFIG.layout.grid.stepPx;
|
|
21
|
+
const x0 = Math.floor((minX - GRID_PX/2) / GRID_PX) * GRID_PX + GRID_PX/2;
|
|
22
|
+
const y0 = Math.floor((minY - GRID_PX/2) / GRID_PX) * GRID_PX + GRID_PX/2;
|
|
23
|
+
const out: Vec[] = [];
|
|
24
|
+
for (let y = y0; y <= maxY; y += GRID_PX) {
|
|
25
|
+
for (let x = x0; x <= maxX; x += GRID_PX) out.push({ x, y });
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Build a set of grid centers that lie inside ANY of the polygons. */
|
|
31
|
+
export function cellSetForPolys(polys: Poly[]): Set<string> {
|
|
32
|
+
if (!polys.length) return new Set();
|
|
33
|
+
const { minX, minY, maxX, maxY } = aabb(polys);
|
|
34
|
+
const centers = cellCentersInAABB(minX, minY, maxX, maxY);
|
|
35
|
+
const key = (p: Vec) => `${p.x},${p.y}`;
|
|
36
|
+
const S = new Set<string>();
|
|
37
|
+
center: for (const c of centers) {
|
|
38
|
+
for (const poly of polys) {
|
|
39
|
+
if (pointInPolygon(c, poly)) { S.add(key(c)); continue center; }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return S;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Anchors | Silhouette: complete iff silhouette cell set ⊆ piece cell set. */
|
|
46
|
+
export function anchorsSilhouetteComplete(silPolys: Poly[], piecePolys: Poly[]): boolean {
|
|
47
|
+
if (!silPolys.length) return false;
|
|
48
|
+
const S = cellSetForPolys(silPolys);
|
|
49
|
+
if (S.size === 0) return false;
|
|
50
|
+
const P = cellSetForPolys(piecePolys);
|
|
51
|
+
if (P.size === 0) return false;
|
|
52
|
+
for (const k of S) if (!P.has(k)) return false;
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- helpers for translation-invariant equality on the grid -----------------
|
|
57
|
+
function stringSetToVecs(S: Set<string>): Vec[] {
|
|
58
|
+
const out: Vec[] = [];
|
|
59
|
+
for (const k of S) {
|
|
60
|
+
const [xs, ys] = k.split(",");
|
|
61
|
+
out.push({ x: Number(xs), y: Number(ys) });
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeCells(S: Set<string>): Set<string> {
|
|
67
|
+
if (S.size === 0) return S;
|
|
68
|
+
const pts = stringSetToVecs(S);
|
|
69
|
+
let minX = Infinity,
|
|
70
|
+
minY = Infinity;
|
|
71
|
+
for (const p of pts) {
|
|
72
|
+
if (p.x < minX) minX = p.x;
|
|
73
|
+
if (p.y < minY) minY = p.y;
|
|
74
|
+
}
|
|
75
|
+
const N = new Set<string>();
|
|
76
|
+
for (const p of pts) N.add(`${p.x - minX},${p.y - minY}`);
|
|
77
|
+
return N;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function setsEqual(a: Set<string>, b: Set<string>): boolean {
|
|
81
|
+
if (a.size !== b.size) return false;
|
|
82
|
+
for (const k of a) if (!b.has(k)) return false;
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Anchors | Workspace:
|
|
88
|
+
* complete iff the pieces form the sector's silhouette pattern up to a GRID_PX translation.
|
|
89
|
+
* (No rotation/mirror; exact coverage — no extra cells.)
|
|
90
|
+
*/
|
|
91
|
+
export function anchorsWorkspaceComplete(
|
|
92
|
+
silPolys: Poly[],
|
|
93
|
+
piecePolys: Poly[]
|
|
94
|
+
): boolean {
|
|
95
|
+
if (!silPolys.length) return false;
|
|
96
|
+
const Sraw = cellSetForPolys(silPolys);
|
|
97
|
+
const Praw = cellSetForPolys(piecePolys);
|
|
98
|
+
if (Sraw.size === 0 || Praw.size === 0) return false;
|
|
99
|
+
const S = normalizeCells(Sraw);
|
|
100
|
+
const P = normalizeCells(Praw);
|
|
101
|
+
return setsEqual(S, P);
|
|
102
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Face-to-Face Attachment Validation for Prep Mode
|
|
3
|
+
*
|
|
4
|
+
* Validates that pieces in prep mode attach to existing pieces by sharing
|
|
5
|
+
* at least 1 anchor unit of edge contact. Uses discrete unit segments
|
|
6
|
+
* for precise edge detection.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Piece, Vec, Blueprint } from "../../domain/types";
|
|
10
|
+
import { CONFIG } from "../../config/config";
|
|
11
|
+
import { boundsOfBlueprint } from "../geometry";
|
|
12
|
+
|
|
13
|
+
export interface EdgeAttachmentResult {
|
|
14
|
+
isAttached: boolean;
|
|
15
|
+
attachedPieceIds: string[];
|
|
16
|
+
sharedEdgeLength: number; // In anchor units
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UnitSegment {
|
|
20
|
+
a: Vec;
|
|
21
|
+
b: Vec;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Convert a polygon edge into unit-length segments
|
|
26
|
+
* Each segment represents a discrete coordinate unit along the edge
|
|
27
|
+
*/
|
|
28
|
+
function edgeToUnitSegments(start: Vec, end: Vec, gridSize: number): UnitSegment[] {
|
|
29
|
+
const segments: UnitSegment[] = [];
|
|
30
|
+
|
|
31
|
+
// Convert to grid coordinates
|
|
32
|
+
const startGrid = {
|
|
33
|
+
x: Math.round(start.x / gridSize),
|
|
34
|
+
y: Math.round(start.y / gridSize)
|
|
35
|
+
};
|
|
36
|
+
const endGrid = {
|
|
37
|
+
x: Math.round(end.x / gridSize),
|
|
38
|
+
y: Math.round(end.y / gridSize)
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const dx = endGrid.x - startGrid.x;
|
|
42
|
+
const dy = endGrid.y - startGrid.y;
|
|
43
|
+
|
|
44
|
+
if (dx === 0 && dy === 0) return []; // Zero-length edge
|
|
45
|
+
|
|
46
|
+
const steps = Math.max(Math.abs(dx), Math.abs(dy));
|
|
47
|
+
const stepX = dx / steps;
|
|
48
|
+
const stepY = dy / steps;
|
|
49
|
+
|
|
50
|
+
for (let i = 0; i < steps; i++) {
|
|
51
|
+
const aX = Math.round(startGrid.x + i * stepX);
|
|
52
|
+
const aY = Math.round(startGrid.y + i * stepY);
|
|
53
|
+
const bX = Math.round(startGrid.x + (i + 1) * stepX);
|
|
54
|
+
const bY = Math.round(startGrid.y + (i + 1) * stepY);
|
|
55
|
+
segments.push({
|
|
56
|
+
a: { x: aX, y: aY },
|
|
57
|
+
b: { x: bX, y: bY }
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return segments;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get all unit segments for a piece (all edges broken down into unit-length segments)
|
|
68
|
+
* Uses the same coordinate system as piecePolysAt for consistency with visual rendering
|
|
69
|
+
*/
|
|
70
|
+
function getPieceUnitSegments(
|
|
71
|
+
piece: Piece,
|
|
72
|
+
getBlueprint: (id: string) => Blueprint | undefined,
|
|
73
|
+
getPrimitive: (kind: string) => any,
|
|
74
|
+
gridSize: number
|
|
75
|
+
): UnitSegment[] {
|
|
76
|
+
const blueprint = getBlueprint(piece.blueprintId);
|
|
77
|
+
if (!blueprint?.shape) return [];
|
|
78
|
+
|
|
79
|
+
// Calculate offset using same logic as piecePolysAt
|
|
80
|
+
const bb = boundsOfBlueprint(blueprint, getPrimitive);
|
|
81
|
+
const ox = piece.pos.x - bb.min.x;
|
|
82
|
+
const oy = piece.pos.y - bb.min.y;
|
|
83
|
+
|
|
84
|
+
const allSegments: UnitSegment[] = [];
|
|
85
|
+
|
|
86
|
+
// Translate polygons and convert edges to unit segments
|
|
87
|
+
for (const poly of blueprint.shape) {
|
|
88
|
+
const translatedPoly = poly.map((vertex: Vec) => ({
|
|
89
|
+
x: vertex.x + ox,
|
|
90
|
+
y: vertex.y + oy
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
for (let i = 0; i < translatedPoly.length; i++) {
|
|
94
|
+
const current = translatedPoly[i];
|
|
95
|
+
const next = translatedPoly[(i + 1) % translatedPoly.length];
|
|
96
|
+
|
|
97
|
+
if (!current || !next) continue;
|
|
98
|
+
|
|
99
|
+
allSegments.push(...edgeToUnitSegments(current, next, gridSize));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return allSegments;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Main face-to-face attachment validation function
|
|
108
|
+
*/
|
|
109
|
+
export function checkFaceToFaceAttachment(
|
|
110
|
+
piece: Piece,
|
|
111
|
+
existingPieces: Piece[],
|
|
112
|
+
getBlueprint: (id: string) => Blueprint | undefined,
|
|
113
|
+
getPrimitive: (kind: string) => any
|
|
114
|
+
): EdgeAttachmentResult {
|
|
115
|
+
if (existingPieces.length === 0) {
|
|
116
|
+
return { isAttached: true, attachedPieceIds: [], sharedEdgeLength: 0 };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const gridSize = CONFIG.layout.grid.stepPx;
|
|
120
|
+
const newPieceSegments = getPieceUnitSegments(piece, getBlueprint, getPrimitive, gridSize);
|
|
121
|
+
|
|
122
|
+
if (newPieceSegments.length === 0) {
|
|
123
|
+
return { isAttached: false, attachedPieceIds: [], sharedEdgeLength: 0 };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let totalSharedLength = 0;
|
|
127
|
+
const attachedPieceIds: string[] = [];
|
|
128
|
+
|
|
129
|
+
// Check against each existing piece
|
|
130
|
+
for (const existingPiece of existingPieces) {
|
|
131
|
+
const existingSegments = getPieceUnitSegments(existingPiece, getBlueprint, getPrimitive, gridSize);
|
|
132
|
+
|
|
133
|
+
// Find shared segments (undirected edge matching)
|
|
134
|
+
const sharedSegments = newPieceSegments.filter(newSeg =>
|
|
135
|
+
existingSegments.some(existingSeg =>
|
|
136
|
+
// Check if segments share the same endpoints (identical or reversed)
|
|
137
|
+
(newSeg.a.x === existingSeg.a.x && newSeg.a.y === existingSeg.a.y &&
|
|
138
|
+
newSeg.b.x === existingSeg.b.x && newSeg.b.y === existingSeg.b.y) ||
|
|
139
|
+
(newSeg.a.x === existingSeg.b.x && newSeg.a.y === existingSeg.b.y &&
|
|
140
|
+
newSeg.b.x === existingSeg.a.x && newSeg.b.y === existingSeg.a.y)
|
|
141
|
+
)
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
if (sharedSegments.length > 0) {
|
|
145
|
+
totalSharedLength += sharedSegments.length;
|
|
146
|
+
attachedPieceIds.push(existingPiece.id);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
isAttached: totalSharedLength >= 1,
|
|
152
|
+
attachedPieceIds,
|
|
153
|
+
sharedEdgeLength: totalSharedLength
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Check if removing a piece would disconnect the remaining pieces in a sector
|
|
159
|
+
* Uses graph connectivity to ensure all remaining pieces stay connected
|
|
160
|
+
*/
|
|
161
|
+
export function wouldRemovalDisconnectSector(
|
|
162
|
+
pieceToRemove: Piece,
|
|
163
|
+
allPiecesInSector: Piece[],
|
|
164
|
+
getBlueprint: (id: string) => Blueprint | undefined,
|
|
165
|
+
getPrimitive: (kind: string) => any
|
|
166
|
+
): boolean {
|
|
167
|
+
// If there are 0 or 1 pieces remaining, no disconnection possible
|
|
168
|
+
if (allPiecesInSector.length <= 2) return false;
|
|
169
|
+
|
|
170
|
+
const remainingPieces = allPiecesInSector.filter(p => p.id !== pieceToRemove.id);
|
|
171
|
+
if (remainingPieces.length <= 1) return false;
|
|
172
|
+
|
|
173
|
+
// Build adjacency graph of remaining pieces
|
|
174
|
+
const adjacencyMap = new Map<string, string[]>();
|
|
175
|
+
|
|
176
|
+
// Initialize adjacency map
|
|
177
|
+
for (const piece of remainingPieces) {
|
|
178
|
+
adjacencyMap.set(piece.id, []);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Check all pairs of remaining pieces for connectivity
|
|
182
|
+
for (let i = 0; i < remainingPieces.length; i++) {
|
|
183
|
+
for (let j = i + 1; j < remainingPieces.length; j++) {
|
|
184
|
+
const piece1 = remainingPieces[i];
|
|
185
|
+
const piece2 = remainingPieces[j];
|
|
186
|
+
|
|
187
|
+
if (!piece1 || !piece2) continue;
|
|
188
|
+
|
|
189
|
+
const result = checkFaceToFaceAttachment(piece1, [piece2], getBlueprint, getPrimitive);
|
|
190
|
+
if (result.isAttached) {
|
|
191
|
+
adjacencyMap.get(piece1.id)!.push(piece2.id);
|
|
192
|
+
adjacencyMap.get(piece2.id)!.push(piece1.id);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Check if graph is connected using DFS
|
|
198
|
+
const visited = new Set<string>();
|
|
199
|
+
const startPiece = remainingPieces[0];
|
|
200
|
+
|
|
201
|
+
if (!startPiece) return false;
|
|
202
|
+
|
|
203
|
+
function dfs(pieceId: string) {
|
|
204
|
+
if (visited.has(pieceId)) return;
|
|
205
|
+
visited.add(pieceId);
|
|
206
|
+
|
|
207
|
+
const neighbors = adjacencyMap.get(pieceId) || [];
|
|
208
|
+
for (const neighborId of neighbors) {
|
|
209
|
+
dfs(neighborId);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
dfs(startPiece.id);
|
|
214
|
+
|
|
215
|
+
// If we visited all pieces, the graph is connected
|
|
216
|
+
return visited.size !== remainingPieces.length;
|
|
217
|
+
}
|