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,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
|
+
}
|