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,742 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InteractionTracker - Event accumulation and data tracking for experiments
|
|
3
|
+
*
|
|
4
|
+
* This class listens to game controller events and builds structured data
|
|
5
|
+
* according to the data-tracking.ts schema. It generates InteractionEvent
|
|
6
|
+
* objects and accumulates TrialEndData for export.
|
|
7
|
+
*
|
|
8
|
+
* ## Key Responsibilities
|
|
9
|
+
* - Track interaction lifecycle (pickup → placedown)
|
|
10
|
+
* - Accumulate click events and mouse tracking between interactions
|
|
11
|
+
* - Generate InteractionEvent objects with state snapshots
|
|
12
|
+
* - Build TrialEndData on trial completion
|
|
13
|
+
* - Call onInteraction/onTrialEnd callbacks for data export
|
|
14
|
+
*
|
|
15
|
+
* ## Architecture
|
|
16
|
+
* ```
|
|
17
|
+
* User Interaction
|
|
18
|
+
* ↓
|
|
19
|
+
* UI Controllers (drag/click)
|
|
20
|
+
* ↓
|
|
21
|
+
* BaseGameController Events
|
|
22
|
+
* ↓
|
|
23
|
+
* InteractionTracker
|
|
24
|
+
* ↓
|
|
25
|
+
* onInteraction(event) → socket.emit()
|
|
26
|
+
* onTrialEnd(data) → socket.emit()
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
31
|
+
import type { BaseGameController } from '../engine/state/BaseGameController';
|
|
32
|
+
import { boundsOfBlueprint, piecePolysAt } from '../engine/geometry';
|
|
33
|
+
import { CONFIG } from '../config/config';
|
|
34
|
+
import type {
|
|
35
|
+
InteractionEvent,
|
|
36
|
+
StateSnapshot,
|
|
37
|
+
ConstructionTrialData,
|
|
38
|
+
PrepTrialData,
|
|
39
|
+
DataTrackingCallbacks,
|
|
40
|
+
SectorSnapshot,
|
|
41
|
+
PieceSnapshot,
|
|
42
|
+
MacroSnapshot
|
|
43
|
+
} from './data-tracking';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Pickup event data (temporary storage until placedown)
|
|
47
|
+
*/
|
|
48
|
+
interface PickupData {
|
|
49
|
+
pieceId: string;
|
|
50
|
+
blueprintId: string;
|
|
51
|
+
blueprintType: 'primitive' | 'composite';
|
|
52
|
+
timestamp: number;
|
|
53
|
+
source: 'blueprint' | 'sector';
|
|
54
|
+
sectorId: string | undefined;
|
|
55
|
+
position: { x: number; y: number };
|
|
56
|
+
vertices: number[][][];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Click event data (accumulated until interaction completes)
|
|
61
|
+
*/
|
|
62
|
+
interface ClickEventData {
|
|
63
|
+
timestamp: number;
|
|
64
|
+
location: { x: number; y: number };
|
|
65
|
+
clickType: 'blueprint_view_switch' | 'invalid_placement' | 'sector_complete_attempt';
|
|
66
|
+
blueprintViewSwitch?: {
|
|
67
|
+
from: 'quickstash' | 'primitives';
|
|
68
|
+
to: 'quickstash' | 'primitives';
|
|
69
|
+
};
|
|
70
|
+
invalidPlacement?: {
|
|
71
|
+
reason: 'overlapping' | 'outside_bounds' | 'no_valid_anchor' | 'sector_complete';
|
|
72
|
+
attemptedSectorId?: string;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Mouse tracking event data (accumulated until interaction completes)
|
|
78
|
+
*/
|
|
79
|
+
interface MouseTrackingData {
|
|
80
|
+
timestamp: number;
|
|
81
|
+
anchorX: number;
|
|
82
|
+
anchorY: number;
|
|
83
|
+
sectorId: string | undefined;
|
|
84
|
+
phase: 'before_pickup' | 'while_holding';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* InteractionTracker - Main tracking class
|
|
89
|
+
*/
|
|
90
|
+
export class InteractionTracker {
|
|
91
|
+
// IDs
|
|
92
|
+
private gameId: string;
|
|
93
|
+
private trialId: string;
|
|
94
|
+
|
|
95
|
+
// Callbacks
|
|
96
|
+
private callbacks: DataTrackingCallbacks;
|
|
97
|
+
|
|
98
|
+
// Controller reference
|
|
99
|
+
private controller: BaseGameController;
|
|
100
|
+
|
|
101
|
+
// Trial timing
|
|
102
|
+
private trialStartTime: number;
|
|
103
|
+
private readonly gridStep: number = CONFIG.layout.grid.stepPx;
|
|
104
|
+
|
|
105
|
+
// Interaction state
|
|
106
|
+
private interactionIndex: number = 0;
|
|
107
|
+
private currentPickup: PickupData | null = null;
|
|
108
|
+
private previousSnapshotId: string | undefined = undefined;
|
|
109
|
+
|
|
110
|
+
// Event accumulation (reset after each interaction)
|
|
111
|
+
private clickEvents: ClickEventData[] = [];
|
|
112
|
+
private mouseTracking: MouseTrackingData[] = [];
|
|
113
|
+
|
|
114
|
+
// Interaction history (for TrialEndData)
|
|
115
|
+
private interactions: InteractionEvent[] = [];
|
|
116
|
+
|
|
117
|
+
// Construction-specific tracking
|
|
118
|
+
private completionTimes: Array<{ sectorId: string; completedAt: number }> = [];
|
|
119
|
+
|
|
120
|
+
// Prep-specific tracking
|
|
121
|
+
private createdMacros: MacroSnapshot[] = [];
|
|
122
|
+
|
|
123
|
+
constructor(
|
|
124
|
+
controller: BaseGameController,
|
|
125
|
+
callbacks: DataTrackingCallbacks,
|
|
126
|
+
trialId?: string,
|
|
127
|
+
gameId?: string
|
|
128
|
+
) {
|
|
129
|
+
this.controller = controller;
|
|
130
|
+
this.callbacks = callbacks;
|
|
131
|
+
this.trialId = trialId || uuidv4();
|
|
132
|
+
this.gameId = gameId || uuidv4();
|
|
133
|
+
this.trialStartTime = Date.now();
|
|
134
|
+
|
|
135
|
+
// Register tracking callbacks with controller
|
|
136
|
+
this.controller.setTrackingCallbacks({
|
|
137
|
+
onSectorCompleted: (sectorId) => this.recordSectorCompletion(sectorId)
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Cleanup method to unregister from controller
|
|
143
|
+
*/
|
|
144
|
+
dispose(): void {
|
|
145
|
+
this.controller.setTrackingCallbacks(null);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ===== Event Recording Methods =====
|
|
149
|
+
// Called by UI controllers to record events
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Record piece pickup event
|
|
153
|
+
*/
|
|
154
|
+
recordPickup(
|
|
155
|
+
pieceId: string,
|
|
156
|
+
blueprintId: string,
|
|
157
|
+
blueprintType: 'primitive' | 'composite',
|
|
158
|
+
source: 'blueprint' | 'sector',
|
|
159
|
+
position: { x: number; y: number },
|
|
160
|
+
vertices: number[][][],
|
|
161
|
+
sectorId?: string
|
|
162
|
+
): void {
|
|
163
|
+
const anchorPosition = this.toAnchorPoint(position);
|
|
164
|
+
const anchorVertices = this.toAnchorVertices(vertices);
|
|
165
|
+
|
|
166
|
+
this.currentPickup = {
|
|
167
|
+
pieceId,
|
|
168
|
+
blueprintId,
|
|
169
|
+
blueprintType,
|
|
170
|
+
timestamp: Date.now(),
|
|
171
|
+
source,
|
|
172
|
+
sectorId,
|
|
173
|
+
position: anchorPosition,
|
|
174
|
+
vertices: anchorVertices
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// Mark all subsequent mouse tracking as "while_holding"
|
|
178
|
+
// (UI controllers should call recordMouseMove after this)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Record piece placedown event
|
|
183
|
+
*/
|
|
184
|
+
recordPlacedown(
|
|
185
|
+
outcome: 'placed' | 'deleted',
|
|
186
|
+
sectorId?: string,
|
|
187
|
+
position?: { x: number; y: number },
|
|
188
|
+
vertices?: number[][][],
|
|
189
|
+
anchorId?: string,
|
|
190
|
+
wasValid?: boolean,
|
|
191
|
+
wasOverlapping?: boolean,
|
|
192
|
+
completedSector?: boolean
|
|
193
|
+
): void {
|
|
194
|
+
if (!this.currentPickup) {
|
|
195
|
+
console.warn('[InteractionTracker] recordPlacedown called without pickup');
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const interactionType = this.determineInteractionType(this.currentPickup.source, outcome);
|
|
200
|
+
const placedownTimestamp = Date.now();
|
|
201
|
+
const holdDuration = placedownTimestamp - this.currentPickup.timestamp;
|
|
202
|
+
|
|
203
|
+
// Build post-interaction snapshot
|
|
204
|
+
// If this placement completes a sector, mark it in the snapshot
|
|
205
|
+
const postSnapshot = this.buildStateSnapshot(completedSector ? sectorId : undefined);
|
|
206
|
+
|
|
207
|
+
const anchorPosition = position ? this.toAnchorPoint(position) : undefined;
|
|
208
|
+
const anchorVertices = vertices ? this.toAnchorVertices(vertices) : undefined;
|
|
209
|
+
|
|
210
|
+
// Create interaction event - build incrementally to satisfy exactOptionalPropertyTypes
|
|
211
|
+
const event: InteractionEvent = {
|
|
212
|
+
// Metadata
|
|
213
|
+
interactionId: uuidv4(),
|
|
214
|
+
trialId: this.trialId,
|
|
215
|
+
gameId: this.gameId,
|
|
216
|
+
interactionIndex: this.interactionIndex++,
|
|
217
|
+
|
|
218
|
+
// Interaction type
|
|
219
|
+
interactionType,
|
|
220
|
+
|
|
221
|
+
// Piece information
|
|
222
|
+
pieceId: this.currentPickup.pieceId,
|
|
223
|
+
blueprintId: this.currentPickup.blueprintId,
|
|
224
|
+
blueprintType: this.currentPickup.blueprintType,
|
|
225
|
+
|
|
226
|
+
// Pickup event
|
|
227
|
+
pickupTimestamp: this.currentPickup.timestamp,
|
|
228
|
+
pickupSource: this.currentPickup.source,
|
|
229
|
+
pickupSectorId: this.currentPickup.sectorId,
|
|
230
|
+
pickupPosition: this.currentPickup.position,
|
|
231
|
+
pickupVertices: this.currentPickup.vertices,
|
|
232
|
+
|
|
233
|
+
// Placedown event
|
|
234
|
+
placedownTimestamp,
|
|
235
|
+
placedownOutcome: outcome,
|
|
236
|
+
|
|
237
|
+
// Validation context
|
|
238
|
+
wasValid: outcome === 'placed' && wasValid === true,
|
|
239
|
+
wasOverlapping: outcome === 'placed' && wasOverlapping === true,
|
|
240
|
+
|
|
241
|
+
// Timing
|
|
242
|
+
holdDuration,
|
|
243
|
+
|
|
244
|
+
// Events
|
|
245
|
+
clickEvents: [...this.clickEvents],
|
|
246
|
+
mouseTracking: [...this.mouseTracking],
|
|
247
|
+
|
|
248
|
+
// Snapshots
|
|
249
|
+
postSnapshot
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// Add optional placedown properties based on outcome
|
|
253
|
+
if (outcome === 'placed' && sectorId) {
|
|
254
|
+
event.placedownSectorId = sectorId;
|
|
255
|
+
}
|
|
256
|
+
if (outcome === 'placed' && anchorPosition) {
|
|
257
|
+
event.placedownPosition = anchorPosition;
|
|
258
|
+
}
|
|
259
|
+
if (outcome === 'placed' && anchorVertices) {
|
|
260
|
+
event.placedownVertices = anchorVertices;
|
|
261
|
+
}
|
|
262
|
+
if (anchorId) {
|
|
263
|
+
event.placedownAnchorId = anchorId;
|
|
264
|
+
}
|
|
265
|
+
if (this.previousSnapshotId !== undefined) {
|
|
266
|
+
event.preSnapshotId = this.previousSnapshotId;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Store for trial end data
|
|
270
|
+
this.interactions.push(event);
|
|
271
|
+
this.previousSnapshotId = postSnapshot.snapshotId;
|
|
272
|
+
|
|
273
|
+
// Call interaction callback
|
|
274
|
+
if (this.callbacks.onInteraction) {
|
|
275
|
+
this.callbacks.onInteraction(event);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Reset interaction state
|
|
279
|
+
this.currentPickup = null;
|
|
280
|
+
this.clickEvents = [];
|
|
281
|
+
this.mouseTracking = [];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Record click event
|
|
286
|
+
*/
|
|
287
|
+
recordClickEvent(
|
|
288
|
+
location: { x: number; y: number },
|
|
289
|
+
clickType: 'blueprint_view_switch' | 'invalid_placement' | 'sector_complete_attempt',
|
|
290
|
+
metadata?: {
|
|
291
|
+
blueprintViewSwitch?: { from: 'quickstash' | 'primitives'; to: 'quickstash' | 'primitives' };
|
|
292
|
+
invalidPlacement?: { reason: 'overlapping' | 'outside_bounds' | 'no_valid_anchor' | 'sector_complete'; attemptedSectorId?: string };
|
|
293
|
+
}
|
|
294
|
+
): void {
|
|
295
|
+
const anchorLocation = this.toAnchorPoint(location);
|
|
296
|
+
|
|
297
|
+
this.clickEvents.push({
|
|
298
|
+
timestamp: Date.now(),
|
|
299
|
+
location: anchorLocation,
|
|
300
|
+
clickType,
|
|
301
|
+
...metadata
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Record mouse movement to different anchor
|
|
307
|
+
*/
|
|
308
|
+
recordMouseMove(
|
|
309
|
+
anchorX: number,
|
|
310
|
+
anchorY: number,
|
|
311
|
+
sectorId?: string
|
|
312
|
+
): void {
|
|
313
|
+
const phase: 'before_pickup' | 'while_holding' = this.currentPickup ? 'while_holding' : 'before_pickup';
|
|
314
|
+
const anchorCoordX = this.toAnchorValue(anchorX);
|
|
315
|
+
const anchorCoordY = this.toAnchorValue(anchorY);
|
|
316
|
+
|
|
317
|
+
// Only record if anchor position changed
|
|
318
|
+
const lastTracking = this.mouseTracking[this.mouseTracking.length - 1];
|
|
319
|
+
if (lastTracking && lastTracking.anchorX === anchorCoordX && lastTracking.anchorY === anchorCoordY) {
|
|
320
|
+
return; // Same position, skip
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
this.mouseTracking.push({
|
|
324
|
+
timestamp: Date.now(),
|
|
325
|
+
anchorX: anchorCoordX,
|
|
326
|
+
anchorY: anchorCoordY,
|
|
327
|
+
sectorId,
|
|
328
|
+
phase
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Record sector completion
|
|
334
|
+
*/
|
|
335
|
+
recordSectorCompletion(sectorId: string): void {
|
|
336
|
+
this.completionTimes.push({
|
|
337
|
+
sectorId,
|
|
338
|
+
completedAt: Date.now()
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Record macro creation (prep mode only)
|
|
344
|
+
*/
|
|
345
|
+
recordMacroCreation(macro: MacroSnapshot): void {
|
|
346
|
+
this.createdMacros.push(macro);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ===== Trial Completion =====
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Finalize trial and call onTrialEnd callback
|
|
353
|
+
*/
|
|
354
|
+
finalizeTrial(endReason: 'timeout' | 'auto_complete' | 'submit'): void {
|
|
355
|
+
const trialEndTime = Date.now();
|
|
356
|
+
const totalDuration = trialEndTime - this.trialStartTime;
|
|
357
|
+
const finalSnapshot = this.buildStateSnapshot();
|
|
358
|
+
|
|
359
|
+
const mode = this.controller.state.cfg.mode;
|
|
360
|
+
|
|
361
|
+
if (mode === 'construction') {
|
|
362
|
+
const finalBlueprintState = this.buildFinalBlueprintState(finalSnapshot);
|
|
363
|
+
|
|
364
|
+
// Extract composites as quickstash macros (ready for next trial input)
|
|
365
|
+
const quickstashMacros = finalBlueprintState
|
|
366
|
+
.filter(bp => bp.blueprintType === 'composite')
|
|
367
|
+
.map(bp => ({
|
|
368
|
+
id: bp.blueprintId,
|
|
369
|
+
parts: bp.parts!, // Composites always have parts
|
|
370
|
+
...(bp.label && { label: bp.label })
|
|
371
|
+
}));
|
|
372
|
+
|
|
373
|
+
const data: ConstructionTrialData = {
|
|
374
|
+
trialType: 'construction',
|
|
375
|
+
trialId: this.trialId,
|
|
376
|
+
gameId: this.gameId,
|
|
377
|
+
trialNum: 0, // TODO: Plugin should provide this
|
|
378
|
+
trialStartTime: this.trialStartTime,
|
|
379
|
+
trialEndTime,
|
|
380
|
+
totalDuration,
|
|
381
|
+
endReason: endReason as 'timeout' | 'auto_complete',
|
|
382
|
+
completionTimes: this.completionTimes,
|
|
383
|
+
finalBlueprintState,
|
|
384
|
+
quickstashMacros,
|
|
385
|
+
finalSnapshot
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
if (this.callbacks.onTrialEnd) {
|
|
389
|
+
this.callbacks.onTrialEnd(data);
|
|
390
|
+
}
|
|
391
|
+
} else {
|
|
392
|
+
const finalMacros = this.createdMacros.length
|
|
393
|
+
? this.createdMacros
|
|
394
|
+
: this.buildMacrosFromSnapshot(finalSnapshot);
|
|
395
|
+
|
|
396
|
+
// Convert MacroSnapshot to quickstash input format
|
|
397
|
+
// Ensure IDs start with "comp:" for proper border rendering
|
|
398
|
+
const quickstashMacros = finalMacros.map(macro => ({
|
|
399
|
+
id: macro.macroId.startsWith('comp:') ? macro.macroId : `comp:${macro.macroId}`,
|
|
400
|
+
parts: macro.parts
|
|
401
|
+
// Note: label not included in prep macros
|
|
402
|
+
}));
|
|
403
|
+
|
|
404
|
+
const data: PrepTrialData = {
|
|
405
|
+
trialType: 'prep',
|
|
406
|
+
trialId: this.trialId,
|
|
407
|
+
gameId: this.gameId,
|
|
408
|
+
trialNum: 0, // TODO: Plugin should provide this
|
|
409
|
+
trialStartTime: this.trialStartTime,
|
|
410
|
+
trialEndTime,
|
|
411
|
+
totalDuration,
|
|
412
|
+
endReason: 'submit',
|
|
413
|
+
createdMacros: finalMacros,
|
|
414
|
+
quickstashMacros,
|
|
415
|
+
finalSnapshot
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
if (this.callbacks.onTrialEnd) {
|
|
419
|
+
this.callbacks.onTrialEnd(data);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ===== Helper Methods =====
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Determine interaction type from source and outcome
|
|
428
|
+
*/
|
|
429
|
+
private determineInteractionType(
|
|
430
|
+
source: 'blueprint' | 'sector',
|
|
431
|
+
outcome: 'placed' | 'deleted'
|
|
432
|
+
): 'spawn' | 'move' | 'delete' {
|
|
433
|
+
if (outcome === 'deleted') return 'delete';
|
|
434
|
+
if (source === 'blueprint') return 'spawn';
|
|
435
|
+
return 'move';
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private toAnchorValue(value: number): number {
|
|
439
|
+
return Math.round(value / this.gridStep);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private toAnchorPoint(point: { x: number; y: number }): { x: number; y: number } {
|
|
443
|
+
return { x: this.toAnchorValue(point.x), y: this.toAnchorValue(point.y) };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private toAnchorVertices(vertices: number[][][]): number[][][] {
|
|
447
|
+
return vertices.map(ring =>
|
|
448
|
+
ring.map(vertex => [
|
|
449
|
+
this.toAnchorValue(vertex[0]!),
|
|
450
|
+
this.toAnchorValue(vertex[1]!)
|
|
451
|
+
])
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private buildMacrosFromSnapshot(snapshot: StateSnapshot): MacroSnapshot[] {
|
|
456
|
+
return snapshot.sectors.map(sec => {
|
|
457
|
+
// STEP 1: Convert bbox positions to shape origin positions in anchor coordinates
|
|
458
|
+
const shapeOriginPositions = sec.pieces.map(piece => {
|
|
459
|
+
const bp = this.controller.getBlueprint(piece.blueprintId);
|
|
460
|
+
const kind = bp && 'kind' in bp ? bp.kind : piece.blueprintId;
|
|
461
|
+
|
|
462
|
+
// Get the piece's actual position in pixels from controller state
|
|
463
|
+
const actualPiece = this.controller.findPiece(piece.pieceId);
|
|
464
|
+
if (!actualPiece || !bp) {
|
|
465
|
+
return {
|
|
466
|
+
kind,
|
|
467
|
+
position: piece.position // Fallback to bbox position
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Get bounding box of the blueprint using the geometry helper
|
|
472
|
+
const bb = boundsOfBlueprint(bp, this.controller.getPrimitive);
|
|
473
|
+
|
|
474
|
+
// Convert bbox position to shape origin position
|
|
475
|
+
// In pixels: shapeOriginPos = piece.pos - bb.min
|
|
476
|
+
// Then convert to anchor coordinates
|
|
477
|
+
const shapeOriginPixels = {
|
|
478
|
+
x: actualPiece.pos.x - bb.min.x,
|
|
479
|
+
y: actualPiece.pos.y - bb.min.y
|
|
480
|
+
};
|
|
481
|
+
const shapeOriginAnchor = this.toAnchorPoint(shapeOriginPixels);
|
|
482
|
+
|
|
483
|
+
return {
|
|
484
|
+
kind,
|
|
485
|
+
position: shapeOriginAnchor // Shape origin in anchor coordinates
|
|
486
|
+
};
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// STEP 2: Calculate centroid and convert to relative offsets
|
|
490
|
+
if (shapeOriginPositions.length === 0) {
|
|
491
|
+
return {
|
|
492
|
+
macroId: sec.sectorId,
|
|
493
|
+
parts: [],
|
|
494
|
+
shape: [],
|
|
495
|
+
pieceCount: 0
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Calculate centroid of all pieces (in anchor coordinates)
|
|
500
|
+
let sumX = 0, sumY = 0;
|
|
501
|
+
for (const item of shapeOriginPositions) {
|
|
502
|
+
sumX += item.position.x;
|
|
503
|
+
sumY += item.position.y;
|
|
504
|
+
}
|
|
505
|
+
const centroidX = Math.round(sumX / shapeOriginPositions.length);
|
|
506
|
+
const centroidY = Math.round(sumY / shapeOriginPositions.length);
|
|
507
|
+
|
|
508
|
+
// Convert absolute positions to relative offsets (like ANCHOR_COMPOSITES format)
|
|
509
|
+
const parts = shapeOriginPositions.map(item => ({
|
|
510
|
+
kind: item.kind,
|
|
511
|
+
anchorOffset: {
|
|
512
|
+
x: Math.round(item.position.x - centroidX),
|
|
513
|
+
y: Math.round(item.position.y - centroidY)
|
|
514
|
+
}
|
|
515
|
+
}));
|
|
516
|
+
|
|
517
|
+
const shape = sec.pieces.flatMap(piece => piece.vertices || []);
|
|
518
|
+
|
|
519
|
+
return {
|
|
520
|
+
macroId: sec.sectorId,
|
|
521
|
+
parts,
|
|
522
|
+
shape,
|
|
523
|
+
pieceCount: sec.pieceCount
|
|
524
|
+
};
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Build state snapshot from current controller state
|
|
530
|
+
* @param justCompletedSectorId - Sector ID that was just completed (but controller state hasn't updated yet)
|
|
531
|
+
*/
|
|
532
|
+
private buildStateSnapshot(justCompletedSectorId?: string): StateSnapshot {
|
|
533
|
+
const snapshotId = uuidv4();
|
|
534
|
+
const timestamp = Date.now();
|
|
535
|
+
|
|
536
|
+
// Build sector snapshots
|
|
537
|
+
const sectors: SectorSnapshot[] = [];
|
|
538
|
+
const completedSectorIds: string[] = [];
|
|
539
|
+
|
|
540
|
+
for (const sector of this.controller.state.cfg.sectors) {
|
|
541
|
+
const sectorState = this.controller.state.sectors[sector.id];
|
|
542
|
+
// Check if this sector was just completed OR is already completed
|
|
543
|
+
const completed = sector.id === justCompletedSectorId || !!sectorState?.completedAt;
|
|
544
|
+
|
|
545
|
+
if (completed) {
|
|
546
|
+
completedSectorIds.push(sector.id);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const pieces: PieceSnapshot[] = (sectorState?.pieces || []).map(p => ({
|
|
550
|
+
pieceId: p.id,
|
|
551
|
+
blueprintId: p.blueprintId,
|
|
552
|
+
blueprintType: this.getBlueprintType(p.blueprintId),
|
|
553
|
+
position: this.toAnchorPoint(p.pos),
|
|
554
|
+
vertices: this.getPieceVertices(p.id)
|
|
555
|
+
}));
|
|
556
|
+
|
|
557
|
+
const snapshot: SectorSnapshot = {
|
|
558
|
+
sectorId: sector.id,
|
|
559
|
+
completed,
|
|
560
|
+
pieceCount: pieces.length,
|
|
561
|
+
pieces
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
if (sectorState?.completedAt !== undefined) {
|
|
565
|
+
snapshot.completedAt = sectorState.completedAt;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
sectors.push(snapshot);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Build sector-tangram map
|
|
572
|
+
const sectorTangramMap = this.controller.state.cfg.sectors.map(s => ({
|
|
573
|
+
sectorId: s.id,
|
|
574
|
+
tangramId: s.id // In our system, sector ID == tangram ID
|
|
575
|
+
}));
|
|
576
|
+
|
|
577
|
+
// Build blueprint order
|
|
578
|
+
const blueprintOrder = {
|
|
579
|
+
primitives: this.controller.state.primitives.map(bp => bp.id),
|
|
580
|
+
quickstash: this.controller.state.quickstash.map(bp => bp.id)
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
return {
|
|
584
|
+
snapshotId,
|
|
585
|
+
timestamp,
|
|
586
|
+
sectors,
|
|
587
|
+
completedSectorIds,
|
|
588
|
+
sectorTangramMap,
|
|
589
|
+
blueprintOrder
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Get blueprint type from blueprint ID
|
|
595
|
+
*/
|
|
596
|
+
private getBlueprintType(blueprintId: string): 'primitive' | 'composite' {
|
|
597
|
+
const bp = this.controller.getBlueprint(blueprintId);
|
|
598
|
+
if (!bp) return 'primitive'; // Fallback
|
|
599
|
+
return 'kind' in bp ? 'primitive' : 'composite';
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Get piece vertices in world space
|
|
604
|
+
*/
|
|
605
|
+
private getPieceVertices(pieceId: string): number[][][] {
|
|
606
|
+
const piece = this.controller.findPiece(pieceId);
|
|
607
|
+
if (!piece) return [];
|
|
608
|
+
|
|
609
|
+
const bp = this.controller.getBlueprint(piece.blueprintId);
|
|
610
|
+
if (!bp) return [];
|
|
611
|
+
|
|
612
|
+
const bb = boundsOfBlueprint(bp, (k) => this.controller.getPrimitive(k));
|
|
613
|
+
const polys = piecePolysAt(bp, bb, piece.pos) ?? [];
|
|
614
|
+
const asNumbers = polys.map(ring => ring.map(pt => [pt.x, pt.y] as [number, number]));
|
|
615
|
+
return this.toAnchorVertices(asNumbers);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Build final blueprint state (usage counts + definitions)
|
|
620
|
+
*/
|
|
621
|
+
private buildFinalBlueprintState(snapshot: StateSnapshot): Array<{
|
|
622
|
+
blueprintId: string;
|
|
623
|
+
blueprintType: 'primitive' | 'composite';
|
|
624
|
+
totalPieces: number;
|
|
625
|
+
bySector: Array<{ sectorId: string; count: number }>;
|
|
626
|
+
parts?: Array<{ kind: string; anchorOffset: { x: number; y: number } }>;
|
|
627
|
+
shape?: Array<Array<{ x: number; y: number }>>;
|
|
628
|
+
label?: string;
|
|
629
|
+
}> {
|
|
630
|
+
const counts = new Map<string, {
|
|
631
|
+
blueprintType: 'primitive' | 'composite';
|
|
632
|
+
totalPieces: number;
|
|
633
|
+
bySector: Map<string, number>;
|
|
634
|
+
}>();
|
|
635
|
+
|
|
636
|
+
// Accumulate counts
|
|
637
|
+
for (const sector of snapshot.sectors) {
|
|
638
|
+
for (const piece of sector.pieces) {
|
|
639
|
+
if (!counts.has(piece.blueprintId)) {
|
|
640
|
+
counts.set(piece.blueprintId, {
|
|
641
|
+
blueprintType: piece.blueprintType,
|
|
642
|
+
totalPieces: 0,
|
|
643
|
+
bySector: new Map()
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const entry = counts.get(piece.blueprintId)!;
|
|
648
|
+
entry.totalPieces++;
|
|
649
|
+
entry.bySector.set(sector.sectorId, (entry.bySector.get(sector.sectorId) || 0) + 1);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Ensure all primitives are represented, even if count is zero
|
|
654
|
+
for (const primitive of this.controller.state.primitives) {
|
|
655
|
+
if (!counts.has(primitive.id)) {
|
|
656
|
+
counts.set(primitive.id, {
|
|
657
|
+
blueprintType: 'primitive',
|
|
658
|
+
totalPieces: 0,
|
|
659
|
+
bySector: new Map()
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Ensure all composites are represented, even if count is zero
|
|
665
|
+
for (const composite of this.controller.state.quickstash) {
|
|
666
|
+
if (!counts.has(composite.id)) {
|
|
667
|
+
counts.set(composite.id, {
|
|
668
|
+
blueprintType: 'composite',
|
|
669
|
+
totalPieces: 0,
|
|
670
|
+
bySector: new Map()
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Convert to array format with blueprint definitions
|
|
676
|
+
return Array.from(counts.entries()).map(([blueprintId, data]) => {
|
|
677
|
+
const blueprint = this.controller.getBlueprint(blueprintId);
|
|
678
|
+
|
|
679
|
+
const result: {
|
|
680
|
+
blueprintId: string;
|
|
681
|
+
blueprintType: 'primitive' | 'composite';
|
|
682
|
+
totalPieces: number;
|
|
683
|
+
bySector: Array<{ sectorId: string; count: number }>;
|
|
684
|
+
parts?: Array<{ kind: string; anchorOffset: { x: number; y: number } }>;
|
|
685
|
+
shape?: Array<Array<{ x: number; y: number }>>;
|
|
686
|
+
label?: string;
|
|
687
|
+
} = {
|
|
688
|
+
blueprintId,
|
|
689
|
+
blueprintType: data.blueprintType,
|
|
690
|
+
totalPieces: data.totalPieces,
|
|
691
|
+
bySector: Array.from(data.bySector.entries()).map(([sectorId, count]) => ({
|
|
692
|
+
sectorId,
|
|
693
|
+
count
|
|
694
|
+
}))
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
// Add blueprint definition for both primitives and composites
|
|
698
|
+
if (blueprint) {
|
|
699
|
+
if (data.blueprintType === 'composite' && 'parts' in blueprint) {
|
|
700
|
+
// Composite blueprint: convert pixel-based parts to anchor-based parts
|
|
701
|
+
result.parts = blueprint.parts.map(part => ({
|
|
702
|
+
kind: part.kind,
|
|
703
|
+
anchorOffset: {
|
|
704
|
+
x: Math.round(part.offset.x / this.gridStep),
|
|
705
|
+
y: Math.round(part.offset.y / this.gridStep)
|
|
706
|
+
}
|
|
707
|
+
}));
|
|
708
|
+
|
|
709
|
+
// Convert pixel-based shape to anchor-based shape
|
|
710
|
+
if (blueprint.shape) {
|
|
711
|
+
result.shape = blueprint.shape.map(poly =>
|
|
712
|
+
poly.map(vertex => ({
|
|
713
|
+
x: Math.round(vertex.x / this.gridStep),
|
|
714
|
+
y: Math.round(vertex.y / this.gridStep)
|
|
715
|
+
}))
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Add label if available
|
|
720
|
+
if (blueprint.label) {
|
|
721
|
+
result.label = blueprint.label;
|
|
722
|
+
}
|
|
723
|
+
} else if (data.blueprintType === 'primitive' && 'kind' in blueprint) {
|
|
724
|
+
// Primitive blueprint: convert pixel-based shape to anchor-based shape
|
|
725
|
+
if (blueprint.shape) {
|
|
726
|
+
result.shape = blueprint.shape.map(poly =>
|
|
727
|
+
poly.map(vertex => ({
|
|
728
|
+
x: Math.round(vertex.x / this.gridStep),
|
|
729
|
+
y: Math.round(vertex.y / this.gridStep)
|
|
730
|
+
}))
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Primitives don't have labels, but we could add a default one based on kind
|
|
735
|
+
result.label = blueprint.kind;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
return result;
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
}
|