react-native-chess-kit 0.1.0
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/LICENSE +21 -0
- package/README.md +168 -0
- package/lib/commonjs/board-background.js +49 -0
- package/lib/commonjs/board-background.js.map +1 -0
- package/lib/commonjs/board-coordinates.js +78 -0
- package/lib/commonjs/board-coordinates.js.map +1 -0
- package/lib/commonjs/board-drag-ghost.js +110 -0
- package/lib/commonjs/board-drag-ghost.js.map +1 -0
- package/lib/commonjs/board-legal-dots.js +67 -0
- package/lib/commonjs/board-legal-dots.js.map +1 -0
- package/lib/commonjs/board-piece.js +74 -0
- package/lib/commonjs/board-piece.js.map +1 -0
- package/lib/commonjs/board-pieces.js +47 -0
- package/lib/commonjs/board-pieces.js.map +1 -0
- package/lib/commonjs/board.js +188 -0
- package/lib/commonjs/board.js.map +1 -0
- package/lib/commonjs/index.js +26 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/types.js +6 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/commonjs/use-board-gesture.js +158 -0
- package/lib/commonjs/use-board-gesture.js.map +1 -0
- package/lib/commonjs/use-board-pieces.js +195 -0
- package/lib/commonjs/use-board-pieces.js.map +1 -0
- package/lib/commonjs/use-board-state.js +78 -0
- package/lib/commonjs/use-board-state.js.map +1 -0
- package/lib/module/board-background.js +44 -0
- package/lib/module/board-background.js.map +1 -0
- package/lib/module/board-coordinates.js +73 -0
- package/lib/module/board-coordinates.js.map +1 -0
- package/lib/module/board-drag-ghost.js +104 -0
- package/lib/module/board-drag-ghost.js.map +1 -0
- package/lib/module/board-legal-dots.js +62 -0
- package/lib/module/board-legal-dots.js.map +1 -0
- package/lib/module/board-piece.js +69 -0
- package/lib/module/board-piece.js.map +1 -0
- package/lib/module/board-pieces.js +42 -0
- package/lib/module/board-pieces.js.map +1 -0
- package/lib/module/board.js +184 -0
- package/lib/module/board.js.map +1 -0
- package/lib/module/index.js +21 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +4 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/use-board-gesture.js +154 -0
- package/lib/module/use-board-gesture.js.map +1 -0
- package/lib/module/use-board-pieces.js +189 -0
- package/lib/module/use-board-pieces.js.map +1 -0
- package/lib/module/use-board-state.js +74 -0
- package/lib/module/use-board-state.js.map +1 -0
- package/lib/typescript/board-background.d.ts +15 -0
- package/lib/typescript/board-background.d.ts.map +1 -0
- package/lib/typescript/board-coordinates.d.ts +20 -0
- package/lib/typescript/board-coordinates.d.ts.map +1 -0
- package/lib/typescript/board-drag-ghost.d.ts +21 -0
- package/lib/typescript/board-drag-ghost.d.ts.map +1 -0
- package/lib/typescript/board-legal-dots.d.ts +16 -0
- package/lib/typescript/board-legal-dots.d.ts.map +1 -0
- package/lib/typescript/board-piece.d.ts +36 -0
- package/lib/typescript/board-piece.d.ts.map +1 -0
- package/lib/typescript/board-pieces.d.ts +22 -0
- package/lib/typescript/board-pieces.d.ts.map +1 -0
- package/lib/typescript/board.d.ts +17 -0
- package/lib/typescript/board.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +4 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/lib/typescript/types.d.ts +88 -0
- package/lib/typescript/types.d.ts.map +1 -0
- package/lib/typescript/use-board-gesture.d.ts +46 -0
- package/lib/typescript/use-board-gesture.d.ts.map +1 -0
- package/lib/typescript/use-board-pieces.d.ts +23 -0
- package/lib/typescript/use-board-pieces.d.ts.map +1 -0
- package/lib/typescript/use-board-state.d.ts +35 -0
- package/lib/typescript/use-board-state.d.ts.map +1 -0
- package/package.json +73 -0
- package/src/board-background.tsx +46 -0
- package/src/board-coordinates.tsx +98 -0
- package/src/board-drag-ghost.tsx +132 -0
- package/src/board-legal-dots.tsx +73 -0
- package/src/board-piece.tsx +104 -0
- package/src/board-pieces.tsx +56 -0
- package/src/board.tsx +203 -0
- package/src/index.ts +39 -0
- package/src/types.ts +114 -0
- package/src/use-board-gesture.ts +201 -0
- package/src/use-board-pieces.ts +158 -0
- package/src/use-board-state.ts +104 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { SharedValue } from 'react-native-reanimated';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Core chess types
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
/** Board orientation / side color */
|
|
8
|
+
export type ChessColor = 'white' | 'black';
|
|
9
|
+
|
|
10
|
+
/** How the user interacts with pieces */
|
|
11
|
+
export type MoveMethod = 'drag' | 'click' | 'both';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Piece data
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/** A single piece on the board with its position and identity */
|
|
18
|
+
export type BoardPiece = {
|
|
19
|
+
/** Unique stable identifier (survives position changes): e.g. "wp-0", "bk-0" */
|
|
20
|
+
id: string;
|
|
21
|
+
/** Piece code matching standard notation: 'wp', 'wn', 'wb', 'wr', 'wq', 'wk', 'bp', etc. */
|
|
22
|
+
code: string;
|
|
23
|
+
/** Current square in algebraic notation: 'a1'..'h8' */
|
|
24
|
+
square: string;
|
|
25
|
+
/** 'w' or 'b' */
|
|
26
|
+
color: 'w' | 'b';
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Intermediate representation from FEN parsing (before ID assignment) */
|
|
30
|
+
export type ParsedPiece = {
|
|
31
|
+
code: string;
|
|
32
|
+
square: string;
|
|
33
|
+
color: 'w' | 'b';
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Board ref (exposed to consumers via forwardRef)
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
export type BoardRef = {
|
|
41
|
+
/** Pre-apply a move to internal state. Visual animation happens when parent updates the FEN prop. */
|
|
42
|
+
move: (move: { from: string; to: string }) => void;
|
|
43
|
+
/** Highlight a square with a color */
|
|
44
|
+
highlight: (square: string, color: string) => void;
|
|
45
|
+
/** Clear all imperative highlights */
|
|
46
|
+
clearHighlights: () => void;
|
|
47
|
+
/** Reset board to a new FEN position */
|
|
48
|
+
resetBoard: (fen: string) => void;
|
|
49
|
+
/** Undo the last visually applied move (snap back to previous position) */
|
|
50
|
+
undo: () => void;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Board props
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
export type BoardColors = {
|
|
58
|
+
light: string;
|
|
59
|
+
dark: string;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type BoardProps = {
|
|
63
|
+
/** Current board position in FEN notation */
|
|
64
|
+
fen: string;
|
|
65
|
+
/** Which color is at the bottom of the board */
|
|
66
|
+
orientation: ChessColor;
|
|
67
|
+
/** Board width/height in pixels */
|
|
68
|
+
boardSize: number;
|
|
69
|
+
/** Whether gesture interaction is enabled */
|
|
70
|
+
gestureEnabled: boolean;
|
|
71
|
+
/** Which side can interact: 'white', 'black', or 'both' */
|
|
72
|
+
player: ChessColor | 'both';
|
|
73
|
+
/** Called after a visual move is applied */
|
|
74
|
+
onMove?: (info: { from: string; to: string }) => void;
|
|
75
|
+
/** Board square colors */
|
|
76
|
+
colors: BoardColors;
|
|
77
|
+
/** Move animation duration in ms (0 = instant) */
|
|
78
|
+
moveDuration: number;
|
|
79
|
+
/** Whether to show file labels (a-h) */
|
|
80
|
+
withLetters: boolean;
|
|
81
|
+
/** Whether to show rank numbers (1-8) */
|
|
82
|
+
withNumbers: boolean;
|
|
83
|
+
/** Custom piece renderer. Receives piece code ('wp', 'bk', etc.) and pixel size. */
|
|
84
|
+
renderPiece?: (pieceCode: string, size: number) => React.ReactElement;
|
|
85
|
+
/** Whether to show legal move dots when a piece is selected */
|
|
86
|
+
showLegalMoves: boolean;
|
|
87
|
+
/** How the user moves pieces */
|
|
88
|
+
moveMethod: MoveMethod;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Gesture shared values (internal, but exported for advanced overlay use)
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
export type GestureState = {
|
|
96
|
+
/** Square the active piece started from (null if no drag/selection) */
|
|
97
|
+
activeSquare: SharedValue<string | null>;
|
|
98
|
+
/** Current drag position in board-local pixels */
|
|
99
|
+
dragX: SharedValue<number>;
|
|
100
|
+
dragY: SharedValue<number>;
|
|
101
|
+
/** Whether a drag gesture is currently active */
|
|
102
|
+
isDragging: SharedValue<boolean>;
|
|
103
|
+
/** The piece code being dragged (for rendering the ghost) */
|
|
104
|
+
dragPieceCode: SharedValue<string | null>;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Legal move dots
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
export type LegalMoveTarget = {
|
|
112
|
+
square: string;
|
|
113
|
+
isCapture: boolean;
|
|
114
|
+
};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { useMemo, useCallback, useRef } from 'react';
|
|
2
|
+
import { Gesture } from 'react-native-gesture-handler';
|
|
3
|
+
import {
|
|
4
|
+
useSharedValue,
|
|
5
|
+
runOnJS,
|
|
6
|
+
} from 'react-native-reanimated';
|
|
7
|
+
|
|
8
|
+
import type { ChessColor, MoveMethod, BoardPiece, LegalMoveTarget, GestureState } from './types';
|
|
9
|
+
import { xyToSquare } from './use-board-pieces';
|
|
10
|
+
|
|
11
|
+
type GestureCallbacks = {
|
|
12
|
+
onPieceSelected: (square: string) => void;
|
|
13
|
+
onPieceMoved: (from: string, to: string) => void;
|
|
14
|
+
onSelectionCleared: () => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type UseBoardGestureParams = {
|
|
18
|
+
squareSize: number;
|
|
19
|
+
orientation: ChessColor;
|
|
20
|
+
gestureEnabled: boolean;
|
|
21
|
+
player: ChessColor | 'both';
|
|
22
|
+
moveMethod: MoveMethod;
|
|
23
|
+
pieces: BoardPiece[];
|
|
24
|
+
callbacks: GestureCallbacks;
|
|
25
|
+
/** Currently selected square (for tap-to-move second tap) */
|
|
26
|
+
selectedSquare: string | null;
|
|
27
|
+
/** Legal move targets from the currently selected piece */
|
|
28
|
+
legalMoves: LegalMoveTarget[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type UseBoardGestureReturn = {
|
|
32
|
+
gesture: ReturnType<typeof Gesture.Pan>;
|
|
33
|
+
gestureState: GestureState;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Single centralized gesture handler for the entire board.
|
|
38
|
+
*
|
|
39
|
+
* Instead of 32 separate Gesture.Pan() handlers (one per piece), we use ONE
|
|
40
|
+
* handler on the board container. Touch -> coordinate math -> which piece.
|
|
41
|
+
*
|
|
42
|
+
* Supports three modes:
|
|
43
|
+
* - 'drag': drag piece to target square
|
|
44
|
+
* - 'click': tap source piece, then tap target square
|
|
45
|
+
* - 'both': drag or click (default)
|
|
46
|
+
*
|
|
47
|
+
* All drag position tracking uses shared values — zero JS bridge calls,
|
|
48
|
+
* zero re-renders during drag. Only the final move triggers JS via runOnJS.
|
|
49
|
+
*
|
|
50
|
+
* The gesture object is STABLE (only recreated when squareSize, orientation,
|
|
51
|
+
* gestureEnabled, player, or moveMethod change). Frequently-changing data
|
|
52
|
+
* (pieces, selectedSquare, legalMoves) is read from refs via runOnJS bridge
|
|
53
|
+
* functions, avoiding costly gesture teardown/rebuild on every move.
|
|
54
|
+
*/
|
|
55
|
+
export function useBoardGesture({
|
|
56
|
+
squareSize,
|
|
57
|
+
orientation,
|
|
58
|
+
gestureEnabled,
|
|
59
|
+
player,
|
|
60
|
+
moveMethod,
|
|
61
|
+
pieces,
|
|
62
|
+
callbacks,
|
|
63
|
+
selectedSquare,
|
|
64
|
+
legalMoves,
|
|
65
|
+
}: UseBoardGestureParams): UseBoardGestureReturn {
|
|
66
|
+
// Shared values for drag tracking — updated on UI thread only
|
|
67
|
+
const activeSquare = useSharedValue<string | null>(null);
|
|
68
|
+
const dragX = useSharedValue(0);
|
|
69
|
+
const dragY = useSharedValue(0);
|
|
70
|
+
const isDragging = useSharedValue(false);
|
|
71
|
+
const dragPieceCode = useSharedValue<string | null>(null);
|
|
72
|
+
|
|
73
|
+
const gestureState: GestureState = {
|
|
74
|
+
activeSquare,
|
|
75
|
+
dragX,
|
|
76
|
+
dragY,
|
|
77
|
+
isDragging,
|
|
78
|
+
dragPieceCode,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// --- Refs for frequently-changing data (read from JS thread via runOnJS) ---
|
|
82
|
+
// These update every move but do NOT cause gesture object recreation.
|
|
83
|
+
const piecesRef = useRef(pieces);
|
|
84
|
+
piecesRef.current = pieces;
|
|
85
|
+
|
|
86
|
+
const selectedSquareRef = useRef(selectedSquare);
|
|
87
|
+
selectedSquareRef.current = selectedSquare;
|
|
88
|
+
|
|
89
|
+
const legalMovesRef = useRef(legalMoves);
|
|
90
|
+
legalMovesRef.current = legalMoves;
|
|
91
|
+
|
|
92
|
+
const callbacksRef = useRef(callbacks);
|
|
93
|
+
callbacksRef.current = callbacks;
|
|
94
|
+
|
|
95
|
+
// --- JS-thread bridge functions called from worklets via runOnJS ---
|
|
96
|
+
// These read current values from refs, so they always have fresh data.
|
|
97
|
+
|
|
98
|
+
const handleBegin = useCallback((touchX: number, touchY: number) => {
|
|
99
|
+
const square = xyToSquare(touchX, touchY, squareSize, orientation);
|
|
100
|
+
const currentPieces = piecesRef.current;
|
|
101
|
+
const currentSelected = selectedSquareRef.current;
|
|
102
|
+
const currentLegalMoves = legalMovesRef.current;
|
|
103
|
+
const cbs = callbacksRef.current;
|
|
104
|
+
const canClick = moveMethod !== 'drag';
|
|
105
|
+
|
|
106
|
+
// Build lookup for the current touch
|
|
107
|
+
const piece = currentPieces.find((p) => p.square === square);
|
|
108
|
+
const isPlayerPiece = piece
|
|
109
|
+
? player === 'both' || (piece.color === 'w' ? 'white' : 'black') === player
|
|
110
|
+
: false;
|
|
111
|
+
|
|
112
|
+
// Click-to-move: second tap on a legal target square
|
|
113
|
+
const legalSquares = new Set(currentLegalMoves.map((m) => m.square));
|
|
114
|
+
if (canClick && currentSelected && legalSquares.has(square)) {
|
|
115
|
+
cbs.onPieceMoved(currentSelected, square);
|
|
116
|
+
activeSquare.value = null;
|
|
117
|
+
isDragging.value = false;
|
|
118
|
+
dragPieceCode.value = null;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (isPlayerPiece && piece) {
|
|
123
|
+
// Tapped/started dragging a player piece
|
|
124
|
+
activeSquare.value = square;
|
|
125
|
+
dragX.value = touchX;
|
|
126
|
+
dragY.value = touchY;
|
|
127
|
+
dragPieceCode.value = piece.code;
|
|
128
|
+
cbs.onPieceSelected(square);
|
|
129
|
+
} else {
|
|
130
|
+
// Tapped empty square or opponent piece — clear selection
|
|
131
|
+
activeSquare.value = null;
|
|
132
|
+
dragPieceCode.value = null;
|
|
133
|
+
if (currentSelected) {
|
|
134
|
+
cbs.onSelectionCleared();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}, [squareSize, orientation, player, moveMethod, activeSquare, dragX, dragY, isDragging, dragPieceCode]);
|
|
138
|
+
|
|
139
|
+
const handleEnd = useCallback((touchX: number, touchY: number) => {
|
|
140
|
+
const fromSquare = activeSquare.value;
|
|
141
|
+
if (!fromSquare) return;
|
|
142
|
+
|
|
143
|
+
const toSquare = xyToSquare(touchX, touchY, squareSize, orientation);
|
|
144
|
+
isDragging.value = false;
|
|
145
|
+
|
|
146
|
+
if (fromSquare !== toSquare) {
|
|
147
|
+
callbacksRef.current.onPieceMoved(fromSquare, toSquare);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
activeSquare.value = null;
|
|
151
|
+
dragPieceCode.value = null;
|
|
152
|
+
}, [squareSize, orientation, activeSquare, isDragging, dragPieceCode]);
|
|
153
|
+
|
|
154
|
+
// --- Build the gesture (STABLE — only changes on layout/config changes) ---
|
|
155
|
+
const canDrag = moveMethod !== 'click';
|
|
156
|
+
|
|
157
|
+
const gesture = useMemo(() => {
|
|
158
|
+
return Gesture.Pan()
|
|
159
|
+
.enabled(gestureEnabled)
|
|
160
|
+
.minDistance(0) // Also detect taps (zero-distance pans)
|
|
161
|
+
.onBegin((e) => {
|
|
162
|
+
'worklet';
|
|
163
|
+
// Bridge to JS for piece lookup + selection logic
|
|
164
|
+
runOnJS(handleBegin)(e.x, e.y);
|
|
165
|
+
})
|
|
166
|
+
.onStart(() => {
|
|
167
|
+
'worklet';
|
|
168
|
+
if (!canDrag || !activeSquare.value) return;
|
|
169
|
+
isDragging.value = true;
|
|
170
|
+
})
|
|
171
|
+
.onUpdate((e) => {
|
|
172
|
+
'worklet';
|
|
173
|
+
if (!canDrag || !isDragging.value) return;
|
|
174
|
+
// Only 2 shared value writes — no JS bridge, no re-renders
|
|
175
|
+
dragX.value = e.x;
|
|
176
|
+
dragY.value = e.y;
|
|
177
|
+
})
|
|
178
|
+
.onEnd((e) => {
|
|
179
|
+
'worklet';
|
|
180
|
+
if (!isDragging.value || !activeSquare.value) return;
|
|
181
|
+
runOnJS(handleEnd)(e.x, e.y);
|
|
182
|
+
})
|
|
183
|
+
.onFinalize(() => {
|
|
184
|
+
'worklet';
|
|
185
|
+
// Safety reset if gesture was interrupted
|
|
186
|
+
isDragging.value = false;
|
|
187
|
+
});
|
|
188
|
+
}, [
|
|
189
|
+
gestureEnabled,
|
|
190
|
+
canDrag,
|
|
191
|
+
handleBegin,
|
|
192
|
+
handleEnd,
|
|
193
|
+
// Shared values are stable refs — listed for exhaustive-deps but don't cause recreations
|
|
194
|
+
activeSquare,
|
|
195
|
+
dragX,
|
|
196
|
+
dragY,
|
|
197
|
+
isDragging,
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
return { gesture, gestureState };
|
|
201
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { useMemo, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
import type { ChessColor, BoardPiece, ParsedPiece } from './types';
|
|
4
|
+
|
|
5
|
+
const FEN_PIECE_MAP: Record<string, { code: string; color: 'w' | 'b' }> = {
|
|
6
|
+
p: { code: 'bp', color: 'b' },
|
|
7
|
+
r: { code: 'br', color: 'b' },
|
|
8
|
+
n: { code: 'bn', color: 'b' },
|
|
9
|
+
b: { code: 'bb', color: 'b' },
|
|
10
|
+
q: { code: 'bq', color: 'b' },
|
|
11
|
+
k: { code: 'bk', color: 'b' },
|
|
12
|
+
P: { code: 'wp', color: 'w' },
|
|
13
|
+
R: { code: 'wr', color: 'w' },
|
|
14
|
+
N: { code: 'wn', color: 'w' },
|
|
15
|
+
B: { code: 'wb', color: 'w' },
|
|
16
|
+
Q: { code: 'wq', color: 'w' },
|
|
17
|
+
K: { code: 'wk', color: 'w' },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const FILES = 'abcdefgh';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse the piece-placement part of a FEN string into an array of pieces.
|
|
24
|
+
* Pure function — no React dependencies, suitable for worklets if needed.
|
|
25
|
+
*/
|
|
26
|
+
function parseFenPieces(fen: string): ParsedPiece[] {
|
|
27
|
+
const placement = fen.split(' ')[0];
|
|
28
|
+
const ranks = placement.split('/');
|
|
29
|
+
const pieces: ParsedPiece[] = [];
|
|
30
|
+
|
|
31
|
+
for (let rankIdx = 0; rankIdx < ranks.length; rankIdx++) {
|
|
32
|
+
const rank = ranks[rankIdx];
|
|
33
|
+
let fileIdx = 0;
|
|
34
|
+
|
|
35
|
+
for (const char of rank) {
|
|
36
|
+
if (char >= '1' && char <= '8') {
|
|
37
|
+
fileIdx += parseInt(char, 10);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const mapping = FEN_PIECE_MAP[char];
|
|
42
|
+
if (mapping) {
|
|
43
|
+
// FEN ranks are from rank 8 (index 0) down to rank 1 (index 7)
|
|
44
|
+
const square = `${FILES[fileIdx]}${8 - rankIdx}`;
|
|
45
|
+
pieces.push({ code: mapping.code, square, color: mapping.color });
|
|
46
|
+
}
|
|
47
|
+
fileIdx++;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return pieces;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Convert square notation to pixel coordinates (top-left corner).
|
|
56
|
+
* Orientation-aware: flips the board when playing as black.
|
|
57
|
+
*/
|
|
58
|
+
export function squareToXY(
|
|
59
|
+
square: string,
|
|
60
|
+
squareSize: number,
|
|
61
|
+
orientation: ChessColor,
|
|
62
|
+
): { x: number; y: number } {
|
|
63
|
+
'worklet';
|
|
64
|
+
const fileIdx = square.charCodeAt(0) - 97; // 'a'=0 .. 'h'=7
|
|
65
|
+
const rankIdx = parseInt(square[1], 10) - 1; // '1'=0 .. '8'=7
|
|
66
|
+
|
|
67
|
+
const col = orientation === 'white' ? fileIdx : 7 - fileIdx;
|
|
68
|
+
const row = orientation === 'white' ? 7 - rankIdx : rankIdx;
|
|
69
|
+
|
|
70
|
+
return { x: col * squareSize, y: row * squareSize };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Convert pixel coordinates to a square notation string.
|
|
75
|
+
* Clamps to board bounds. Orientation-aware.
|
|
76
|
+
*/
|
|
77
|
+
export function xyToSquare(
|
|
78
|
+
x: number,
|
|
79
|
+
y: number,
|
|
80
|
+
squareSize: number,
|
|
81
|
+
orientation: ChessColor,
|
|
82
|
+
): string {
|
|
83
|
+
'worklet';
|
|
84
|
+
const col = Math.max(0, Math.min(7, Math.floor(x / squareSize)));
|
|
85
|
+
const row = Math.max(0, Math.min(7, Math.floor(y / squareSize)));
|
|
86
|
+
|
|
87
|
+
const fileIdx = orientation === 'white' ? col : 7 - col;
|
|
88
|
+
const rankIdx = orientation === 'white' ? 7 - row : row;
|
|
89
|
+
|
|
90
|
+
// String.fromCharCode not available in worklets — use lookup
|
|
91
|
+
const files = 'abcdefgh';
|
|
92
|
+
return `${files[fileIdx]}${rankIdx + 1}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Manages the piece list derived from FEN, with stable IDs for React keys.
|
|
97
|
+
*
|
|
98
|
+
* Stable IDs prevent unmount/remount cycles when pieces change position.
|
|
99
|
+
* A piece keeps its ID as long as it exists on the board — only capture
|
|
100
|
+
* (removal) or promotion (code change) creates a new ID.
|
|
101
|
+
*/
|
|
102
|
+
export function useBoardPieces(fen: string): BoardPiece[] {
|
|
103
|
+
// Track piece-code counters across renders for stable ID assignment
|
|
104
|
+
const idCounterRef = useRef<Record<string, number>>({});
|
|
105
|
+
const prevPiecesRef = useRef<BoardPiece[]>([]);
|
|
106
|
+
|
|
107
|
+
return useMemo(() => {
|
|
108
|
+
const parsed = parseFenPieces(fen);
|
|
109
|
+
const prev = prevPiecesRef.current;
|
|
110
|
+
const prevBySquare = new Map(prev.map((p) => [p.square, p]));
|
|
111
|
+
|
|
112
|
+
// Try to reuse IDs from previous render:
|
|
113
|
+
// 1. Same code on same square -> keep ID (piece didn't move)
|
|
114
|
+
// 2. Same code moved to a new square -> find unmatched previous piece of same code
|
|
115
|
+
const usedPrevIds = new Set<string>();
|
|
116
|
+
const result: BoardPiece[] = [];
|
|
117
|
+
|
|
118
|
+
// First pass: exact square matches (piece stayed or appeared on same square)
|
|
119
|
+
const unmatched: ParsedPiece[] = [];
|
|
120
|
+
for (const p of parsed) {
|
|
121
|
+
const existing = prevBySquare.get(p.square);
|
|
122
|
+
if (existing && existing.code === p.code && !usedPrevIds.has(existing.id)) {
|
|
123
|
+
usedPrevIds.add(existing.id);
|
|
124
|
+
result.push({ ...p, id: existing.id });
|
|
125
|
+
} else {
|
|
126
|
+
unmatched.push(p);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Second pass: match unmatched pieces by code to previous pieces that moved
|
|
131
|
+
for (const p of unmatched) {
|
|
132
|
+
let matchedId: string | null = null;
|
|
133
|
+
|
|
134
|
+
for (const prevPiece of prev) {
|
|
135
|
+
if (
|
|
136
|
+
prevPiece.code === p.code &&
|
|
137
|
+
!usedPrevIds.has(prevPiece.id)
|
|
138
|
+
) {
|
|
139
|
+
matchedId = prevPiece.id;
|
|
140
|
+
usedPrevIds.add(prevPiece.id);
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (matchedId) {
|
|
146
|
+
result.push({ ...p, id: matchedId });
|
|
147
|
+
} else {
|
|
148
|
+
// New piece (promotion, or first render) — assign fresh ID
|
|
149
|
+
const counter = idCounterRef.current;
|
|
150
|
+
counter[p.code] = (counter[p.code] ?? 0) + 1;
|
|
151
|
+
result.push({ ...p, id: `${p.code}-${counter[p.code]}` });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
prevPiecesRef.current = result;
|
|
156
|
+
return result;
|
|
157
|
+
}, [fen]);
|
|
158
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { useRef, useCallback } from 'react';
|
|
2
|
+
import { Chess } from 'chess.js';
|
|
3
|
+
import type { Square } from 'chess.js';
|
|
4
|
+
|
|
5
|
+
import type { ChessColor, BoardPiece, LegalMoveTarget } from './types';
|
|
6
|
+
|
|
7
|
+
type MoveResult = {
|
|
8
|
+
/** Whether the move was applied to the internal chess.js instance */
|
|
9
|
+
applied: boolean;
|
|
10
|
+
/** The new FEN after the move (if applied) */
|
|
11
|
+
fen?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type BoardStateReturn = {
|
|
15
|
+
/** Get legal moves for a piece on the given square */
|
|
16
|
+
getLegalMoves: (square: string) => LegalMoveTarget[];
|
|
17
|
+
/** Check if a given square has a piece belonging to the active player */
|
|
18
|
+
isPlayerPiece: (square: string, pieces: BoardPiece[], player: ChessColor | 'both') => boolean;
|
|
19
|
+
/** Apply a move to the internal chess state. Returns the new FEN if valid. */
|
|
20
|
+
applyMove: (from: string, to: string, promotion?: string) => MoveResult;
|
|
21
|
+
/** Undo the last move on the internal chess state */
|
|
22
|
+
undoMove: () => string | null;
|
|
23
|
+
/** Load a new FEN into the internal chess state */
|
|
24
|
+
loadFen: (fen: string) => void;
|
|
25
|
+
/** Get the current FEN from internal state */
|
|
26
|
+
getFen: () => string;
|
|
27
|
+
/** Get the current turn from internal state */
|
|
28
|
+
getTurn: () => 'w' | 'b';
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Manages the internal chess.js instance for legal move validation.
|
|
33
|
+
*
|
|
34
|
+
* This mirrors the visual board state. When the parent passes a new FEN,
|
|
35
|
+
* the internal chess.js is synced. Legal move queries and move application
|
|
36
|
+
* happen against this instance.
|
|
37
|
+
*
|
|
38
|
+
* The chess.js instance lives in a ref — no React state, no re-renders.
|
|
39
|
+
*/
|
|
40
|
+
export function useBoardState(initialFen: string): BoardStateReturn {
|
|
41
|
+
const chessRef = useRef<Chess>(null!);
|
|
42
|
+
if (!chessRef.current) chessRef.current = new Chess(initialFen);
|
|
43
|
+
|
|
44
|
+
const getLegalMoves = useCallback((square: string): LegalMoveTarget[] => {
|
|
45
|
+
try {
|
|
46
|
+
const moves = chessRef.current.moves({ square: square as Square, verbose: true });
|
|
47
|
+
return moves.map((m) => ({
|
|
48
|
+
square: m.to,
|
|
49
|
+
isCapture: m.captured !== undefined,
|
|
50
|
+
}));
|
|
51
|
+
} catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
const isPlayerPiece = useCallback(
|
|
57
|
+
(square: string, pieces: BoardPiece[], player: ChessColor | 'both'): boolean => {
|
|
58
|
+
const piece = pieces.find((p) => p.square === square);
|
|
59
|
+
if (!piece) return false;
|
|
60
|
+
|
|
61
|
+
if (player === 'both') return true;
|
|
62
|
+
|
|
63
|
+
const pieceColor: ChessColor = piece.color === 'w' ? 'white' : 'black';
|
|
64
|
+
return pieceColor === player;
|
|
65
|
+
},
|
|
66
|
+
[],
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const applyMove = useCallback((from: string, to: string, promotion?: string): MoveResult => {
|
|
70
|
+
try {
|
|
71
|
+
chessRef.current.move({
|
|
72
|
+
from: from as Square,
|
|
73
|
+
to: to as Square,
|
|
74
|
+
promotion: promotion as 'q' | 'r' | 'b' | 'n' | undefined,
|
|
75
|
+
});
|
|
76
|
+
return { applied: true, fen: chessRef.current.fen() };
|
|
77
|
+
} catch {
|
|
78
|
+
return { applied: false };
|
|
79
|
+
}
|
|
80
|
+
}, []);
|
|
81
|
+
|
|
82
|
+
const undoMove = useCallback((): string | null => {
|
|
83
|
+
const result = chessRef.current.undo();
|
|
84
|
+
return result ? chessRef.current.fen() : null;
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
const loadFen = useCallback((fen: string) => {
|
|
88
|
+
chessRef.current.load(fen);
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
const getFen = useCallback(() => chessRef.current.fen(), []);
|
|
92
|
+
|
|
93
|
+
const getTurn = useCallback(() => chessRef.current.turn(), []);
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
getLegalMoves,
|
|
97
|
+
isPlayerPiece,
|
|
98
|
+
applyMove,
|
|
99
|
+
undoMove,
|
|
100
|
+
loadFen,
|
|
101
|
+
getFen,
|
|
102
|
+
getTurn,
|
|
103
|
+
};
|
|
104
|
+
}
|