@mirasen/chessboard 1.0.6 → 1.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/CHANGELOG.md +18 -0
- package/README.md +78 -76
- package/dist/adapters/chessjs/index.d.ts +1 -0
- package/dist/adapters/chessjs/index.js +1 -0
- package/dist/adapters/chessjs/main.d.ts +19 -0
- package/dist/adapters/chessjs/main.js +134 -0
- package/dist/animation/planner.js +28 -28
- package/dist/animation/types.d.ts +6 -3
- package/dist/extensions/first-party/board-events/factory.js +11 -2
- package/dist/extensions/first-party/main-renderer/pieces/update.js +1 -1
- package/dist/state/board/fen/main.d.ts +10 -0
- package/dist/state/board/fen/main.js +94 -0
- package/dist/state/board/position.js +2 -13
- package/dist/state/change/helpers.d.ts +2 -1
- package/dist/state/change/helpers.js +1 -1
- package/package.json +14 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @mirasen/chessboard
|
|
2
2
|
|
|
3
|
+
## 1.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 3956358: Add a stable chess.js adapter with pure move conversion helpers.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- 3956358: Fix lifted-drag animation suppression for compound moves.
|
|
12
|
+
- 3956358: Fix `onUIMove` so it only fires for committed UI moves.
|
|
13
|
+
|
|
14
|
+
## 1.0.7
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- 92c3a69: Fix promotion animation planning when cancelling deferred promotion moves after a previous promotion.
|
|
19
|
+
- 92c3a69: Fix piece suppression updates during deferred move animations.
|
|
20
|
+
|
|
3
21
|
## 1.0.6
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -37,16 +37,23 @@ The default first-party extension baseline includes rendering, events, selection
|
|
|
37
37
|
- `promotion`
|
|
38
38
|
- `autoPromote`
|
|
39
39
|
|
|
40
|
+
The package also includes `chess.js` adapter helpers for connecting legal destinations, UI moves, external moves, promotion, castling, and en passant without writing special-move glue code by hand.
|
|
41
|
+
|
|
40
42
|
That means the board already covers a meaningful baseline chess UX instead of stopping at “draw squares and pieces”.
|
|
41
43
|
|
|
42
44
|
### Built-in chessboard behavior
|
|
43
45
|
|
|
44
|
-
Mirasen Chessboard provides a complete modern interaction and visual baseline out of the box: selection, deselection, reselection, drag-to-move, release targeting, legal destinations, promotion, auto-promotion, and state-diff animation.
|
|
46
|
+
Mirasen Chessboard provides a complete modern interaction and visual baseline out of the box: selection, deselection, reselection, drag-to-move, release targeting, legal destinations, promotion, auto-promotion, and state-diff animation.
|
|
47
|
+
|
|
48
|
+
Moves are resolved on release rather than committed immediately on press, giving users a more forgiving interaction flow. Because animation is driven by board-state transitions, pieces can move, appear, disappear, promote, castle, or return smoothly without app-level rendering glue code.
|
|
49
|
+
|
|
50
|
+
Rules and legality still belong to your game layer. Use the built-in `chess.js` adapter when you want a ready-made bridge from `chess.js` legal moves and move results into the board.
|
|
45
51
|
|
|
46
52
|
## Highlights
|
|
47
53
|
|
|
48
54
|
- Real built-in chess interaction
|
|
49
|
-
- Easy game/rules
|
|
55
|
+
- **Easy game/rules integration** — connect your own game model, or use the built-in `chess.js` adapter
|
|
56
|
+
- Works on desktop and mobile out of the box, with pointer-based mouse and touch interaction
|
|
50
57
|
- State-diff animation beyond simple move events
|
|
51
58
|
- Extension-driven architecture
|
|
52
59
|
- First-party baseline extensions
|
|
@@ -67,8 +74,8 @@ Interactive examples are available on the Mirasen website:
|
|
|
67
74
|
|
|
68
75
|
- [Examples overview](https://mirasen.io/chessboard/examples/)
|
|
69
76
|
- [Minimal interactive example](https://mirasen.io/chessboard/examples/minimal.html)
|
|
70
|
-
- [Legal moves example](https://mirasen.io/chessboard/examples/legal-moves.html)
|
|
71
77
|
- [Promotion example](https://mirasen.io/chessboard/examples/promotion.html)
|
|
78
|
+
- [chess.js game example](https://mirasen.io/chessboard/examples/chessjs.html)
|
|
72
79
|
|
|
73
80
|
## Usage
|
|
74
81
|
|
|
@@ -94,40 +101,25 @@ board.setMovability({
|
|
|
94
101
|
});
|
|
95
102
|
```
|
|
96
103
|
|
|
97
|
-
### Connect to
|
|
104
|
+
### Connect to chess.js
|
|
98
105
|
|
|
99
|
-
`@mirasen/chessboard`
|
|
106
|
+
`@mirasen/chessboard` owns board interaction, rendering, promotion UI, and animation.
|
|
107
|
+
Your game/rules layer owns legality and game state.
|
|
100
108
|
|
|
101
|
-
|
|
109
|
+
For `chess.js`, use the built-in adapter:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
npm install chess.js
|
|
113
|
+
```
|
|
102
114
|
|
|
103
115
|
```ts
|
|
116
|
+
import { createBoard } from '@mirasen/chessboard';
|
|
104
117
|
import {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
} from '
|
|
110
|
-
|
|
111
|
-
type GameMove = {
|
|
112
|
-
from: string;
|
|
113
|
-
to: string;
|
|
114
|
-
capturedSquare?: string;
|
|
115
|
-
secondary?: {
|
|
116
|
-
from: string;
|
|
117
|
-
to: string;
|
|
118
|
-
};
|
|
119
|
-
promotedTo?: 'queen' | 'rook' | 'bishop' | 'knight';
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
type GameDestination = {
|
|
123
|
-
to: string;
|
|
124
|
-
capturedSquare?: string;
|
|
125
|
-
secondary?: {
|
|
126
|
-
from: string;
|
|
127
|
-
to: string;
|
|
128
|
-
};
|
|
129
|
-
promotedTo?: ('queen' | 'rook' | 'bishop' | 'knight')[];
|
|
130
|
-
};
|
|
118
|
+
toBoardMove,
|
|
119
|
+
toBoardMoveDestinations,
|
|
120
|
+
toGameMove
|
|
121
|
+
} from '@mirasen/chessboard/adapters/chessjs';
|
|
122
|
+
import { Chess } from 'chess.js';
|
|
131
123
|
|
|
132
124
|
const element = document.getElementById('board');
|
|
133
125
|
|
|
@@ -135,70 +127,76 @@ if (!element) {
|
|
|
135
127
|
throw new Error('Missing board element');
|
|
136
128
|
}
|
|
137
129
|
|
|
138
|
-
const
|
|
130
|
+
const chess = new Chess();
|
|
139
131
|
const board = createBoard({
|
|
140
132
|
element
|
|
141
133
|
});
|
|
142
134
|
|
|
143
|
-
|
|
135
|
+
// Let the user play white in this example.
|
|
136
|
+
const playerColor = 'w';
|
|
144
137
|
|
|
145
|
-
|
|
146
|
-
return {
|
|
147
|
-
to: gameDestination.to,
|
|
148
|
-
capturedSquare: gameDestination.capturedSquare,
|
|
149
|
-
secondary: gameDestination.secondary, // e.g. castling.
|
|
150
|
-
promotedTo: gameDestination.promotedTo
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function toBoardMoveRequest(gameMove: GameMove): MoveRequestInput {
|
|
155
|
-
return {
|
|
156
|
-
from: gameMove.from,
|
|
157
|
-
to: gameMove.to,
|
|
158
|
-
capturedSquare: gameMove.capturedSquare,
|
|
159
|
-
secondary: gameMove.secondary,
|
|
160
|
-
promotedTo: gameMove.promotedTo
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function toGameMove(move: MoveOutput): GameMove {
|
|
165
|
-
return {
|
|
166
|
-
from: move.from,
|
|
167
|
-
to: move.to,
|
|
168
|
-
capturedSquare: move.capturedSquare,
|
|
169
|
-
secondary: move.secondary,
|
|
170
|
-
promotedTo: move.promotedTo
|
|
171
|
-
};
|
|
172
|
-
}
|
|
138
|
+
board.setPosition(chess.fen());
|
|
173
139
|
|
|
174
140
|
board.setMovability({
|
|
175
141
|
mode: 'strict',
|
|
176
|
-
destinations: (source)
|
|
177
|
-
|
|
142
|
+
destinations: (source) => {
|
|
143
|
+
// During the computer's turn, do not allow user moves.
|
|
144
|
+
if (chess.turn() !== playerColor) {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const moves = chess.moves({ square: source, verbose: true });
|
|
149
|
+
const destinations = toBoardMoveDestinations(moves);
|
|
178
150
|
|
|
179
|
-
return
|
|
151
|
+
return destinations.length > 0 ? destinations : undefined;
|
|
180
152
|
}
|
|
181
153
|
});
|
|
182
154
|
|
|
183
155
|
board.extensions.events.setOnUIMove((move) => {
|
|
184
|
-
// The board accepted and applied this UI move.
|
|
185
|
-
// Keep
|
|
186
|
-
|
|
156
|
+
// The board has already accepted and applied this UI move.
|
|
157
|
+
// Keep chess.js in sync.
|
|
158
|
+
chess.move(toGameMove(move));
|
|
159
|
+
|
|
160
|
+
if (!chess.isGameOver()) {
|
|
161
|
+
makeRandomComputerMove();
|
|
162
|
+
}
|
|
187
163
|
});
|
|
188
164
|
|
|
189
|
-
function
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
165
|
+
function makeRandomComputerMove() {
|
|
166
|
+
const moves = chess.moves({ verbose: true });
|
|
167
|
+
const randomMove = moves[Math.floor(Math.random() * moves.length)];
|
|
168
|
+
|
|
169
|
+
if (!randomMove) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Apply the move to chess.js first.
|
|
174
|
+
const appliedMove = chess.move(randomMove);
|
|
193
175
|
|
|
194
|
-
// Then
|
|
195
|
-
board.move(
|
|
176
|
+
// Then apply and animate the same move on the board.
|
|
177
|
+
board.move(toBoardMove(appliedMove));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function resetGame() {
|
|
181
|
+
chess.reset();
|
|
182
|
+
board.setPosition(chess.fen());
|
|
196
183
|
}
|
|
197
184
|
```
|
|
198
185
|
|
|
199
|
-
|
|
186
|
+
The adapter exposes three small conversion helpers:
|
|
187
|
+
|
|
188
|
+
- `toGameMove` converts a board UI move into a move accepted by `chess.move(...)`.
|
|
189
|
+
- `toBoardMove` converts a `chess.js` move result into a board move request for external or computer moves.
|
|
190
|
+
- `toBoardMoveDestinations` converts verbose legal `chess.js` moves into strict board destinations.
|
|
200
191
|
|
|
201
|
-
This
|
|
192
|
+
This keeps the boundary explicit:
|
|
193
|
+
|
|
194
|
+
- `chess.js` decides which moves are legal.
|
|
195
|
+
- The board handles interaction, legal targets, promotion UI, castling/en-passant board updates, and animation.
|
|
196
|
+
- User moves flow from the board into `chess.js`.
|
|
197
|
+
- External moves flow from `chess.js` back into the board.
|
|
198
|
+
|
|
199
|
+
`toBoardMoveDestinations` preserves special move information such as promotion choices, en passant capture squares, and castling secondary rook movement, so those flows work through the normal board pipeline.
|
|
202
200
|
|
|
203
201
|
### Promotion and auto-promotion
|
|
204
202
|
|
|
@@ -325,3 +323,7 @@ For details, see:
|
|
|
325
323
|
|
|
326
324
|
- [./assets/pieces/chessnut/ATTRIBUTION.md](./assets/pieces/chessnut/ATTRIBUTION.md)
|
|
327
325
|
- [./assets/pieces/chessnut/LICENSE.txt](./assets/pieces/chessnut/LICENSE.txt)
|
|
326
|
+
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { toBoardMove, toBoardMoveDestinations, toGameMove } from './main.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { toBoardMove, toBoardMoveDestinations, toGameMove } from './main.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Chess, Move } from 'chess.js';
|
|
2
|
+
import type { MoveRequestInput } from '../../state/board/types/input.js';
|
|
3
|
+
import type { MoveOutput } from '../../state/board/types/output.js';
|
|
4
|
+
import type { MoveDestinationInput } from '../../state/interaction/types/input.js';
|
|
5
|
+
type ChessJsMoveInput = Exclude<Parameters<Chess['move']>[0], string | null>;
|
|
6
|
+
/**
|
|
7
|
+
* Convert a board MoveOutput into the object notation accepted by chess.move().
|
|
8
|
+
*/
|
|
9
|
+
export declare function toGameMove(move: MoveOutput): ChessJsMoveInput;
|
|
10
|
+
/**
|
|
11
|
+
* Convert a chess.js Move object into MoveRequestInput.
|
|
12
|
+
*/
|
|
13
|
+
export declare function toBoardMove(move: Move): MoveRequestInput;
|
|
14
|
+
/**
|
|
15
|
+
* Convert readonly chess.js verbose moves into readonly MoveDestinationInput[].
|
|
16
|
+
* Merges promotion variants for the same from/to/special-move identity into one destination.
|
|
17
|
+
*/
|
|
18
|
+
export declare function toBoardMoveDestinations(moves: readonly Move[]): MoveDestinationInput[];
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
const BOARD_TO_CHESSJS = {
|
|
2
|
+
Q: 'q',
|
|
3
|
+
R: 'r',
|
|
4
|
+
B: 'b',
|
|
5
|
+
N: 'n'
|
|
6
|
+
};
|
|
7
|
+
const CHESSJS_TO_BOARD = {
|
|
8
|
+
q: 'Q',
|
|
9
|
+
r: 'R',
|
|
10
|
+
b: 'B',
|
|
11
|
+
n: 'N'
|
|
12
|
+
};
|
|
13
|
+
function toBoardPromotion(p) {
|
|
14
|
+
const result = CHESSJS_TO_BOARD[p];
|
|
15
|
+
if (!result) {
|
|
16
|
+
throw new Error(`Unsupported chess.js promotion symbol: "${p}"`);
|
|
17
|
+
}
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
function toChessJsPromotion(p) {
|
|
21
|
+
const result = BOARD_TO_CHESSJS[p];
|
|
22
|
+
if (!result) {
|
|
23
|
+
throw new Error(`Unsupported board promotion symbol: "${p}"`);
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Convert a board MoveOutput into the object notation accepted by chess.move().
|
|
29
|
+
*/
|
|
30
|
+
export function toGameMove(move) {
|
|
31
|
+
const result = {
|
|
32
|
+
from: move.from,
|
|
33
|
+
to: move.to
|
|
34
|
+
};
|
|
35
|
+
if (move.promotedTo) {
|
|
36
|
+
result.promotion = toChessJsPromotion(move.promotedTo);
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Convert a chess.js Move object into MoveRequestInput.
|
|
42
|
+
*/
|
|
43
|
+
export function toBoardMove(move) {
|
|
44
|
+
const result = {
|
|
45
|
+
from: move.from,
|
|
46
|
+
to: move.to
|
|
47
|
+
};
|
|
48
|
+
if (move.isPromotion()) {
|
|
49
|
+
if (!move.promotion) {
|
|
50
|
+
throw new Error('Promotion move missing promotion piece');
|
|
51
|
+
}
|
|
52
|
+
result.promotedTo = toBoardPromotion(move.promotion);
|
|
53
|
+
}
|
|
54
|
+
if (move.isEnPassant()) {
|
|
55
|
+
const file = move.to[0];
|
|
56
|
+
const toRank = parseInt(move.to[1], 10);
|
|
57
|
+
const capturedRank = move.color === 'w' ? toRank - 1 : toRank + 1;
|
|
58
|
+
result.capturedSquare = `${file}${capturedRank}`;
|
|
59
|
+
}
|
|
60
|
+
if (move.isKingsideCastle()) {
|
|
61
|
+
if (move.from === 'e1' && move.to === 'g1') {
|
|
62
|
+
result.secondary = { from: 'h1', to: 'f1' };
|
|
63
|
+
}
|
|
64
|
+
else if (move.from === 'e8' && move.to === 'g8') {
|
|
65
|
+
result.secondary = { from: 'h8', to: 'f8' };
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
throw new Error(`Unsupported chess.js kingside castling move: ${move.from}-${move.to}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else if (move.isQueensideCastle()) {
|
|
72
|
+
if (move.from === 'e1' && move.to === 'c1') {
|
|
73
|
+
result.secondary = { from: 'a1', to: 'd1' };
|
|
74
|
+
}
|
|
75
|
+
else if (move.from === 'e8' && move.to === 'c8') {
|
|
76
|
+
result.secondary = { from: 'a8', to: 'd8' };
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
throw new Error(`Unsupported chess.js queenside castling move: ${move.from}-${move.to}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Convert readonly chess.js verbose moves into readonly MoveDestinationInput[].
|
|
86
|
+
* Merges promotion variants for the same from/to/special-move identity into one destination.
|
|
87
|
+
*/
|
|
88
|
+
export function toBoardMoveDestinations(moves) {
|
|
89
|
+
if (moves.length === 0)
|
|
90
|
+
return [];
|
|
91
|
+
const destinations = [];
|
|
92
|
+
const keyToIndex = new Map();
|
|
93
|
+
for (const move of moves) {
|
|
94
|
+
const boardMove = toBoardMove(move);
|
|
95
|
+
// Build identity key: from + to + capturedSquare + secondary
|
|
96
|
+
const key = [
|
|
97
|
+
boardMove.from,
|
|
98
|
+
boardMove.to,
|
|
99
|
+
boardMove.capturedSquare ?? '',
|
|
100
|
+
boardMove.secondary ? `${boardMove.secondary.from}-${boardMove.secondary.to}` : ''
|
|
101
|
+
].join('|');
|
|
102
|
+
const existingIdx = keyToIndex.get(key);
|
|
103
|
+
if (existingIdx !== undefined) {
|
|
104
|
+
// Merge promotion into existing destination
|
|
105
|
+
const existing = destinations[existingIdx];
|
|
106
|
+
if (boardMove.promotedTo) {
|
|
107
|
+
if (!existing.promotedTo) {
|
|
108
|
+
existing.promotedTo = [];
|
|
109
|
+
}
|
|
110
|
+
if (!existing.promotedTo.includes(boardMove.promotedTo)) {
|
|
111
|
+
existing.promotedTo.push(boardMove.promotedTo);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
// Create new destination
|
|
117
|
+
const dest = {
|
|
118
|
+
to: boardMove.to
|
|
119
|
+
};
|
|
120
|
+
if (boardMove.capturedSquare) {
|
|
121
|
+
dest.capturedSquare = boardMove.capturedSquare;
|
|
122
|
+
}
|
|
123
|
+
if (boardMove.secondary) {
|
|
124
|
+
dest.secondary = boardMove.secondary;
|
|
125
|
+
}
|
|
126
|
+
if (boardMove.promotedTo) {
|
|
127
|
+
dest.promotedTo = [boardMove.promotedTo];
|
|
128
|
+
}
|
|
129
|
+
keyToIndex.set(key, destinations.length);
|
|
130
|
+
destinations.push(dest);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return destinations;
|
|
134
|
+
}
|
|
@@ -2,6 +2,7 @@ import assert from '@ktarmyshov/assert';
|
|
|
2
2
|
import { isNonEmptyPieceCode } from '../state/board/check.js';
|
|
3
3
|
import { fileOf, rankOf } from '../state/board/coords.js';
|
|
4
4
|
import { SQUARE_COUNT } from '../state/board/types/internal.js';
|
|
5
|
+
import { baseMovesEqual } from '../state/change/helpers.js';
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
6
7
|
// Precomputed squared distance: (dFile² + dRank²) for all square pairs.
|
|
7
8
|
// Max value = 7² + 7² = 98, fits in Uint8. Index: a * 64 + b.
|
|
@@ -55,7 +56,7 @@ function buildExcludeForDrop(lastMove) {
|
|
|
55
56
|
function buildEffectiveAnimationPlanningSnapshot(state) {
|
|
56
57
|
const request = state.change.deferredUIMoveRequest;
|
|
57
58
|
if (request === null)
|
|
58
|
-
return state; // nothing to do
|
|
59
|
+
return { ...state, lastMoveSource: 'state' }; // nothing to do
|
|
59
60
|
const newPieces = new Uint8Array(state.board.pieces);
|
|
60
61
|
const from = request.sourceSquare;
|
|
61
62
|
const to = request.destination.to;
|
|
@@ -73,40 +74,41 @@ function buildEffectiveAnimationPlanningSnapshot(state) {
|
|
|
73
74
|
piece: pieceCode
|
|
74
75
|
};
|
|
75
76
|
const newChange = {
|
|
77
|
+
...state.change,
|
|
76
78
|
deferredUIMoveRequest: null,
|
|
77
79
|
lastMove: newLastMove
|
|
78
80
|
};
|
|
79
81
|
return {
|
|
80
82
|
...state,
|
|
81
83
|
board: newBoard,
|
|
82
|
-
change: newChange
|
|
84
|
+
change: newChange,
|
|
85
|
+
lastMoveSource: 'projected-deferred-ui-move'
|
|
83
86
|
};
|
|
84
87
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
* piece (e.g. queen) on the target square. For animation planning we
|
|
89
|
-
* temporarily replace it with the original piece (pawn) so that the greedy
|
|
90
|
-
* matching can build a proper move track from→to instead of separate fades.
|
|
91
|
-
*
|
|
92
|
-
* After the animation finishes the promoted piece will "snap" into view on
|
|
93
|
-
* the next render frame.
|
|
94
|
-
*/
|
|
95
|
-
function buildPromotionEffectiveCurrent(state) {
|
|
96
|
-
const lastMove = state.change.lastMove;
|
|
97
|
-
if (state.change.deferredUIMoveRequest !== null ||
|
|
88
|
+
function normalizePromotionEffectiveCurrent(previous, current) {
|
|
89
|
+
const lastMove = current.change.lastMove;
|
|
90
|
+
if (current.change.deferredUIMoveRequest !== null ||
|
|
98
91
|
lastMove === null ||
|
|
99
92
|
lastMove.promotedTo === undefined) {
|
|
100
|
-
return
|
|
93
|
+
return current;
|
|
101
94
|
}
|
|
102
|
-
|
|
95
|
+
if (previous.lastMoveSource === 'projected-deferred-ui-move') {
|
|
96
|
+
const previousLastMove = previous.change.lastMove;
|
|
97
|
+
if (previousLastMove === null || !baseMovesEqual(previousLastMove, lastMove)) {
|
|
98
|
+
return current;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
else if (previous.board.pieces[lastMove.from] !== lastMove.piece) {
|
|
102
|
+
return current;
|
|
103
|
+
}
|
|
104
|
+
const newPieces = new Uint8Array(current.board.pieces);
|
|
103
105
|
newPieces[lastMove.to] = lastMove.piece;
|
|
104
106
|
const newBoard = {
|
|
105
|
-
...
|
|
107
|
+
...current.board,
|
|
106
108
|
pieces: newPieces
|
|
107
109
|
};
|
|
108
110
|
return {
|
|
109
|
-
...
|
|
111
|
+
...current,
|
|
110
112
|
board: newBoard
|
|
111
113
|
};
|
|
112
114
|
}
|
|
@@ -231,17 +233,15 @@ export function calculateAnimationPlan(input) {
|
|
|
231
233
|
// 2) Detect lifted-piece drop and prepare exclude spec
|
|
232
234
|
const droppedMove = detectLiftedPieceDrop(effectivePrevious, effectiveCurrent);
|
|
233
235
|
const exclude = droppedMove !== null ? buildExcludeForDrop(droppedMove) : EMPTY_EXCLUDE;
|
|
234
|
-
// 3) Normalize current
|
|
235
|
-
const planningCurrent =
|
|
236
|
+
// 3) Normalize current promoted piece when the promoted move belongs to this transition
|
|
237
|
+
const planningCurrent = normalizePromotionEffectiveCurrent(effectivePrevious, effectiveCurrent);
|
|
236
238
|
// 4) Calculate tracks
|
|
237
239
|
const tracks = calculateTracks(effectivePrevious.board, planningCurrent.board, exclude);
|
|
238
240
|
// 5) Build suppressedSquares: track-derived + excluded drop endpoints
|
|
239
241
|
const suppressed = collectSuppressedSquaresFromTracks(tracks);
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
return { tracks, suppressedSquares: suppressed };
|
|
242
|
+
const result = {
|
|
243
|
+
tracks,
|
|
244
|
+
suppressedSquares: suppressed
|
|
245
|
+
};
|
|
246
|
+
return result;
|
|
247
247
|
}
|
|
@@ -30,14 +30,17 @@ export interface AnimationSession {
|
|
|
30
30
|
duration: number;
|
|
31
31
|
}
|
|
32
32
|
export type AnimationSessionSnapshot = ReadonlyDeep<AnimationSession>;
|
|
33
|
-
export interface
|
|
33
|
+
export interface AnimationPlanningInputSnapshot {
|
|
34
34
|
readonly board: BoardStateSnapshot;
|
|
35
35
|
readonly change: ChangeStateSnapshot;
|
|
36
36
|
readonly interaction: InteractionStateSnapshot;
|
|
37
37
|
}
|
|
38
|
+
export interface AnimationPlanningSnapshot extends AnimationPlanningInputSnapshot {
|
|
39
|
+
readonly lastMoveSource: 'state' | 'projected-deferred-ui-move';
|
|
40
|
+
}
|
|
38
41
|
export interface AnimationPlanningInput {
|
|
39
|
-
previous:
|
|
40
|
-
current:
|
|
42
|
+
previous: AnimationPlanningInputSnapshot;
|
|
43
|
+
current: AnimationPlanningInputSnapshot;
|
|
41
44
|
}
|
|
42
45
|
export interface AnimationPlan {
|
|
43
46
|
readonly tracks: readonly AnimationTrack[];
|
|
@@ -40,8 +40,17 @@ function createBoardEventsInstance() {
|
|
|
40
40
|
currentFrame: context.currentFrame
|
|
41
41
|
});
|
|
42
42
|
}
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
const lastMoveChanged = context.mutation.hasMutation({
|
|
44
|
+
causes: ['state.change.setLastMove']
|
|
45
|
+
});
|
|
46
|
+
const interactionCompleted = context.mutation.hasMutation({
|
|
47
|
+
causes: [
|
|
48
|
+
'runtime.interaction.completeCoreDragTo',
|
|
49
|
+
'runtime.interaction.completeExtensionDragTo',
|
|
50
|
+
'runtime.interaction.resolveDeferredUIMoveRequest'
|
|
51
|
+
]
|
|
52
|
+
});
|
|
53
|
+
if (interactionCompleted && lastMoveChanged && context.currentFrame.state.change.lastMove) {
|
|
45
54
|
internalState.onUIMove?.(denormalizeMove(context.currentFrame.state.change.lastMove));
|
|
46
55
|
}
|
|
47
56
|
},
|
|
@@ -6,7 +6,7 @@ import { calculateSuppressedSquares } from './suppress.js';
|
|
|
6
6
|
export function rendererPiecesOnUpdate(state, context, animationSuppressedSquares) {
|
|
7
7
|
if (!isUpdateContextRenderable(context) ||
|
|
8
8
|
!context.mutation.hasMutation({
|
|
9
|
-
prefixes: ['state.
|
|
9
|
+
prefixes: ['state.'],
|
|
10
10
|
causes: ['layout.refreshGeometry']
|
|
11
11
|
})) {
|
|
12
12
|
return;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { FenString } from '../types/input.js';
|
|
2
|
+
import { ParsedPosition } from '../types/internal.js';
|
|
3
|
+
/**
|
|
4
|
+
* Parse a FEN string into a {@link ParsedPosition}.
|
|
5
|
+
*
|
|
6
|
+
* Only the first two FEN fields (piece placement + active color) are required
|
|
7
|
+
* for a board-only representation. The remaining fields (castling, en-passant,
|
|
8
|
+
* half-move clock, full-move number) are accepted but ignored.
|
|
9
|
+
*/
|
|
10
|
+
export declare function parseFen(fen: FenString): ParsedPosition;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { SQUARE_COUNT } from '../types/internal.js';
|
|
2
|
+
/**
|
|
3
|
+
* Direct FEN character → PieceCode lookup.
|
|
4
|
+
* Uppercase = White, lowercase = Black.
|
|
5
|
+
*/
|
|
6
|
+
const FEN_PIECE_MAP = {
|
|
7
|
+
P: 1 /* PieceCode.WhitePawn */,
|
|
8
|
+
N: 2 /* PieceCode.WhiteKnight */,
|
|
9
|
+
B: 3 /* PieceCode.WhiteBishop */,
|
|
10
|
+
R: 4 /* PieceCode.WhiteRook */,
|
|
11
|
+
Q: 5 /* PieceCode.WhiteQueen */,
|
|
12
|
+
K: 6 /* PieceCode.WhiteKing */,
|
|
13
|
+
p: 9 /* PieceCode.BlackPawn */,
|
|
14
|
+
n: 10 /* PieceCode.BlackKnight */,
|
|
15
|
+
b: 11 /* PieceCode.BlackBishop */,
|
|
16
|
+
r: 12 /* PieceCode.BlackRook */,
|
|
17
|
+
q: 13 /* PieceCode.BlackQueen */,
|
|
18
|
+
k: 14 /* PieceCode.BlackKing */
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Parse the piece-placement section of a FEN string into a Uint8Array[64].
|
|
22
|
+
*
|
|
23
|
+
* FEN ranks are ordered 8→1 (top to bottom), but our board indexing is
|
|
24
|
+
* a1=0, b1=1, ..., h1=7, a2=8, ..., h8=63.
|
|
25
|
+
* So rank 8 maps to indices 56–63 and rank 1 to indices 0–7.
|
|
26
|
+
*/
|
|
27
|
+
function parsePiecePlacement(placement) {
|
|
28
|
+
const pieces = new Uint8Array(SQUARE_COUNT); // initialized to 0 (PieceCode.Empty)
|
|
29
|
+
const ranks = placement.split('/');
|
|
30
|
+
if (ranks.length !== 8) {
|
|
31
|
+
throw new Error(`Invalid FEN piece placement: expected 8 ranks separated by '/', got ${ranks.length}`);
|
|
32
|
+
}
|
|
33
|
+
for (let rankIdx = 0; rankIdx < 8; rankIdx++) {
|
|
34
|
+
// FEN rank index 0 = rank 8 (board row 7), index 7 = rank 1 (board row 0)
|
|
35
|
+
const boardRow = 7 - rankIdx;
|
|
36
|
+
const baseSquare = boardRow * 8;
|
|
37
|
+
const rankStr = ranks[rankIdx];
|
|
38
|
+
let file = 0;
|
|
39
|
+
for (let i = 0; i < rankStr.length; i++) {
|
|
40
|
+
const ch = rankStr[i];
|
|
41
|
+
if (ch >= '1' && ch <= '8') {
|
|
42
|
+
// Skip empty squares
|
|
43
|
+
file += ch.charCodeAt(0) - '0'.charCodeAt(0);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
const piece = FEN_PIECE_MAP[ch];
|
|
47
|
+
if (piece === undefined) {
|
|
48
|
+
throw new Error(`Invalid FEN piece character: '${ch}'`);
|
|
49
|
+
}
|
|
50
|
+
if (file >= 8) {
|
|
51
|
+
throw new Error(`Invalid FEN piece placement: too many squares in rank ${8 - rankIdx}`);
|
|
52
|
+
}
|
|
53
|
+
pieces[baseSquare + file] = piece;
|
|
54
|
+
file++;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (file !== 8) {
|
|
58
|
+
throw new Error(`Invalid FEN piece placement: rank ${8 - rankIdx} has ${file} squares instead of 8`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return pieces;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Parse the active-color field of a FEN string.
|
|
65
|
+
*/
|
|
66
|
+
function parseTurn(turn) {
|
|
67
|
+
switch (turn) {
|
|
68
|
+
case 'w':
|
|
69
|
+
return 0 /* ColorCode.White */;
|
|
70
|
+
case 'b':
|
|
71
|
+
return 8 /* ColorCode.Black */;
|
|
72
|
+
default:
|
|
73
|
+
throw new Error(`Invalid FEN turn field: expected 'w' or 'b', got '${turn}'`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Parse a FEN string into a {@link ParsedPosition}.
|
|
78
|
+
*
|
|
79
|
+
* Only the first two FEN fields (piece placement + active color) are required
|
|
80
|
+
* for a board-only representation. The remaining fields (castling, en-passant,
|
|
81
|
+
* half-move clock, full-move number) are accepted but ignored.
|
|
82
|
+
*/
|
|
83
|
+
export function parseFen(fen) {
|
|
84
|
+
const parts = fen
|
|
85
|
+
.replace(/^\uFEFF/, '')
|
|
86
|
+
.trim()
|
|
87
|
+
.split(/\s+/);
|
|
88
|
+
if (parts.length < 2) {
|
|
89
|
+
throw new Error(`Invalid FEN string: expected at least 2 space-separated fields, got ${parts.length}`);
|
|
90
|
+
}
|
|
91
|
+
const pieces = parsePiecePlacement(parts[0]);
|
|
92
|
+
const turn = parseTurn(parts[1]);
|
|
93
|
+
return { pieces, turn };
|
|
94
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseFen } from './fen/main.js';
|
|
2
2
|
import { normalizeColor, normalizePiece, normalizeSquare } from './normalize.js';
|
|
3
3
|
import { SQUARE_COUNT } from './types/internal.js';
|
|
4
4
|
const START_POSITION_RECORD_STRING = {
|
|
@@ -63,18 +63,7 @@ function isPositionInputObject(input) {
|
|
|
63
63
|
return typeof input === 'object' && input !== null && !Array.isArray(input);
|
|
64
64
|
}
|
|
65
65
|
function boardParsePositionInputFen(input) {
|
|
66
|
-
|
|
67
|
-
if (!position) {
|
|
68
|
-
throw new Error('Failed to parse FEN string');
|
|
69
|
-
}
|
|
70
|
-
const piecePositionRecord = {};
|
|
71
|
-
for (const [square, piece] of position.board) {
|
|
72
|
-
piecePositionRecord[square] = { role: piece.type, color: piece.color };
|
|
73
|
-
}
|
|
74
|
-
return {
|
|
75
|
-
pieces: boardParsePositionInputRecord(piecePositionRecord),
|
|
76
|
-
turn: normalizeColor(position.turn)
|
|
77
|
-
};
|
|
66
|
+
return parseFen(input);
|
|
78
67
|
}
|
|
79
68
|
export function boardParsePosition(input) {
|
|
80
69
|
if (input === 'start') {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { MoveRequest, MoveRequestBase, MoveSnapshot } from '../board/types/internal.js';
|
|
1
|
+
import type { MoveBaseSnapshot, MoveRequest, MoveRequestBase, MoveSnapshot } from '../board/types/internal.js';
|
|
2
2
|
import { MoveDestinationSnapshot } from '../interaction/types/internal.js';
|
|
3
3
|
import { ChangeStateSnapshot } from './types/main.js';
|
|
4
4
|
import { PendingUIMoveRequestSnapshot } from './types/ui-move.js';
|
|
5
|
+
export declare function baseMovesEqual(moveA: MoveBaseSnapshot, moveB: MoveBaseSnapshot): boolean;
|
|
5
6
|
export declare function movesEqual(moveA: MoveSnapshot | null, moveB: MoveSnapshot | null): boolean;
|
|
6
7
|
export declare function moveRequestBasesEqual(requestA: MoveRequestBase | null, requestB: MoveRequestBase | null): boolean;
|
|
7
8
|
export declare function moveRequestsEqual(requestA: MoveRequest | null, requestB: MoveRequest | null): boolean;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mirasen/chessboard",
|
|
3
3
|
"description": "Framework-agnostic TypeScript chessboard platform with real chess interaction built in.",
|
|
4
|
-
"version": "1.0
|
|
4
|
+
"version": "1.1.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Mirasen",
|
|
@@ -42,6 +42,10 @@
|
|
|
42
42
|
"types": "./dist/extensions/build/index.d.ts",
|
|
43
43
|
"import": "./dist/extensions/build/index.js"
|
|
44
44
|
},
|
|
45
|
+
"./adapters/chessjs": {
|
|
46
|
+
"types": "./dist/adapters/chessjs/index.d.ts",
|
|
47
|
+
"import": "./dist/adapters/chessjs/index.js"
|
|
48
|
+
},
|
|
45
49
|
"./unstable/*": {
|
|
46
50
|
"types": "./dist/*.d.ts",
|
|
47
51
|
"import": "./dist/*.js"
|
|
@@ -79,6 +83,7 @@
|
|
|
79
83
|
"@eslint/js": "^10.0.1",
|
|
80
84
|
"@types/node": "^25.5.0",
|
|
81
85
|
"@vitest/coverage-istanbul": "^4.1.0",
|
|
86
|
+
"chess.js": "^1.4.0",
|
|
82
87
|
"eslint": "^10.0.2",
|
|
83
88
|
"eslint-config-prettier": "^10.1.2",
|
|
84
89
|
"globals": "^17.4.0",
|
|
@@ -96,9 +101,16 @@
|
|
|
96
101
|
"node": ">=20"
|
|
97
102
|
},
|
|
98
103
|
"dependencies": {
|
|
99
|
-
"@echecs/fen": "^2.1.0",
|
|
100
104
|
"@ktarmyshov/assert": "^0.1.9",
|
|
101
105
|
"es-toolkit": "^1.45.1",
|
|
102
106
|
"type-fest": "^5.5.0"
|
|
107
|
+
},
|
|
108
|
+
"peerDependencies": {
|
|
109
|
+
"chess.js": "^1.4.0"
|
|
110
|
+
},
|
|
111
|
+
"peerDependenciesMeta": {
|
|
112
|
+
"chess.js": {
|
|
113
|
+
"optional": true
|
|
114
|
+
}
|
|
103
115
|
}
|
|
104
116
|
}
|