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.
Files changed (72) hide show
  1. package/README.md +25 -0
  2. package/dist/construct/index.browser.js +20431 -0
  3. package/dist/construct/index.browser.js.map +1 -0
  4. package/dist/construct/index.browser.min.js +42 -0
  5. package/dist/construct/index.browser.min.js.map +1 -0
  6. package/dist/construct/index.cjs +3720 -0
  7. package/dist/construct/index.cjs.map +1 -0
  8. package/dist/construct/index.d.ts +204 -0
  9. package/dist/construct/index.js +3718 -0
  10. package/dist/construct/index.js.map +1 -0
  11. package/dist/index.cjs +3920 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.d.ts +340 -0
  14. package/dist/index.js +3917 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/prep/index.browser.js +20455 -0
  17. package/dist/prep/index.browser.js.map +1 -0
  18. package/dist/prep/index.browser.min.js +42 -0
  19. package/dist/prep/index.browser.min.js.map +1 -0
  20. package/dist/prep/index.cjs +3744 -0
  21. package/dist/prep/index.cjs.map +1 -0
  22. package/dist/prep/index.d.ts +139 -0
  23. package/dist/prep/index.js +3742 -0
  24. package/dist/prep/index.js.map +1 -0
  25. package/package.json +77 -0
  26. package/src/core/components/README.md +249 -0
  27. package/src/core/components/board/BoardView.tsx +352 -0
  28. package/src/core/components/board/GameBoard.tsx +682 -0
  29. package/src/core/components/board/index.ts +70 -0
  30. package/src/core/components/board/useAnchorGrid.ts +110 -0
  31. package/src/core/components/board/useClickController.ts +436 -0
  32. package/src/core/components/board/useDragController.ts +1051 -0
  33. package/src/core/components/board/usePieceState.ts +178 -0
  34. package/src/core/components/board/utils.ts +76 -0
  35. package/src/core/components/index.ts +33 -0
  36. package/src/core/components/pieces/BlueprintRing.tsx +238 -0
  37. package/src/core/config/config.ts +85 -0
  38. package/src/core/domain/blueprints.ts +25 -0
  39. package/src/core/domain/layout.ts +159 -0
  40. package/src/core/domain/primitives.ts +159 -0
  41. package/src/core/domain/solve.ts +184 -0
  42. package/src/core/domain/types.ts +111 -0
  43. package/src/core/engine/collision/grid-snapping.ts +283 -0
  44. package/src/core/engine/collision/index.ts +4 -0
  45. package/src/core/engine/collision/sat-collision.ts +46 -0
  46. package/src/core/engine/collision/validation.ts +166 -0
  47. package/src/core/engine/geometry/bounds.ts +91 -0
  48. package/src/core/engine/geometry/collision.ts +64 -0
  49. package/src/core/engine/geometry/index.ts +19 -0
  50. package/src/core/engine/geometry/math.ts +101 -0
  51. package/src/core/engine/geometry/pieces.ts +290 -0
  52. package/src/core/engine/geometry/polygons.ts +43 -0
  53. package/src/core/engine/state/BaseGameController.ts +368 -0
  54. package/src/core/engine/validation/border-rendering.ts +318 -0
  55. package/src/core/engine/validation/complete.ts +102 -0
  56. package/src/core/engine/validation/face-to-face.ts +217 -0
  57. package/src/core/index.ts +3 -0
  58. package/src/core/io/InteractionTracker.ts +742 -0
  59. package/src/core/io/data-tracking.ts +271 -0
  60. package/src/core/io/json-to-tangram-spec.ts +110 -0
  61. package/src/core/io/quickstash.ts +141 -0
  62. package/src/core/io/stims.ts +110 -0
  63. package/src/core/types/index.ts +5 -0
  64. package/src/core/types/plugin-interfaces.ts +101 -0
  65. package/src/index.spec.ts +19 -0
  66. package/src/index.ts +2 -0
  67. package/src/plugins/tangram-construct/ConstructionApp.tsx +105 -0
  68. package/src/plugins/tangram-construct/index.ts +156 -0
  69. package/src/plugins/tangram-prep/PrepApp.tsx +182 -0
  70. package/src/plugins/tangram-prep/index.ts +122 -0
  71. package/tangram-construct.min.js +42 -0
  72. package/tangram-prep.min.js +42 -0
