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,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Board Component Hooks - Individual exports for advanced plugin development
|
|
3
|
+
*
|
|
4
|
+
* This module exports the individual React hooks that power the GameBoard component.
|
|
5
|
+
* These hooks can be used directly by plugin developers who need more granular
|
|
6
|
+
* control over board behavior than what GameBoard provides.
|
|
7
|
+
*
|
|
8
|
+
* ## Hook Architecture
|
|
9
|
+
*
|
|
10
|
+
* The board functionality is decomposed into focused hooks that each handle
|
|
11
|
+
* a specific aspect of the game mechanics:
|
|
12
|
+
*
|
|
13
|
+
* - **usePieceState**: Manages piece data and polygon computation
|
|
14
|
+
* - **useAnchorGrid**: Computes snap points and validation grids
|
|
15
|
+
* - **useDragController**: Handles drag interactions and collision detection
|
|
16
|
+
* - **useClickController**: Manages click-based interactions
|
|
17
|
+
*
|
|
18
|
+
* ## Usage Patterns
|
|
19
|
+
*
|
|
20
|
+
* ### Option 1: Use GameBoard (Recommended)
|
|
21
|
+
* Most plugins should use the GameBoard component which internally uses all hooks:
|
|
22
|
+
* ```typescript
|
|
23
|
+
* import { GameBoard } from "@/core/components";
|
|
24
|
+
* <GameBoard sectors={sectors} quickstash={macros} ... />
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* ### Option 2: Use Individual Hooks (Advanced)
|
|
28
|
+
* For custom board implementations, use hooks directly:
|
|
29
|
+
* ```typescript
|
|
30
|
+
* import { usePieceState, useDragController } from "@/core/components/board";
|
|
31
|
+
*
|
|
32
|
+
* function CustomBoard({ controller, layout }) {
|
|
33
|
+
* const pieceState = usePieceState(controller);
|
|
34
|
+
* const dragController = useDragController(
|
|
35
|
+
* controller, layout, pieceState.pieces, // additional params
|
|
36
|
+
* );
|
|
37
|
+
*
|
|
38
|
+
* return <div>Custom UI using hook state</div>;
|
|
39
|
+
* }
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* ## Hook Dependencies
|
|
43
|
+
*
|
|
44
|
+
* The hooks have dependencies on each other:
|
|
45
|
+
* - useDragController depends on usePieceState output
|
|
46
|
+
* - useClickController integrates with useDragController
|
|
47
|
+
* - useAnchorGrid is independent but used by drag/click controllers
|
|
48
|
+
*
|
|
49
|
+
* @see {@link GameBoard} For the complete integrated component
|
|
50
|
+
* @see {@link ../README.md} For comprehensive usage documentation
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
// Piece state management
|
|
54
|
+
export { usePieceState } from './usePieceState';
|
|
55
|
+
export type { PieceData, PieceStateHook } from './usePieceState';
|
|
56
|
+
|
|
57
|
+
// Anchor grid computation
|
|
58
|
+
export { useAnchorGrid } from './useAnchorGrid';
|
|
59
|
+
export type { AnchorDots } from './BoardView';
|
|
60
|
+
|
|
61
|
+
// Drag interaction handling
|
|
62
|
+
export { useDragController } from './useDragController';
|
|
63
|
+
export type { DragControllerHook } from './useDragController';
|
|
64
|
+
|
|
65
|
+
// Click interaction handling
|
|
66
|
+
export { useClickController } from './useClickController';
|
|
67
|
+
export type { ClickControllerHook } from './useClickController';
|
|
68
|
+
|
|
69
|
+
// Utility functions
|
|
70
|
+
export { blueprintLocalFromWorld, scalePolys } from './utils';
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { BaseGameController } from "@/core/engine/state/BaseGameController";
|
|
3
|
+
import type { CircleLayout } from "@/core/domain/layout";
|
|
4
|
+
import { rectForBand } from "@/core/domain/layout";
|
|
5
|
+
import { placeSilhouetteGridAlignedAsPolys } from "@/core/engine/geometry";
|
|
6
|
+
import {
|
|
7
|
+
workspaceNodes,
|
|
8
|
+
silhouetteNodes,
|
|
9
|
+
silhouetteBandNodes,
|
|
10
|
+
innerRingNodes
|
|
11
|
+
} from "@/core/engine/collision/grid-snapping";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Anchor dots data for a single sector
|
|
15
|
+
*
|
|
16
|
+
* Represents the valid and invalid snap points for piece placement within
|
|
17
|
+
* a sector. Valid dots allow piece placement, invalid dots show grid structure
|
|
18
|
+
* but don't allow snapping.
|
|
19
|
+
*/
|
|
20
|
+
export interface AnchorDots {
|
|
21
|
+
/** Sector ID these anchor dots belong to */
|
|
22
|
+
sectorId: string;
|
|
23
|
+
|
|
24
|
+
/** Array of valid snap points where pieces can be placed */
|
|
25
|
+
valid: { x: number; y: number }[];
|
|
26
|
+
|
|
27
|
+
/** Array of invalid snap points (visual grid, no placement allowed) */
|
|
28
|
+
invalid: { x: number; y: number }[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Return interface for useAnchorGrid hook
|
|
33
|
+
*
|
|
34
|
+
* Provides computed anchor grid data for all active sectors in the layout.
|
|
35
|
+
* Used by rendering components to draw snap points and by interaction hooks
|
|
36
|
+
* for piece placement validation.
|
|
37
|
+
*/
|
|
38
|
+
export interface AnchorGridHook {
|
|
39
|
+
/** Array of anchor dot data for all sectors (excludes completed sectors) */
|
|
40
|
+
anchorDots: AnchorDots[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Hook for computing anchor grid data for all sectors
|
|
45
|
+
*
|
|
46
|
+
* This hook computes the snap points (anchor dots) for piece placement validation
|
|
47
|
+
* across all active sectors. It handles both workspace and silhouette target modes,
|
|
48
|
+
* computing appropriate valid and invalid snap points for each.
|
|
49
|
+
*
|
|
50
|
+
* ## Key Features
|
|
51
|
+
* - **Workspace mode**: All grid nodes within sector bounds are valid
|
|
52
|
+
* - **Silhouette mode**: Only nodes within silhouette mask are valid
|
|
53
|
+
* - **Completion hiding**: Completed sectors don't show anchor dots
|
|
54
|
+
* - **Scale-aware**: Handles silhouette scaling for proper grid alignment
|
|
55
|
+
*
|
|
56
|
+
* ## Target Mode Behavior
|
|
57
|
+
* - **Workspace**: `valid` contains all workspace nodes, `invalid` is empty
|
|
58
|
+
* - **Silhouette**: `valid` contains nodes inside silhouette, `invalid` contains band nodes outside
|
|
59
|
+
*
|
|
60
|
+
* @param controller - BaseGameController instance for accessing game state
|
|
61
|
+
* @param layout - Computed CircleLayout containing sector geometry
|
|
62
|
+
* @param scaleS - Scaling factor for silhouette placement (from solveLogicalBox)
|
|
63
|
+
* @returns Hook interface with anchor dots for all active sectors
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```typescript
|
|
67
|
+
* const { anchorDots } = useAnchorGrid(controller, layout, scaleS);
|
|
68
|
+
* const sectorAnchors = anchorDots.find(a => a.sectorId === "sector_1");
|
|
69
|
+
* const snapPoints = sectorAnchors?.valid ?? [];
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export function useAnchorGrid(
|
|
73
|
+
controller: BaseGameController,
|
|
74
|
+
layout: CircleLayout,
|
|
75
|
+
scaleS: number
|
|
76
|
+
): AnchorGridHook {
|
|
77
|
+
const cfg = controller.state.cfg;
|
|
78
|
+
|
|
79
|
+
const anchorDots: AnchorDots[] = React.useMemo(() => {
|
|
80
|
+
const out: AnchorDots[] = [];
|
|
81
|
+
|
|
82
|
+
// Add inner ring anchors (always valid, shown throughout)
|
|
83
|
+
out.push({ sectorId: "inner-ring", valid: innerRingNodes(layout), invalid: [] });
|
|
84
|
+
|
|
85
|
+
for (const s of layout.sectors) {
|
|
86
|
+
// Show anchors for ALL sectors, including completed ones
|
|
87
|
+
if (cfg.target === "workspace") {
|
|
88
|
+
out.push({ sectorId: s.id, valid: workspaceNodes(layout, s), invalid: [] });
|
|
89
|
+
} else {
|
|
90
|
+
const mask = controller.state.cfg.sectors.find(ss => ss.id === s.id)?.silhouette.mask ?? [];
|
|
91
|
+
if (!mask || mask.length === 0) continue;
|
|
92
|
+
const rect = rectForBand(layout, s, "silhouette", 1.0); // center only; size was handled in global scale
|
|
93
|
+
const placedPolys = placeSilhouetteGridAlignedAsPolys(mask, scaleS, { cx: rect.cx, cy: rect.cy });
|
|
94
|
+
const bandAll = silhouetteBandNodes(layout, s);
|
|
95
|
+
const valid = silhouetteNodes(layout, s, placedPolys);
|
|
96
|
+
// invalid = bandAll \ valid (by coordinate)
|
|
97
|
+
const key = (p: { x: number; y: number }) => `${p.x},${p.y}`;
|
|
98
|
+
const validSet = new Set(valid.map(key));
|
|
99
|
+
const invalid = bandAll.filter(p => !validSet.has(key(p)));
|
|
100
|
+
out.push({ sectorId: s.id, valid, invalid });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
105
|
+
}, [cfg.target, controller.state.sectors, layout, scaleS]);
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
anchorDots
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { BaseGameController } from "@/core/engine/state/BaseGameController";
|
|
3
|
+
import type { CircleLayout } from "@/core/domain/layout";
|
|
4
|
+
import { sectorAtPoint } from "@/core/domain/layout";
|
|
5
|
+
import { boundsOfBlueprint, computeSupportOffsets, clampTopLeftBySupport, piecePolysAt } from "@/core/engine/geometry";
|
|
6
|
+
import type { PieceData } from "./usePieceState";
|
|
7
|
+
|
|
8
|
+
// Click controller state is managed internally with React hooks
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Return interface for useClickController hook
|
|
12
|
+
*
|
|
13
|
+
* Provides click-based interaction functionality that works alongside
|
|
14
|
+
* useDragController to support click-and-place interaction patterns.
|
|
15
|
+
*/
|
|
16
|
+
export interface ClickControllerHook {
|
|
17
|
+
// State
|
|
18
|
+
/** ID of pending blueprint for placement (from blueprint ring clicks) */
|
|
19
|
+
pendingBpId: string | null;
|
|
20
|
+
|
|
21
|
+
/** ID of currently selected piece (for click-to-move functionality) */
|
|
22
|
+
selectedPieceId: string | null;
|
|
23
|
+
|
|
24
|
+
// Handlers
|
|
25
|
+
/**
|
|
26
|
+
* Handler for root SVG clicks (placement, movement, deletion)
|
|
27
|
+
* @param e - Pointer event from SVG root element
|
|
28
|
+
*/
|
|
29
|
+
onRootPointerDown: (e: React.PointerEvent<SVGSVGElement>) => void;
|
|
30
|
+
|
|
31
|
+
// Helper methods for drag controller integration
|
|
32
|
+
/** Clear all selection state */
|
|
33
|
+
clearSelection: () => void;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Set pending blueprint ID for placement
|
|
37
|
+
* @param id - Blueprint ID or null to clear
|
|
38
|
+
*/
|
|
39
|
+
setPendingBp: (id: string | null) => void;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Set selected piece ID for movement
|
|
43
|
+
* @param id - Piece ID or null to clear
|
|
44
|
+
*/
|
|
45
|
+
setSelectedPiece: (id: string | null) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Hook for managing click-based interactions in click input mode
|
|
50
|
+
*
|
|
51
|
+
* This hook works in conjunction with useDragController to provide click-and-place
|
|
52
|
+
* interaction patterns. It manages the selection state for both blueprint icons and
|
|
53
|
+
* live pieces, handling placement, movement, and deletion through click interactions.
|
|
54
|
+
*
|
|
55
|
+
* ## Click Interaction Patterns
|
|
56
|
+
* - **Blueprint click**: Selects blueprint for placement (pendingBpId)
|
|
57
|
+
* - **Piece click**: Starts "carry" state via drag controller integration
|
|
58
|
+
* - **Empty click**: Places pending blueprint or moves selected piece
|
|
59
|
+
* - **Center badge click**: Deletes carried/selected piece
|
|
60
|
+
* - **Completed sector click**: Deletes pieces (invalid placement)
|
|
61
|
+
*
|
|
62
|
+
* ## State Management
|
|
63
|
+
* - **pendingBpId**: Blueprint selected from ring, awaiting placement
|
|
64
|
+
* - **selectedPieceId**: Live piece selected for movement
|
|
65
|
+
* - **Integration with drag controller**: Uses draggingId for "carry" state
|
|
66
|
+
*
|
|
67
|
+
* ## Dependencies
|
|
68
|
+
* This hook requires tight integration with useDragController as it uses the
|
|
69
|
+
* drag controller's carry mechanism for piece movement in click mode.
|
|
70
|
+
*
|
|
71
|
+
* @param controller - BaseGameController for state management
|
|
72
|
+
* @param layout - Computed CircleLayout for sector geometry
|
|
73
|
+
* @param pieces - Current piece data from usePieceState
|
|
74
|
+
* @param clickMode - Whether click input mode is active
|
|
75
|
+
* @param draggingId - Current dragging piece ID from drag controller
|
|
76
|
+
* @param setDraggingId - Setter for dragging ID (drag controller integration)
|
|
77
|
+
* @param dragRef - Drag state ref from drag controller
|
|
78
|
+
* @param isInsideBadge - Function to check if point is inside center badge
|
|
79
|
+
* @param isSectorLocked - Function to check if sector is completed
|
|
80
|
+
* @param maybeCompleteSector - Callback to check sector completion
|
|
81
|
+
* @param svgPoint - Function to convert screen to SVG coordinates
|
|
82
|
+
* @param force - Force React re-render function
|
|
83
|
+
* @returns Hook interface with click state and handlers
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```typescript
|
|
87
|
+
* const clickController = useClickController(
|
|
88
|
+
* controller, layout, pieces, clickMode,
|
|
89
|
+
* draggingId, setDraggingId, dragRef,
|
|
90
|
+
* isInsideBadge, isSectorLocked, maybeCompleteSector, svgPoint, force
|
|
91
|
+
* );
|
|
92
|
+
*
|
|
93
|
+
* // Usage in JSX:
|
|
94
|
+
* <svg onPointerDown={clickController.onRootPointerDown}>
|
|
95
|
+
* Game content
|
|
96
|
+
* </svg>
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
export function useClickController(
|
|
100
|
+
controller: BaseGameController,
|
|
101
|
+
layout: CircleLayout,
|
|
102
|
+
pieces: PieceData[],
|
|
103
|
+
clickMode: boolean,
|
|
104
|
+
draggingId: string | null,
|
|
105
|
+
setDraggingId: (id: string | null) => void,
|
|
106
|
+
dragRef: React.RefObject<any>,
|
|
107
|
+
isInsideBadge: (x: number, y: number) => boolean,
|
|
108
|
+
isSectorLocked: (id: string) => boolean,
|
|
109
|
+
maybeCompleteSector: (secId: string) => boolean,
|
|
110
|
+
svgPoint: (clientX: number, clientY: number) => { x: number; y: number },
|
|
111
|
+
force: () => void,
|
|
112
|
+
// Interaction tracking
|
|
113
|
+
tracker?: {
|
|
114
|
+
recordPlacedown: (
|
|
115
|
+
outcome: 'placed' | 'deleted',
|
|
116
|
+
sectorId?: string,
|
|
117
|
+
position?: { x: number; y: number },
|
|
118
|
+
vertices?: number[][][],
|
|
119
|
+
anchorId?: string,
|
|
120
|
+
wasValid?: boolean,
|
|
121
|
+
wasOverlapping?: boolean,
|
|
122
|
+
completedSector?: boolean
|
|
123
|
+
) => void;
|
|
124
|
+
recordClickEvent?: (
|
|
125
|
+
location: { x: number; y: number },
|
|
126
|
+
clickType: 'blueprint_view_switch' | 'invalid_placement' | 'sector_complete_attempt',
|
|
127
|
+
metadata?: any
|
|
128
|
+
) => void;
|
|
129
|
+
} | null
|
|
130
|
+
): ClickControllerHook {
|
|
131
|
+
const cfg = controller.state.cfg;
|
|
132
|
+
const sectorIdAt = (x: number, y: number) => sectorAtPoint(x, y, layout, cfg.target);
|
|
133
|
+
|
|
134
|
+
// Helper: Compute piece vertices in world space
|
|
135
|
+
const getPieceVertices = (pieceId: string): number[][][] => {
|
|
136
|
+
const piece = controller.findPiece(pieceId);
|
|
137
|
+
if (!piece) return [];
|
|
138
|
+
const bp = controller.getBlueprint(piece.blueprintId);
|
|
139
|
+
if (!bp) return [];
|
|
140
|
+
const bb = boundsOfBlueprint(bp, controller.getPrimitive);
|
|
141
|
+
const polys = piecePolysAt(bp, bb, piece.pos);
|
|
142
|
+
// Convert from {x,y}[][] to number[][][]
|
|
143
|
+
return polys.map(ring => ring.map(pt => [pt.x, pt.y]));
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const getCurrentAnchorInfo = () => {
|
|
147
|
+
const d = dragRef.current;
|
|
148
|
+
return {
|
|
149
|
+
anchorId: d?.snapAnchorId ?? d?.pointerAnchorId ?? undefined,
|
|
150
|
+
wasValid: d?.validSnap !== false,
|
|
151
|
+
wasOverlapping: d?.overlaps ?? false,
|
|
152
|
+
anchorX: d?.pointerAnchor?.x,
|
|
153
|
+
anchorY: d?.pointerAnchor?.y,
|
|
154
|
+
sectorId: d?.snapAnchorSectorId ?? d?.pointerAnchorSectorId
|
|
155
|
+
};
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const emitClickEvent = (
|
|
159
|
+
location: { x: number; y: number },
|
|
160
|
+
clickType: 'blueprint_view_switch' | 'invalid_placement' | 'sector_complete_attempt',
|
|
161
|
+
metadata?: any
|
|
162
|
+
) => {
|
|
163
|
+
if (!tracker?.recordClickEvent) return;
|
|
164
|
+
tracker.recordClickEvent(location, clickType, metadata);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// Click mode state
|
|
168
|
+
const [pendingBpId, setPendingBpId] = React.useState<string | null>(null); // selected icon
|
|
169
|
+
const [selectedPieceId, setSelectedPieceId] = React.useState<string | null>(null); // selected live piece
|
|
170
|
+
|
|
171
|
+
const onRootPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
|
|
172
|
+
const { x: px, y: py } = svgPoint(e.clientX, e.clientY);
|
|
173
|
+
|
|
174
|
+
// --- CLICK MODE: second-click behaviors (move/delete/place)
|
|
175
|
+
if (clickMode) {
|
|
176
|
+
const secId = sectorIdAt(px, py);
|
|
177
|
+
|
|
178
|
+
// If we're currently carrying a piece (draggingId set via click), this click commits (or deletes)
|
|
179
|
+
if (draggingId && dragRef.current) {
|
|
180
|
+
const d = dragRef.current;
|
|
181
|
+
const centerX2 = d.tlx + d.aabb.width / 2;
|
|
182
|
+
const centerY2 = d.tly + d.aabb.height / 2;
|
|
183
|
+
|
|
184
|
+
// delete/cancel if clicked center (carry piece is already under pointer)
|
|
185
|
+
if (isInsideBadge(px, py)) {
|
|
186
|
+
const piece = controller.findPiece(d.id);
|
|
187
|
+
const removedFromSector = piece?.sectorId;
|
|
188
|
+
const anchorInfo = getCurrentAnchorInfo();
|
|
189
|
+
controller.remove(d.id);
|
|
190
|
+
// Re-check completion for the sector the piece was removed from
|
|
191
|
+
if (removedFromSector) maybeCompleteSector(removedFromSector);
|
|
192
|
+
|
|
193
|
+
// Record placedown as "deleted"
|
|
194
|
+
if (tracker) {
|
|
195
|
+
tracker.recordPlacedown(
|
|
196
|
+
'deleted',
|
|
197
|
+
removedFromSector,
|
|
198
|
+
undefined,
|
|
199
|
+
undefined,
|
|
200
|
+
anchorInfo.anchorId,
|
|
201
|
+
anchorInfo.wasValid,
|
|
202
|
+
anchorInfo.wasOverlapping
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// TODO: Remove dragRef manipulation once drag controller provides proper interface
|
|
207
|
+
// dragRef.current = null; // Cannot assign to readonly property - drag controller should handle this
|
|
208
|
+
setDraggingId(null);
|
|
209
|
+
setSelectedPieceId(null);
|
|
210
|
+
setPendingBpId(null);
|
|
211
|
+
force();
|
|
212
|
+
return; // don't toggle view
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Check if piece is in inner ring - if so, delete it
|
|
216
|
+
const rFromCenter = Math.hypot(centerX2 - layout.cx, centerY2 - layout.cy);
|
|
217
|
+
if (rFromCenter < layout.innerR) {
|
|
218
|
+
const piece = controller.findPiece(d.id);
|
|
219
|
+
const removedFromSector = piece?.sectorId;
|
|
220
|
+
const anchorInfo = getCurrentAnchorInfo();
|
|
221
|
+
controller.remove(d.id);
|
|
222
|
+
if (removedFromSector) maybeCompleteSector(removedFromSector);
|
|
223
|
+
|
|
224
|
+
// Record placedown as "deleted"
|
|
225
|
+
if (tracker) {
|
|
226
|
+
tracker.recordPlacedown(
|
|
227
|
+
'deleted',
|
|
228
|
+
removedFromSector,
|
|
229
|
+
undefined,
|
|
230
|
+
undefined,
|
|
231
|
+
anchorInfo.anchorId,
|
|
232
|
+
anchorInfo.wasValid,
|
|
233
|
+
anchorInfo.wasOverlapping
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
setDraggingId(null);
|
|
238
|
+
setSelectedPieceId(null);
|
|
239
|
+
setPendingBpId(null);
|
|
240
|
+
force();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const secId2 = sectorIdAt(centerX2, centerY2);
|
|
245
|
+
|
|
246
|
+
if (!secId2) {
|
|
247
|
+
emitClickEvent(
|
|
248
|
+
{ x: centerX2, y: centerY2 },
|
|
249
|
+
'invalid_placement',
|
|
250
|
+
{ invalidPlacement: { reason: 'outside_bounds' } }
|
|
251
|
+
);
|
|
252
|
+
force();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (isSectorLocked(secId2)) {
|
|
257
|
+
emitClickEvent(
|
|
258
|
+
{ x: centerX2, y: centerY2 },
|
|
259
|
+
'sector_complete_attempt',
|
|
260
|
+
{ invalidPlacement: { reason: 'sector_complete', attemptedSectorId: secId2 } }
|
|
261
|
+
);
|
|
262
|
+
force();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// If the candidate is invalid for any reason (overlap / outside / bad node), keep carrying
|
|
267
|
+
if (d.validSnap === false) {
|
|
268
|
+
const anchorInfo = getCurrentAnchorInfo();
|
|
269
|
+
const invalidReason = anchorInfo.wasOverlapping ? 'overlapping' : 'no_valid_anchor';
|
|
270
|
+
emitClickEvent(
|
|
271
|
+
{ x: centerX2, y: centerY2 },
|
|
272
|
+
'invalid_placement',
|
|
273
|
+
{ invalidPlacement: { reason: invalidReason, attemptedSectorId: secId2 } }
|
|
274
|
+
);
|
|
275
|
+
force();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
controller.move(d.id, { x: d.tlx, y: d.tly }); // TL already snapped (valid)
|
|
280
|
+
controller.drop(d.id, secId2);
|
|
281
|
+
|
|
282
|
+
const anchorInfo = getCurrentAnchorInfo();
|
|
283
|
+
const justCompleted = maybeCompleteSector(secId2);
|
|
284
|
+
|
|
285
|
+
// Record placedown as "placed" successfully
|
|
286
|
+
if (tracker) {
|
|
287
|
+
tracker.recordPlacedown(
|
|
288
|
+
'placed',
|
|
289
|
+
secId2,
|
|
290
|
+
{ x: d.tlx, y: d.tly },
|
|
291
|
+
getPieceVertices(d.id),
|
|
292
|
+
anchorInfo.anchorId,
|
|
293
|
+
anchorInfo.wasValid,
|
|
294
|
+
anchorInfo.wasOverlapping,
|
|
295
|
+
justCompleted
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
// TODO: Remove dragRef manipulation once drag controller provides proper interface
|
|
299
|
+
// dragRef.current = null; // Cannot assign to readonly property - drag controller should handle this
|
|
300
|
+
setDraggingId(null);
|
|
301
|
+
setSelectedPieceId(null);
|
|
302
|
+
setPendingBpId(null);
|
|
303
|
+
// auto-revert AFTER placement completes (click mode)
|
|
304
|
+
if (controller.state.blueprintView === "primitives") {
|
|
305
|
+
controller.switchBlueprintView();
|
|
306
|
+
}
|
|
307
|
+
force();
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Selected piece → delete / move / deselect
|
|
312
|
+
if (selectedPieceId) {
|
|
313
|
+
if (isInsideBadge(px, py)) {
|
|
314
|
+
const piece = controller.findPiece(selectedPieceId);
|
|
315
|
+
const removedFromSector = piece?.sectorId;
|
|
316
|
+
controller.remove(selectedPieceId);
|
|
317
|
+
// Re-check completion for the sector the piece was removed from
|
|
318
|
+
if (removedFromSector) maybeCompleteSector(removedFromSector);
|
|
319
|
+
|
|
320
|
+
// Record placedown as "deleted"
|
|
321
|
+
if (tracker) {
|
|
322
|
+
tracker.recordPlacedown('deleted');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
setSelectedPieceId(null);
|
|
326
|
+
force();
|
|
327
|
+
return; // DO NOT toggle view after delete
|
|
328
|
+
}
|
|
329
|
+
// Completed sector: no-op (do nothing, same as incomplete sector in silhouette mode)
|
|
330
|
+
if (secId && isSectorLocked(secId)) {
|
|
331
|
+
setSelectedPieceId(null);
|
|
332
|
+
force();
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (secId) {
|
|
336
|
+
const p = pieces.find(pp => pp.id === selectedPieceId);
|
|
337
|
+
setSelectedPieceId(null);
|
|
338
|
+
if (p) {
|
|
339
|
+
const bp = controller.getBlueprint(p.blueprintId)!;
|
|
340
|
+
const bb = boundsOfBlueprint(bp, controller.getPrimitive);
|
|
341
|
+
const support = computeSupportOffsets(bp, bb);
|
|
342
|
+
const tl0 = { x: px - bb.width / 2, y: py - bb.height / 2 };
|
|
343
|
+
const clamped = clampTopLeftBySupport(
|
|
344
|
+
tl0.x, tl0.y,
|
|
345
|
+
{ aabb: { width: bb.width, height: bb.height }, support },
|
|
346
|
+
layout,
|
|
347
|
+
controller.state.cfg.target,
|
|
348
|
+
false
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
const maxPieces = controller.state.cfg.maxCompositeSize ?? 0;
|
|
352
|
+
if (controller.state.cfg.mode === "prep" && maxPieces > 0) {
|
|
353
|
+
const sectorPieces = controller.getPiecesInSector(secId);
|
|
354
|
+
const effectiveCount = sectorPieces.length - (p.sectorId === secId ? 1 : 0);
|
|
355
|
+
if (effectiveCount + 1 > maxPieces) {
|
|
356
|
+
force();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
controller.move(p.id, { x: clamped.x, y: clamped.y });
|
|
362
|
+
controller.drop(p.id, secId);
|
|
363
|
+
maybeCompleteSector(secId);
|
|
364
|
+
force();
|
|
365
|
+
}
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
// click outside → deselect only
|
|
369
|
+
setSelectedPieceId(null);
|
|
370
|
+
force();
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Pending icon → place
|
|
375
|
+
if (pendingBpId) {
|
|
376
|
+
const secId2 = sectorIdAt(px, py);
|
|
377
|
+
|
|
378
|
+
if (isInsideBadge(px, py) || !secId2 || isSectorLocked(secId2)) {
|
|
379
|
+
setPendingBpId(null); // cancel
|
|
380
|
+
force();
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const bp = controller.getBlueprint(pendingBpId)!;
|
|
385
|
+
const bb = boundsOfBlueprint(bp, controller.getPrimitive);
|
|
386
|
+
const support = computeSupportOffsets(bp, bb);
|
|
387
|
+
const tl0 = { x: px - bb.width / 2, y: py - bb.height / 2 };
|
|
388
|
+
const clamped = clampTopLeftBySupport(
|
|
389
|
+
tl0.x, tl0.y,
|
|
390
|
+
{ aabb: { width: bb.width, height: bb.height }, support },
|
|
391
|
+
layout,
|
|
392
|
+
controller.state.cfg.target,
|
|
393
|
+
false
|
|
394
|
+
);
|
|
395
|
+
const id = controller.spawnFromBlueprint(bp, clamped);
|
|
396
|
+
|
|
397
|
+
const maxPieces = controller.state.cfg.maxCompositeSize ?? 0;
|
|
398
|
+
if (controller.state.cfg.mode === "prep" && maxPieces > 0) {
|
|
399
|
+
const sectorPieces = controller.getPiecesInSector(secId2);
|
|
400
|
+
if (sectorPieces.length + 1 > maxPieces) {
|
|
401
|
+
controller.remove(id);
|
|
402
|
+
setPendingBpId(null);
|
|
403
|
+
force();
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
controller.drop(id, secId2);
|
|
409
|
+
setPendingBpId(null);
|
|
410
|
+
maybeCompleteSector(secId2);
|
|
411
|
+
|
|
412
|
+
// auto-revert AFTER placement completes (click mode)
|
|
413
|
+
if (controller.state.blueprintView === "primitives") controller.switchBlueprintView();
|
|
414
|
+
force();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
// State
|
|
422
|
+
pendingBpId,
|
|
423
|
+
selectedPieceId,
|
|
424
|
+
|
|
425
|
+
// Handlers
|
|
426
|
+
onRootPointerDown,
|
|
427
|
+
|
|
428
|
+
// Helper methods for integration
|
|
429
|
+
clearSelection: () => {
|
|
430
|
+
setSelectedPieceId(null);
|
|
431
|
+
setPendingBpId(null);
|
|
432
|
+
},
|
|
433
|
+
setPendingBp: setPendingBpId,
|
|
434
|
+
setSelectedPiece: setSelectedPieceId,
|
|
435
|
+
};
|
|
436
|
+
}
|