@@ -0,0 +1,368 @@
1
+ /**
2
+ * BaseGameController with dependency injection - PHASE 2 MILESTONE
3
+ *
4
+ * Maintains compatibility with existing RoundController API while implementing
5
+ * clean separation between configuration and data. All game content is injected
6
+ * via constructor parameters, no hardcoded defaults.
7
+ */
8
+
9
+ import type {
10
+ Blueprint,
11
+ Sector,
12
+ LayoutMode,
13
+ PlacementTarget,
14
+ InputMode,
15
+ RoundState,
16
+ SectorState,
17
+ TanKind,
18
+ PrimitiveBlueprint
19
+ } from '../../domain/types';
20
+ import { CONFIG } from '../../config/config';
21
+ import { BlueprintRegistry } from '../../domain/blueprints';
22
+
23
+ export interface GameConfig {
24
+ n: number;
25
+ layout: LayoutMode;
26
+ target: PlacementTarget; // CRITICAL: "workspace" | "silhouette"
27
+ input: InputMode; // CRITICAL: "click" | "drag"
28
+ timeLimitMs: number;
29
+ maxQuickstashSlots: number;
30
+ maxCompositeSize?: number; // Optional - only used in prep mode
31
+ mode: "construction" | "prep"; // NEW: Distinguishes prep vs construction behavior
32
+ minPiecesPerMacro?: number; // NEW: Minimum pieces required per macro (prep mode)
33
+ requireAllSlots?: boolean; // NEW: Whether all slots must be filled (prep mode)
34
+ }
35
+
36
+ let PID = 0;
37
+ const newId = () => `p_${++PID}`;
38
+ const NOW = () => performance.now();
39
+
40
+ // Event tracking callbacks (optional - set by InteractionTracker)
41
+ export interface TrackingCallbacks {
42
+ onPiecePickup?: (pieceId: string, blueprintId: string, blueprintType: 'primitive' | 'composite', source: 'blueprint' | 'sector', position: { x: number; y: number }, vertices: number[][][], sectorId?: string) => void;
43
+ onPiecePlacedown?: (outcome: 'placed' | 'deleted', sectorId?: string, position?: { x: number; y: number }, vertices?: number[][][], anchorId?: string, wasValid?: boolean, wasOverlapping?: boolean) => void;
44
+ onClickEvent?: (location: { x: number; y: number }, anchorX: number, anchorY: number, clickType: 'blueprint_view_switch' | 'invalid_placement' | 'sector_complete_attempt', metadata?: any) => void;
45
+ onMouseMove?: (anchorX: number, anchorY: number, sectorId?: string) => void;
46
+ onSectorCompleted?: (sectorId: string) => void;
47
+ }
48
+
49
+ export class BaseGameController {
50
+ // Core data - injected, not hardcoded
51
+ private sectors: Sector[];
52
+ private quickstash: Blueprint[];
53
+ private primitives: PrimitiveBlueprint[];
54
+
55
+ // Configuration
56
+ private config: GameConfig;
57
+
58
+ // State and registry
59
+ public state: RoundState;
60
+ private t0: number;
61
+ private registry = new BlueprintRegistry();
62
+
63
+ // React state update tracking
64
+ private _updateCount = 0;
65
+ public get updateCount() { return this._updateCount; }
66
+ private notifyStateChange() { this._updateCount++; }
67
+
68
+ // Event tracking (optional)
69
+ private trackingCallbacks: TrackingCallbacks | null = null;
70
+
71
+ constructor(
72
+ sectors: Sector[],
73
+ quickstash: Blueprint[],
74
+ primitives: PrimitiveBlueprint[],
75
+ config: GameConfig
76
+ ) {
77
+ // Validate required inputs - fail fast
78
+ if (!sectors || sectors.length === 0) {
79
+ throw new Error("BaseGameController: sectors array is required and cannot be empty");
80
+ }
81
+ if (!primitives || primitives.length === 0) {
82
+ throw new Error("BaseGameController: primitives array is required and cannot be empty");
83
+ }
84
+ if (!config) {
85
+ throw new Error("BaseGameController: config is required");
86
+ }
87
+
88
+ // CRITICAL: All data is injected, no defaults
89
+ this.sectors = sectors;
90
+ this.quickstash = quickstash || [];
91
+ this.primitives = primitives;
92
+ this.config = config;
93
+
94
+ // Initialize registry
95
+ this.registry.registerAll(primitives);
96
+ this.registry.registerAll(quickstash);
97
+
98
+ // Initialize state
99
+ this.t0 = NOW();
100
+ this.state = this.initializeState();
101
+ }
102
+
103
+ private initializeState(): RoundState {
104
+ // Create state that matches existing RoundController structure
105
+ const sectors: Record<string, SectorState> = {};
106
+ for (const s of this.sectors) {
107
+ sectors[s.id] = { sectorId: s.id, pieces: [] };
108
+ }
109
+
110
+ return {
111
+ cfg: {
112
+ n: this.config.n,
113
+ layout: this.config.layout,
114
+ target: this.config.target,
115
+ input: this.config.input,
116
+ maxQuickstashSlots: this.config.maxQuickstashSlots,
117
+ timeLimitMs: this.config.timeLimitMs,
118
+ snapRadiusPx: CONFIG.game.snapRadiusPx,
119
+ maxCompositeSize: this.config.maxCompositeSize ?? 0, // Default to 0 for legacy compatibility
120
+ sectors: this.sectors,
121
+ mode: this.config.mode
122
+ },
123
+ blueprintView: this.config.mode === "prep" ? "primitives" : "quickstash", // Start with primitives in prep mode
124
+ primitives: this.primitives,
125
+ quickstash: this.quickstash,
126
+ sectors,
127
+ startedAt: this.t0
128
+ };
129
+ }
130
+
131
+ // ===== Event Tracking =====
132
+ // Methods for InteractionTracker integration
133
+
134
+ /**
135
+ * Set tracking callbacks for data collection
136
+ * Called by InteractionTracker to register itself
137
+ */
138
+ setTrackingCallbacks(callbacks: TrackingCallbacks | null): void {
139
+ this.trackingCallbacks = callbacks;
140
+ }
141
+
142
+ // ===== RoundController Compatibility Interface =====
143
+ // These methods maintain compatibility with Board.tsx expectations
144
+
145
+ getState(): RoundState {
146
+ return this.state;
147
+ }
148
+
149
+ getSnapRadius(): number {
150
+ return CONFIG.game.snapRadiusPx;
151
+ }
152
+
153
+ // CRITICAL: Preserve target and input mode access
154
+ getTarget(): PlacementTarget {
155
+ return this.config.target;
156
+ }
157
+
158
+ getInputMode(): InputMode {
159
+ return this.config.input;
160
+ }
161
+
162
+ // ===== Blueprint and Piece Management =====
163
+ // Methods that Board.tsx expects
164
+
165
+ getBlueprint(id: string): Blueprint | undefined {
166
+ return this.registry.get(id);
167
+ }
168
+
169
+ getPrimitive(kind: TanKind | string): PrimitiveBlueprint | undefined {
170
+ return this.registry.getPrimitive(kind);
171
+ }
172
+
173
+ getPiecesInSector(sectorId: string): any[] {
174
+ const sectorState = this.state.sectors[sectorId];
175
+ return sectorState ? sectorState.pieces : [];
176
+ }
177
+
178
+ // ===== Completion Management =====
179
+ // Critical for Board.tsx validation
180
+
181
+ isSectorCompleted(sectorId: string): boolean {
182
+ return !!this.state.sectors[sectorId]?.completedAt;
183
+ }
184
+
185
+ markSectorCompleted(sectorId: string): void {
186
+ const ss = this.state.sectors[sectorId];
187
+ if (!ss || ss.completedAt) return;
188
+
189
+ ss.completedAt = NOW();
190
+
191
+ // Notify tracking system
192
+ if (this.trackingCallbacks?.onSectorCompleted) {
193
+ this.trackingCallbacks.onSectorCompleted(sectorId);
194
+ }
195
+
196
+ const allDone = Object.values(this.state.sectors).every((s: SectorState) => !!s.completedAt);
197
+ if (allDone && !this.state.endedAt) {
198
+ this.state.endedAt = NOW();
199
+ console.log("[BaseGameController] all sectors complete");
200
+ }
201
+ }
202
+
203
+ // ===== Piece Operations =====
204
+ // Methods that Board.tsx uses for drag/drop
205
+
206
+ spawnFromBlueprint(bp: Blueprint, at: { x: number; y: number }): string {
207
+ const id = newId();
208
+ const piece = { id, blueprintId: bp.id, pos: { ...at } };
209
+ this.getFloating().pieces.push(piece);
210
+ this.notifyStateChange();
211
+ return id;
212
+ }
213
+
214
+ move(id: string, to: { x: number; y: number }): void {
215
+ const p = this.findPiece(id);
216
+ if (!p) {
217
+ return;
218
+ }
219
+ p.pos = { ...to };
220
+ this.notifyStateChange();
221
+ }
222
+
223
+ drop(id: string, sectorId?: string): void {
224
+ const p = this.findPiece(id);
225
+ if (!p) {
226
+ return;
227
+ }
228
+
229
+ // Remove from current location
230
+ const where = this.findPieceLocation(id);
231
+ this.removeFromCurrent(id);
232
+ if (where?.sectorId) delete p.sectorId;
233
+
234
+ // Inner-hole or no sector → float
235
+ if (!sectorId) {
236
+ this.getFloating().pieces.push(p);
237
+ this.notifyStateChange();
238
+ return;
239
+ }
240
+
241
+ // Completed sector → delete (already removed above)
242
+ if (this.isSectorCompleted(sectorId)) {
243
+ this.notifyStateChange();
244
+ return;
245
+ }
246
+
247
+ // Place in sector
248
+ const sector = this.state.sectors[sectorId];
249
+ if (!sector) {
250
+ return;
251
+ }
252
+
253
+ sector.pieces.push(p);
254
+ p.sectorId = sectorId;
255
+ this.notifyStateChange();
256
+ }
257
+
258
+ remove(id: string): void {
259
+ const loc = this.findPieceLocation(id);
260
+ if (!loc) return;
261
+ loc.array.splice(loc.index, 1);
262
+ this.notifyStateChange();
263
+ }
264
+
265
+ // ===== Helper Methods =====
266
+ // Internal methods that support the public API
267
+
268
+ findPiece(id: string): any {
269
+ for (const s of Object.values(this.state.sectors)) {
270
+ const sectorState = s as SectorState;
271
+ const p = sectorState.pieces.find((pp) => pp.id === id);
272
+ if (p) return p;
273
+ }
274
+ return this.getFloating().pieces.find((pp) => pp.id === id) ?? null;
275
+ }
276
+
277
+ private getFloating(): SectorState {
278
+ if (!(this as any)._floating) {
279
+ (this as any)._floating = { sectorId: "-floating-", pieces: [] } as SectorState;
280
+ }
281
+ return (this as any)._floating;
282
+ }
283
+
284
+ private findPieceLocation(id: string):
285
+ | { sectorId?: string; array: SectorState["pieces"]; index: number }
286
+ | null {
287
+ for (const s of Object.values(this.state.sectors)) {
288
+ const sectorState = s as SectorState;
289
+ const idx = sectorState.pieces.findIndex((pp) => pp.id === id);
290
+ if (idx >= 0) return { sectorId: sectorState.sectorId, array: sectorState.pieces, index: idx };
291
+ }
292
+ const f = this.getFloating();
293
+ const i = f.pieces.findIndex((pp) => pp.id === id);
294
+ return i >= 0 ? { array: f.pieces, index: i } : null;
295
+ }
296
+
297
+ private removeFromCurrent(id: string): void {
298
+ const loc = this.findPieceLocation(id);
299
+ if (!loc) return;
300
+ loc.array.splice(loc.index, 1);
301
+ }
302
+
303
+ // ===== Additional Compatibility Methods =====
304
+
305
+ switchBlueprintView(): void {
306
+ // Disable toggle in prep mode - keep primitives always visible
307
+ if (this.config.mode === "prep") return;
308
+
309
+ const to = this.state.blueprintView === "primitives" ? "quickstash" : "primitives";
310
+ this.state.blueprintView = to;
311
+ }
312
+
313
+ /**
314
+ * Check if submit button should be enabled in prep mode
315
+ * Validates macro completion based on piece count and slot requirements
316
+ */
317
+ isSubmitEnabled(): boolean {
318
+ if (this.config.mode !== "prep") return true; // Not applicable to construction mode
319
+
320
+ const minPieces = this.config.minPiecesPerMacro ?? 2;
321
+ const maxPieces = this.config.maxCompositeSize ?? Infinity; // No upper limit if not specified
322
+ const requireAll = this.config.requireAllSlots ?? false;
323
+
324
+ // Get all sectors (macro slots)
325
+ const allSectors = Object.values(this.state.sectors);
326
+
327
+ // Check each sector for valid piece count
328
+ let validSectorCount = 0;
329
+ for (const sector of allSectors) {
330
+ const pieceCount = sector.pieces.length;
331
+
332
+ // Valid if between min and max pieces (inclusive)
333
+ if (pieceCount >= minPieces && pieceCount <= maxPieces) {
334
+ validSectorCount++;
335
+ }
336
+ // Invalid if has pieces but not enough or too many
337
+ else if (pieceCount > 0) {
338
+ return false; // Has pieces but invalid count
339
+ }
340
+ // Empty sector (pieceCount === 0) is allowed, will be checked below
341
+ }
342
+
343
+ if (requireAll) {
344
+ // All slots must have valid macros
345
+ return validSectorCount === allSectors.length;
346
+ } else {
347
+ // At least 0 valid macros (can submit nothing)
348
+ return true; // No invalid sectors found above, so can submit
349
+ }
350
+ }
351
+
352
+ snapshot(): any {
353
+ const perSector = Object.values(this.state.sectors).map((s) => {
354
+ const sectorState = s as SectorState;
355
+ const base = {
356
+ sectorId: sectorState.sectorId,
357
+ pieceCount: sectorState.pieces.length,
358
+ };
359
+ return sectorState.completedAt ? { ...base, completedAt: sectorState.completedAt } : base;
360
+ });
361
+ return {
362
+ perSector,
363
+ events: [], // TODO: Will be implemented in validation separation phase
364
+ timeMs: NOW() - this.t0,
365
+ completed: !!this.state.endedAt,
366
+ };
367
+ }
368
+ }
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Border Rendering Utilities for Edge-Aware Piece Borders
3
+ *
4
+ * This module provides utilities for rendering piece borders with edge-aware logic.
5
+ * Uses the same coordinate system and approach as face-to-face.ts for consistency.
6
+ * Visual polish - hides borders between touching pieces
7
+ */
8
+
9
+ import type { Vec, Piece, Blueprint } from "../../domain/types";
10
+ import { CONFIG } from "../../config/config";
11
+ import { boundsOfBlueprint } from "../geometry";
12
+
13
+ export interface UnitSegment {
14
+ a: Vec;
15
+ b: Vec;
16
+ }
17
+
18
+ /**
19
+ * Generate SVG path data for individual edge strokes
20
+ * Each edge becomes a separate path element for selective rendering
21
+ */
22
+ export function generateEdgeStrokePaths(poly: Vec[]): string[] {
23
+ const strokePaths: string[] = [];
24
+
25
+ for (let i = 0; i < poly.length; i++) {
26
+ const current = poly[i];
27
+ const next = poly[(i + 1) % poly.length];
28
+
29
+ if (current && next) {
30
+ strokePaths.push(`M ${current.x} ${current.y} L ${next.x} ${next.y}`);
31
+ }
32
+ }
33
+
34
+ return strokePaths;
35
+ }
36
+
37
+ /**
38
+ * Convert a polygon edge into unit-length segments
39
+ * Uses the same logic as face-to-face.ts for consistency
40
+ */
41
+ function edgeToUnitSegments(start: Vec, end: Vec, gridSize: number): UnitSegment[] {
42
+ const segments: UnitSegment[] = [];
43
+
44
+ // Convert to grid coordinates
45
+ const startGrid = {
46
+ x: Math.round(start.x / gridSize),
47
+ y: Math.round(start.y / gridSize)
48
+ };
49
+ const endGrid = {
50
+ x: Math.round(end.x / gridSize),
51
+ y: Math.round(end.y / gridSize)
52
+ };
53
+
54
+ const dx = endGrid.x - startGrid.x;
55
+ const dy = endGrid.y - startGrid.y;
56
+
57
+ if (dx === 0 && dy === 0) return []; // Zero-length edge
58
+
59
+ const steps = Math.max(Math.abs(dx), Math.abs(dy));
60
+ const stepX = dx / steps;
61
+ const stepY = dy / steps;
62
+
63
+ for (let i = 0; i < steps; i++) {
64
+ const aX = Math.round(startGrid.x + i * stepX);
65
+ const aY = Math.round(startGrid.y + i * stepY);
66
+ const bX = Math.round(startGrid.x + (i + 1) * stepX);
67
+ const bY = Math.round(startGrid.y + (i + 1) * stepY);
68
+ segments.push({
69
+ a: { x: aX, y: aY },
70
+ b: { x: bX, y: bY }
71
+ });
72
+ }
73
+
74
+ return segments;
75
+ }
76
+
77
+ /**
78
+ * Get all unit segments for a piece (same logic as face-to-face.ts)
79
+ * Currently unused - kept for reference
80
+ */
81
+ /*
82
+ function getPieceUnitSegments(
83
+ piece: Piece,
84
+ getBlueprint: (id: string) => Blueprint | undefined,
85
+ getPrimitive: (kind: string) => any,
86
+ gridSize: number
87
+ ): UnitSegment[] {
88
+ // Defensive check: if getBlueprint fails, return empty segments
89
+ let blueprint: Blueprint | undefined;
90
+ try {
91
+ blueprint = getBlueprint(piece.blueprintId);
92
+ } catch (error) {
93
+ console.warn('getBlueprint failed in getPieceUnitSegments:', error);
94
+ return [];
95
+ }
96
+
97
+ if (!blueprint?.shape) return [];
98
+
99
+ // Calculate offset using same logic as piecePolysAt
100
+ const bb = boundsOfBlueprint(blueprint, getPrimitive);
101
+ const ox = piece.pos.x - bb.min.x;
102
+ const oy = piece.pos.y - bb.min.y;
103
+
104
+ const allSegments: UnitSegment[] = [];
105
+
106
+ // Translate polygons and convert edges to unit segments
107
+ for (const poly of blueprint.shape) {
108
+ const translatedPoly = poly.map((vertex: Vec) => ({
109
+ x: vertex.x + ox,
110
+ y: vertex.y + oy
111
+ }));
112
+
113
+ for (let i = 0; i < translatedPoly.length; i++) {
114
+ const current = translatedPoly[i];
115
+ const next = translatedPoly[(i + 1) % translatedPoly.length];
116
+
117
+ if (!current || !next) continue;
118
+
119
+ allSegments.push(...edgeToUnitSegments(current, next, gridSize));
120
+ }
121
+ }
122
+
123
+ return allSegments;
124
+ }
125
+ */
126
+
127
+ /**
128
+ * Check which edges should be hidden for a specific polygon within a piece
129
+ * Handles both internal composite edges and external touching between pieces
130
+ */
131
+ export function getHiddenEdgesForPolygon(
132
+ piece: Piece,
133
+ polyIndex: number,
134
+ allPiecesInSector: Piece[],
135
+ getBlueprint: (id: string) => Blueprint | undefined,
136
+ getPrimitive: (kind: string) => any
137
+ ): boolean[] {
138
+ // Defensive check: if getBlueprint fails, return no hidden edges
139
+ let blueprint: Blueprint | undefined;
140
+ try {
141
+ blueprint = getBlueprint(piece.blueprintId);
142
+ } catch (error) {
143
+ console.warn('getBlueprint failed in getHiddenEdgesForPolygon:', error);
144
+ return [];
145
+ }
146
+
147
+ if (!blueprint?.shape) {
148
+ return [];
149
+ }
150
+
151
+ const poly = blueprint.shape[polyIndex];
152
+ if (!poly) return [];
153
+
154
+ const gridSize = CONFIG.layout.grid.stepPx;
155
+ const bb = boundsOfBlueprint(blueprint, getPrimitive);
156
+ const ox = piece.pos.x - bb.min.x;
157
+ const oy = piece.pos.y - bb.min.y;
158
+
159
+ const translatedPoly = poly.map((vertex: Vec) => ({
160
+ x: vertex.x + ox,
161
+ y: vertex.y + oy
162
+ }));
163
+
164
+ const hiddenEdges: boolean[] = [];
165
+
166
+ // For each edge in this polygon
167
+ for (let i = 0; i < translatedPoly.length; i++) {
168
+ const current = translatedPoly[i];
169
+ const next = translatedPoly[(i + 1) % translatedPoly.length];
170
+
171
+ if (!current || !next) {
172
+ hiddenEdges.push(false);
173
+ continue;
174
+ }
175
+
176
+ // Get segments for this edge
177
+ const edgeSegments = edgeToUnitSegments(current, next, gridSize);
178
+
179
+ // Check if any of these segments are touching other pieces
180
+ let isTouching = false;
181
+
182
+ // 1. Check internal composite edges (if this is a composite)
183
+ if (piece.blueprintId.startsWith('comp:')) {
184
+ // Check against other polygons in the same composite
185
+ for (let otherPolyIndex = 0; otherPolyIndex < blueprint.shape.length; otherPolyIndex++) {
186
+ if (otherPolyIndex === polyIndex) continue; // Skip self
187
+
188
+ const otherPoly = blueprint.shape[otherPolyIndex];
189
+ if (!otherPoly) continue;
190
+
191
+ const otherTranslatedPoly = otherPoly.map((vertex: Vec) => ({
192
+ x: vertex.x + ox,
193
+ y: vertex.y + oy
194
+ }));
195
+
196
+ // Check if this edge touches any edge of the other polygon
197
+ for (let j = 0; j < otherTranslatedPoly.length; j++) {
198
+ const otherCurrent = otherTranslatedPoly[j];
199
+ const otherNext = otherTranslatedPoly[(j + 1) % otherTranslatedPoly.length];
200
+
201
+ if (!otherCurrent || !otherNext) continue;
202
+
203
+ const otherEdgeSegments = edgeToUnitSegments(otherCurrent, otherNext, gridSize);
204
+
205
+ // Check if any segments are shared
206
+ for (const edgeSeg of edgeSegments) {
207
+ const isShared = otherEdgeSegments.some(otherSeg =>
208
+ // Check if segments share the same endpoints (identical or reversed)
209
+ (edgeSeg.a.x === otherSeg.a.x && edgeSeg.a.y === otherSeg.a.y &&
210
+ edgeSeg.b.x === otherSeg.b.x && edgeSeg.b.y === otherSeg.b.y) ||
211
+ (edgeSeg.a.x === otherSeg.b.x && edgeSeg.a.y === otherSeg.b.y &&
212
+ edgeSeg.b.x === otherSeg.a.x && edgeSeg.b.y === otherSeg.a.y)
213
+ );
214
+
215
+ if (isShared) {
216
+ isTouching = true;
217
+ break;
218
+ }
219
+ }
220
+
221
+ if (isTouching) break;
222
+ }
223
+
224
+ if (isTouching) break;
225
+ }
226
+ }
227
+
228
+ // 2. Check external touching between different pieces (if not already touching internally)
229
+ // Only check external touching when hideTouchingBorders is true
230
+ if (!isTouching && CONFIG.game.hideTouchingBorders) {
231
+ for (const otherPiece of allPiecesInSector) {
232
+ if (otherPiece.id === piece.id) continue; // Skip self
233
+
234
+ const otherBlueprint = getBlueprint(otherPiece.blueprintId);
235
+ if (!otherBlueprint?.shape) continue;
236
+
237
+ const otherBb = boundsOfBlueprint(otherBlueprint, getPrimitive);
238
+ const otherOx = otherPiece.pos.x - otherBb.min.x;
239
+ const otherOy = otherPiece.pos.y - otherBb.min.y;
240
+
241
+ // Check if this edge touches any edge of the other piece
242
+ for (const otherPoly of otherBlueprint.shape) {
243
+ const otherTranslatedPoly = otherPoly.map((vertex: Vec) => ({
244
+ x: vertex.x + otherOx,
245
+ y: vertex.y + otherOy
246
+ }));
247
+
248
+ for (let j = 0; j < otherTranslatedPoly.length; j++) {
249
+ const otherCurrent = otherTranslatedPoly[j];
250
+ const otherNext = otherTranslatedPoly[(j + 1) % otherTranslatedPoly.length];
251
+
252
+ if (!otherCurrent || !otherNext) continue;
253
+
254
+ const otherEdgeSegments = edgeToUnitSegments(otherCurrent, otherNext, gridSize);
255
+
256
+ // Check if any segments are shared
257
+ for (const edgeSeg of edgeSegments) {
258
+ const isShared = otherEdgeSegments.some(otherSeg =>
259
+ // Check if segments share the same endpoints (identical or reversed)
260
+ (edgeSeg.a.x === otherSeg.a.x && edgeSeg.a.y === otherSeg.a.y &&
261
+ edgeSeg.b.x === otherSeg.b.x && edgeSeg.b.y === otherSeg.b.y) ||
262
+ (edgeSeg.a.x === otherSeg.b.x && edgeSeg.a.y === otherSeg.b.y &&
263
+ edgeSeg.b.x === otherSeg.a.x && edgeSeg.b.y === otherSeg.a.y)
264
+ );
265
+
266
+ if (isShared) {
267
+ isTouching = true;
268
+ break;
269
+ }
270
+ }
271
+
272
+ if (isTouching) break;
273
+ }
274
+
275
+ if (isTouching) break;
276
+ }
277
+
278
+ if (isTouching) break;
279
+ }
280
+ }
281
+
282
+ hiddenEdges.push(isTouching);
283
+ }
284
+
285
+ return hiddenEdges;
286
+ }
287
+
288
+ /**
289
+ * Check if borders should be shown at all
290
+ * If false, neither primitives nor composites have borders
291
+ */
292
+ export function shouldShowBorders(): boolean {
293
+ return CONFIG.game.showBorders;
294
+ }
295
+
296
+ /**
297
+ * Check if touching borders should be hidden
298
+ * Only relevant when showBorders is true
299
+ * - true: All pieces use selective border rendering
300
+ * - false: Only composites use selective border rendering
301
+ */
302
+ export function shouldHideTouchingBorders(): boolean {
303
+ return CONFIG.game.hideTouchingBorders;
304
+ }
305
+
306
+ /**
307
+ * Check if a piece should have selective border rendering
308
+ * This determines whether to use edge-aware borders or full borders
309
+ */
310
+ export function shouldUseSelectiveBorders(blueprintId: string): boolean {
311
+ // Use selective borders if:
312
+ // 1. Borders are enabled globally AND
313
+ // 2. Either hideTouchingBorders is true (ALL pieces) OR this is a composite piece
314
+ return CONFIG.game.showBorders && (
315
+ CONFIG.game.hideTouchingBorders || // If true, ALL pieces use selective borders
316
+ blueprintId.startsWith('comp:') // If false, only composites use selective borders
317
+ );
318
+ }