@dxos/react-ui-gameboard 0.8.3 → 0.8.4-main.1da679c
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/dist/lib/browser/index.mjs +522 -435
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +522 -435
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/types/src/components/Chessboard/Chessboard.d.ts +20 -0
- package/dist/types/src/components/Chessboard/Chessboard.d.ts.map +1 -0
- package/dist/types/src/components/Chessboard/Chessboard.stories.d.ts +31 -0
- package/dist/types/src/components/Chessboard/Chessboard.stories.d.ts.map +1 -0
- package/dist/types/src/components/Chessboard/chess.d.ts +59 -0
- package/dist/types/src/components/Chessboard/chess.d.ts.map +1 -0
- package/dist/types/src/components/Chessboard/chess.test.d.ts +2 -0
- package/dist/types/src/components/Chessboard/chess.test.d.ts.map +1 -0
- package/dist/types/src/components/Chessboard/index.d.ts.map +1 -0
- package/dist/types/src/components/Gameboard/Gameboard.d.ts +38 -0
- package/dist/types/src/components/Gameboard/Gameboard.d.ts.map +1 -0
- package/dist/types/src/{Board → components/Gameboard}/Piece.d.ts +3 -2
- package/dist/types/src/components/Gameboard/Piece.d.ts.map +1 -0
- package/dist/types/src/components/Gameboard/Square.d.ts.map +1 -0
- package/dist/types/src/components/Gameboard/index.d.ts +4 -0
- package/dist/types/src/components/Gameboard/index.d.ts.map +1 -0
- package/dist/types/src/{Board → components/Gameboard}/types.d.ts +3 -1
- package/dist/types/src/components/Gameboard/types.d.ts.map +1 -0
- package/dist/types/src/components/Gameboard/util.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +3 -0
- package/dist/types/src/components/index.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +1 -2
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +20 -15
- package/src/{Chessboard → components/Chessboard}/Chessboard.stories.tsx +30 -27
- package/src/components/Chessboard/Chessboard.tsx +199 -0
- package/src/components/Chessboard/chess.test.ts +19 -0
- package/src/components/Chessboard/chess.ts +322 -0
- package/src/components/Gameboard/Gameboard.tsx +140 -0
- package/src/{Board → components/Gameboard}/Piece.tsx +25 -22
- package/src/{Board → components/Gameboard}/Square.tsx +4 -4
- package/src/components/Gameboard/index.ts +8 -0
- package/src/{Board → components/Gameboard}/types.ts +4 -1
- package/src/components/index.ts +6 -0
- package/src/index.ts +1 -2
- package/dist/lib/node/index.cjs +0 -1039
- package/dist/lib/node/index.cjs.map +0 -7
- package/dist/lib/node/meta.json +0 -1
- package/dist/types/src/Board/Board.d.ts +0 -15
- package/dist/types/src/Board/Board.d.ts.map +0 -1
- package/dist/types/src/Board/Container.d.ts +0 -14
- package/dist/types/src/Board/Container.d.ts.map +0 -1
- package/dist/types/src/Board/Piece.d.ts.map +0 -1
- package/dist/types/src/Board/Square.d.ts.map +0 -1
- package/dist/types/src/Board/context.d.ts +0 -10
- package/dist/types/src/Board/context.d.ts.map +0 -1
- package/dist/types/src/Board/index.d.ts +0 -8
- package/dist/types/src/Board/index.d.ts.map +0 -1
- package/dist/types/src/Board/types.d.ts.map +0 -1
- package/dist/types/src/Board/util.d.ts.map +0 -1
- package/dist/types/src/Chessboard/Chessboard.d.ts +0 -14
- package/dist/types/src/Chessboard/Chessboard.d.ts.map +0 -1
- package/dist/types/src/Chessboard/Chessboard.stories.d.ts +0 -16
- package/dist/types/src/Chessboard/Chessboard.stories.d.ts.map +0 -1
- package/dist/types/src/Chessboard/chess.d.ts +0 -40
- package/dist/types/src/Chessboard/chess.d.ts.map +0 -1
- package/dist/types/src/Chessboard/index.d.ts.map +0 -1
- package/src/Board/Board.tsx +0 -86
- package/src/Board/Container.tsx +0 -25
- package/src/Board/context.ts +0 -22
- package/src/Board/index.ts +0 -12
- package/src/Chessboard/Chessboard.tsx +0 -190
- package/src/Chessboard/chess.ts +0 -213
- /package/dist/types/src/{Chessboard → components/Chessboard}/index.d.ts +0 -0
- /package/dist/types/src/{Board → components/Gameboard}/Square.d.ts +0 -0
- /package/dist/types/src/{Board → components/Gameboard}/util.d.ts +0 -0
- /package/src/{Chessboard → components/Chessboard}/index.ts +0 -0
- /package/src/{Board → components/Gameboard}/util.ts +0 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type ReadonlySignal, signal } from '@preact/signals-core';
|
|
6
|
+
import { Chess as ChessJS } from 'chess.js';
|
|
7
|
+
import { type FC, type SVGProps } from 'react';
|
|
8
|
+
|
|
9
|
+
import { invariant } from '@dxos/invariant';
|
|
10
|
+
import { log } from '@dxos/log';
|
|
11
|
+
|
|
12
|
+
import * as Alpha from '../../gen/pieces/chess/alpha';
|
|
13
|
+
import {
|
|
14
|
+
type GameboardModel,
|
|
15
|
+
type Location,
|
|
16
|
+
type Move,
|
|
17
|
+
type PieceMap,
|
|
18
|
+
type PieceType,
|
|
19
|
+
type Player,
|
|
20
|
+
locationToString,
|
|
21
|
+
} from '../Gameboard';
|
|
22
|
+
|
|
23
|
+
export type ChessPiece = 'BK' | 'BQ' | 'BR' | 'BB' | 'BN' | 'BP' | 'WK' | 'WQ' | 'WR' | 'WB' | 'WN' | 'WP';
|
|
24
|
+
|
|
25
|
+
export const ChessPieces: Record<ChessPiece, FC<SVGProps<SVGSVGElement>>> = Alpha;
|
|
26
|
+
|
|
27
|
+
export const posToLocation = (pos: string): Location => {
|
|
28
|
+
const col = pos.charCodeAt(0) - 'a'.charCodeAt(0);
|
|
29
|
+
const row = parseInt(pos[1]) - 1;
|
|
30
|
+
return [row, col];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const locationToPos = ([row, col]: Location): string => {
|
|
34
|
+
return String.fromCharCode(col + 'a'.charCodeAt(0)) + (row + 1);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const getRawPgn = (pgn: string) => {
|
|
38
|
+
return pgn.replace(/\[.*?\]/g, '').trim();
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const styles = {
|
|
42
|
+
neutral: {
|
|
43
|
+
black: 'bg-neutral-50',
|
|
44
|
+
white: 'bg-neutral-200',
|
|
45
|
+
promotion: 'bg-neutral-200 hover:bg-neutral-300 opacity-70 hover:opacity-100',
|
|
46
|
+
},
|
|
47
|
+
original: {
|
|
48
|
+
black: 'bg-[#6C95B9]',
|
|
49
|
+
white: 'bg-[#CCD3DB]',
|
|
50
|
+
promotion: 'duration-500 bg-[#CCD3DB] opacity-70 hover:opacity-100',
|
|
51
|
+
},
|
|
52
|
+
blue: {
|
|
53
|
+
black: 'bg-[#608BC1]',
|
|
54
|
+
white: 'bg-[#CBDCEB]',
|
|
55
|
+
promotion: 'duration-500 bg-[#CBDCEB] opacity-70 hover:opacity-100',
|
|
56
|
+
},
|
|
57
|
+
green: {
|
|
58
|
+
black: 'bg-[#8EB486]',
|
|
59
|
+
white: 'bg-[#FDF7F4]',
|
|
60
|
+
promotion: 'duration-500 bg-[#FDF7F4] opacity-70 hover:opacity-100',
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const boardStyles = styles.original;
|
|
65
|
+
|
|
66
|
+
export const getSquareColor = ([row, col]: Location) => {
|
|
67
|
+
return (col + row) % 2 === 0 ? boardStyles.black : boardStyles.white;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const createChess = (pgn?: string): ChessJS => {
|
|
71
|
+
const chess = new ChessJS();
|
|
72
|
+
if (pgn) {
|
|
73
|
+
try {
|
|
74
|
+
chess.loadPgn(pgn);
|
|
75
|
+
} catch {
|
|
76
|
+
log.warn(pgn);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return chess;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Chess model.
|
|
85
|
+
*/
|
|
86
|
+
export class ChessModel implements GameboardModel<ChessPiece> {
|
|
87
|
+
private readonly _chess = new ChessJS();
|
|
88
|
+
private readonly _pieces = signal<PieceMap<ChessPiece>>({});
|
|
89
|
+
private readonly _moveIndex = signal(0);
|
|
90
|
+
|
|
91
|
+
constructor(pgn?: string) {
|
|
92
|
+
this.update(pgn);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
get readonly(): boolean {
|
|
96
|
+
return this._moveIndex.value !== this._chess.history().length;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
get turn(): Player {
|
|
100
|
+
return this._chess.turn() === 'w' ? 'white' : 'black';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
get game(): ChessJS {
|
|
104
|
+
return this._chess;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
get pieces(): ReadonlySignal<PieceMap<ChessPiece>> {
|
|
108
|
+
return this._pieces;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
get moveIndex(): ReadonlySignal<number> {
|
|
112
|
+
return this._moveIndex;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
get fen() {
|
|
116
|
+
return this._chess.fen();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* PGN with headers.
|
|
121
|
+
*
|
|
122
|
+
* [Event "?"]
|
|
123
|
+
* [Site "?"]
|
|
124
|
+
* [Date "2025.08.05"]
|
|
125
|
+
* [Round "?"]
|
|
126
|
+
* [White "?"]
|
|
127
|
+
* [Black "?"]
|
|
128
|
+
* [Result "*"]
|
|
129
|
+
*/
|
|
130
|
+
// TODO(burdon): Update headers.
|
|
131
|
+
get pgn(): string {
|
|
132
|
+
return getRawPgn(this._chess.pgn());
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
setMoveIndex(index: number) {
|
|
136
|
+
const temp = new ChessJS();
|
|
137
|
+
const history = this._chess.history({ verbose: true });
|
|
138
|
+
for (let i = 0; i < index && i < history.length; i++) {
|
|
139
|
+
temp.move(history[i]);
|
|
140
|
+
}
|
|
141
|
+
this._updateBoard(temp);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
update(pgn = ''): void {
|
|
145
|
+
const previous = this._chess.history();
|
|
146
|
+
try {
|
|
147
|
+
this._chess.loadPgn(pgn);
|
|
148
|
+
// TODO(burdon): Get from TS.
|
|
149
|
+
// TODO(burdon): Update if not set.
|
|
150
|
+
this._chess.setHeader('Date', createDate());
|
|
151
|
+
this._chess.setHeader('Site', 'dxos.org');
|
|
152
|
+
// TODO(burdon): Update player keys.
|
|
153
|
+
// this._chess.setHeader('White', 'White');
|
|
154
|
+
// this._chess.setHeader('Black', 'Black');
|
|
155
|
+
} catch {
|
|
156
|
+
// Ignore.
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const current = this._chess.history();
|
|
160
|
+
if (!isValidNextMove(previous, current)) {
|
|
161
|
+
this._pieces.value = {};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
this._updateBoard(this._chess);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
isValidMove(move: Move): boolean {
|
|
168
|
+
return tryMove(new ChessJS(this._chess.fen()), move) !== null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
canPromote(move: Move): boolean {
|
|
172
|
+
const isPawnMove = move.piece === 'BP' || move.piece === 'WP';
|
|
173
|
+
const isToLastRank = move.to[0] === 0 || move.to[0] === 7;
|
|
174
|
+
return isPawnMove && isToLastRank;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
makeMove(move: Move): boolean {
|
|
178
|
+
const game = tryMove(this._chess, move);
|
|
179
|
+
if (!game) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
this._updateBoard(this._chess);
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
makeRandomMove(): boolean {
|
|
188
|
+
const moves = this._chess.moves();
|
|
189
|
+
if (!moves.length) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const move = moves[Math.floor(Math.random() * moves.length)];
|
|
194
|
+
this._chess.move(move);
|
|
195
|
+
|
|
196
|
+
this._updateBoard(this._chess);
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Update pieces preserving identity.
|
|
202
|
+
*/
|
|
203
|
+
private _updateBoard(chess: ChessJS): void {
|
|
204
|
+
this._pieces.value = createPieceMap(chess);
|
|
205
|
+
this._moveIndex.value = chess.history().length;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Attempt move.
|
|
211
|
+
*/
|
|
212
|
+
const tryMove = (chess: ChessJS, move: Move): ChessJS | null => {
|
|
213
|
+
const from = locationToPos(move.from);
|
|
214
|
+
const to = locationToPos(move.to);
|
|
215
|
+
try {
|
|
216
|
+
const promotion = move.promotion ? move.promotion[1].toLowerCase() : 'q';
|
|
217
|
+
chess.move({ from, to, promotion }, { strict: false });
|
|
218
|
+
return chess;
|
|
219
|
+
} catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const isValidNextMove = (previous: string[], current: string[]) => {
|
|
225
|
+
if (current.length > previous.length + 1) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (let i = 0; i < previous.length; i++) {
|
|
230
|
+
if (previous[i] !== current[i]) {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return true;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Starting from a new game, assign piece IDs based on their starting position.
|
|
240
|
+
* Then iterate through the history of the provided game and update the piece map.
|
|
241
|
+
*/
|
|
242
|
+
export const createPieceMap = (chess: ChessJS): PieceMap<ChessPiece> => {
|
|
243
|
+
const temp = new ChessJS();
|
|
244
|
+
let pieces = _createPieceMap(temp);
|
|
245
|
+
const history = chess.history({ verbose: true });
|
|
246
|
+
for (let i = 0; i < history.length; i++) {
|
|
247
|
+
const move = history[i];
|
|
248
|
+
temp.move(move);
|
|
249
|
+
pieces = _diffPieces(pieces, _createPieceMap(temp));
|
|
250
|
+
const test = new Set();
|
|
251
|
+
Object.values(pieces).forEach((piece) => {
|
|
252
|
+
invariant(!test.has(piece.id), 'Duplicate: ' + piece.id);
|
|
253
|
+
test.add(piece.id);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return pieces;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Create a map of pieces from the board positions; assign each piece the ID of the current square.
|
|
262
|
+
*/
|
|
263
|
+
const _createPieceMap = (chess: ChessJS): PieceMap<ChessPiece> => {
|
|
264
|
+
const pieces: PieceMap<ChessPiece> = {};
|
|
265
|
+
chess.board().flatMap((row) =>
|
|
266
|
+
row.forEach((record) => {
|
|
267
|
+
if (!record) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const { square, type, color } = record;
|
|
272
|
+
const pieceType = `${color.toUpperCase()}${type.toUpperCase()}` as ChessPiece;
|
|
273
|
+
const location = posToLocation(square);
|
|
274
|
+
pieces[locationToString(location)] = {
|
|
275
|
+
id: `${square}-${pieceType}`,
|
|
276
|
+
type: pieceType,
|
|
277
|
+
side: color === 'w' ? 'white' : 'black',
|
|
278
|
+
location,
|
|
279
|
+
};
|
|
280
|
+
}),
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
return pieces;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Preserve the original piece objects (and IDs).
|
|
288
|
+
*/
|
|
289
|
+
const _diffPieces = <T extends PieceType>(before: PieceMap<T>, after: PieceMap<T>): PieceMap<T> => {
|
|
290
|
+
const difference: { added: PieceMap; removed: PieceMap } = {
|
|
291
|
+
removed: {},
|
|
292
|
+
added: {},
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// Removed.
|
|
296
|
+
(Object.keys(before) as Array<keyof typeof before>).forEach((square) => {
|
|
297
|
+
if (after[square]?.type !== before[square]?.type) {
|
|
298
|
+
difference.removed[square] = before[square];
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Added.
|
|
303
|
+
(Object.keys(after) as Array<keyof typeof after>).forEach((square) => {
|
|
304
|
+
if (before[square]?.type !== after[square]?.type) {
|
|
305
|
+
difference.added[square] = after[square];
|
|
306
|
+
} else {
|
|
307
|
+
after[square] = before[square];
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Preserve IDs.
|
|
312
|
+
for (const piece of Object.values(difference.added)) {
|
|
313
|
+
const previous = Object.values(difference.removed).find((p) => p.type === piece.type);
|
|
314
|
+
if (previous) {
|
|
315
|
+
piece.id = previous.id;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return after;
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const createDate = (date = new Date()) => date.toISOString().slice(0, 10).replace(/-/g, '.'); // e.g., "2025.08.05"
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
|
6
|
+
import { createContext } from '@radix-ui/react-context';
|
|
7
|
+
import React, { type PropsWithChildren, forwardRef, useCallback, useEffect, useState } from 'react';
|
|
8
|
+
|
|
9
|
+
import { log } from '@dxos/log';
|
|
10
|
+
import { type ThemedClassName } from '@dxos/react-ui';
|
|
11
|
+
import { mx } from '@dxos/react-ui-theme';
|
|
12
|
+
|
|
13
|
+
import { Piece, type PieceProps } from './Piece';
|
|
14
|
+
import { Square, type SquareProps } from './Square';
|
|
15
|
+
import { type GameboardModel, type Move, type PieceRecord, isLocation, isPiece } from './types';
|
|
16
|
+
|
|
17
|
+
export type GameboardContextValue<M extends GameboardModel> = {
|
|
18
|
+
model: M;
|
|
19
|
+
dragging?: boolean; // TODO(burdon): Change to PieceRecord.
|
|
20
|
+
promoting?: PieceRecord;
|
|
21
|
+
onPromotion: (move: Move) => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const [GameboardContextProvider, useRadixGameboardContext] = createContext<GameboardContextValue<any>>('Gameboard');
|
|
25
|
+
|
|
26
|
+
const useGameboardContext = <M extends GameboardModel>(consumerName: string): GameboardContextValue<M> => {
|
|
27
|
+
return useRadixGameboardContext(consumerName);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
//
|
|
31
|
+
// Root
|
|
32
|
+
//
|
|
33
|
+
|
|
34
|
+
type GameboardRootProps<M extends GameboardModel> = PropsWithChildren<{
|
|
35
|
+
model?: M;
|
|
36
|
+
moveNumber?: number;
|
|
37
|
+
onDrop?: (move: Move) => boolean;
|
|
38
|
+
}>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Generic board container.
|
|
42
|
+
*/
|
|
43
|
+
const GameboardRoot = <M extends GameboardModel>({ children, model, moveNumber, onDrop }: GameboardRootProps<M>) => {
|
|
44
|
+
const [dragging, setDragging] = useState(false);
|
|
45
|
+
const [promoting, setPromoting] = useState<PieceRecord | undefined>();
|
|
46
|
+
|
|
47
|
+
const handlePromotion = useCallback<GameboardContextValue<M>['onPromotion']>((move) => {
|
|
48
|
+
log('onPromotion', { move });
|
|
49
|
+
setPromoting(undefined);
|
|
50
|
+
onDrop?.(move);
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!model) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// TODO(burdon): Should target specific container.
|
|
59
|
+
return monitorForElements({
|
|
60
|
+
onDragStart: ({ source }) => {
|
|
61
|
+
log('onDragStart', { source });
|
|
62
|
+
setDragging(true);
|
|
63
|
+
},
|
|
64
|
+
onDrop: ({ source, location }) => {
|
|
65
|
+
log('onDrop', { source, location });
|
|
66
|
+
const target = location.current.dropTargets[0];
|
|
67
|
+
if (!target) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const targetLocation = target.data.location;
|
|
72
|
+
const piece = source.data.piece;
|
|
73
|
+
if (!isLocation(targetLocation) || !isPiece(piece)) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const move: Move = { from: piece.location, to: targetLocation, piece: piece.type };
|
|
78
|
+
if (model.isValidMove(move)) {
|
|
79
|
+
if (model.canPromote?.(move)) {
|
|
80
|
+
setPromoting({ ...piece, location: targetLocation });
|
|
81
|
+
} else {
|
|
82
|
+
onDrop?.(move);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
setDragging(false);
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}, [model]);
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<GameboardContextProvider model={model} dragging={dragging} promoting={promoting} onPromotion={handlePromotion}>
|
|
93
|
+
{children}
|
|
94
|
+
</GameboardContextProvider>
|
|
95
|
+
);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
GameboardRoot.displayName = 'Gameboard.Root';
|
|
99
|
+
|
|
100
|
+
//
|
|
101
|
+
// Content
|
|
102
|
+
//
|
|
103
|
+
|
|
104
|
+
type GameboardContentProps = ThemedClassName<PropsWithChildren<{ grow?: boolean; contain?: boolean }>>;
|
|
105
|
+
|
|
106
|
+
const GameboardContent = forwardRef<HTMLDivElement, GameboardContentProps>(
|
|
107
|
+
({ children, classNames, grow, contain }, forwardedRef) => {
|
|
108
|
+
return (
|
|
109
|
+
<div
|
|
110
|
+
role='none'
|
|
111
|
+
className={mx(grow && 'grid is-full bs-full size-container place-content-center', classNames)}
|
|
112
|
+
ref={forwardedRef}
|
|
113
|
+
>
|
|
114
|
+
{contain ? <div className='is-[min(100cqw,100cqh)] bs-[min(100cqw,100cqh)]'>{children}</div> : children}
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
GameboardContent.displayName = 'Gameboard.Content';
|
|
121
|
+
|
|
122
|
+
//
|
|
123
|
+
// Gameboard
|
|
124
|
+
//
|
|
125
|
+
|
|
126
|
+
export const Gameboard = {
|
|
127
|
+
Root: GameboardRoot,
|
|
128
|
+
Content: GameboardContent,
|
|
129
|
+
Piece,
|
|
130
|
+
Square,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export { useGameboardContext };
|
|
134
|
+
|
|
135
|
+
export type {
|
|
136
|
+
GameboardRootProps,
|
|
137
|
+
GameboardContentProps,
|
|
138
|
+
PieceProps as GameboardPieceProps,
|
|
139
|
+
SquareProps as GameboardSquareProps,
|
|
140
|
+
};
|
|
@@ -3,34 +3,32 @@
|
|
|
3
3
|
//
|
|
4
4
|
|
|
5
5
|
import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
|
6
|
-
// import { preserveOffsetOnSource } from '@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source';
|
|
7
6
|
import { centerUnderPointer } from '@atlaskit/pragmatic-drag-and-drop/element/center-under-pointer';
|
|
8
7
|
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
|
|
9
|
-
import React, {
|
|
8
|
+
import React, { type FC, type SVGProps, memo, useEffect, useRef, useState } from 'react';
|
|
10
9
|
import { createPortal } from 'react-dom';
|
|
11
10
|
|
|
12
11
|
import { invariant } from '@dxos/invariant';
|
|
13
12
|
import { log } from '@dxos/log';
|
|
14
|
-
import { useDynamicRef, useTrackProps
|
|
13
|
+
import { type ThemedClassName, useDynamicRef, useTrackProps } from '@dxos/react-ui';
|
|
15
14
|
import { mx } from '@dxos/react-ui-theme';
|
|
16
15
|
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
16
|
+
import { useGameboardContext } from './Gameboard';
|
|
17
|
+
import { type Location, type PieceRecord, type Player, isEqualLocation, isLocation } from './types';
|
|
19
18
|
import { type DOMRectBounds } from './util';
|
|
20
19
|
|
|
21
20
|
export type PieceProps = ThemedClassName<{
|
|
21
|
+
Component: FC<SVGProps<SVGSVGElement>>;
|
|
22
22
|
piece: PieceRecord;
|
|
23
23
|
bounds: DOMRectBounds;
|
|
24
24
|
label?: string;
|
|
25
25
|
orientation?: Player;
|
|
26
|
-
|
|
26
|
+
onClick?: () => void;
|
|
27
27
|
}>;
|
|
28
28
|
|
|
29
|
-
export const Piece = memo(({ classNames, piece, orientation, bounds, label,
|
|
30
|
-
useTrackProps({ classNames, piece, orientation, bounds, label
|
|
31
|
-
const { model } =
|
|
32
|
-
|
|
33
|
-
const { dragging: isDragging, promoting } = useBoardContext();
|
|
29
|
+
export const Piece = memo(({ classNames, Component, piece, orientation, bounds, label, onClick }: PieceProps) => {
|
|
30
|
+
useTrackProps({ classNames, Component, piece, orientation, bounds, label }, Piece.displayName, false);
|
|
31
|
+
const { model, dragging: isDragging, promoting } = useGameboardContext(Piece.displayName!);
|
|
34
32
|
const promotingRef = useDynamicRef(promoting);
|
|
35
33
|
const [dragging, setDragging] = useState(false);
|
|
36
34
|
const [preview, setPreview] = useState<HTMLElement>();
|
|
@@ -40,20 +38,20 @@ export const Piece = memo(({ classNames, piece, orientation, bounds, label, Comp
|
|
|
40
38
|
|
|
41
39
|
const ref = useRef<HTMLDivElement>(null);
|
|
42
40
|
useEffect(() => {
|
|
41
|
+
if (!model) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
43
45
|
const el = ref.current;
|
|
44
46
|
invariant(el);
|
|
45
47
|
|
|
46
48
|
return draggable({
|
|
47
49
|
element: el,
|
|
48
50
|
getInitialData: () => ({ piece }),
|
|
49
|
-
onGenerateDragPreview: ({ nativeSetDragImage,
|
|
51
|
+
onGenerateDragPreview: ({ nativeSetDragImage, source }) => {
|
|
50
52
|
log('onGenerateDragPreview', { source: source.data });
|
|
51
53
|
setCustomNativeDragPreview({
|
|
52
54
|
getOffset: centerUnderPointer,
|
|
53
|
-
// getOffset: preserveOffsetOnSource({
|
|
54
|
-
// element: source.element,
|
|
55
|
-
// input: location.current.input,
|
|
56
|
-
// }),
|
|
57
55
|
render: ({ container }) => {
|
|
58
56
|
setPreview(container);
|
|
59
57
|
const { width, height } = el.getBoundingClientRect();
|
|
@@ -66,12 +64,17 @@ export const Piece = memo(({ classNames, piece, orientation, bounds, label, Comp
|
|
|
66
64
|
nativeSetDragImage,
|
|
67
65
|
});
|
|
68
66
|
},
|
|
69
|
-
canDrag: () => !promotingRef.current && model
|
|
67
|
+
canDrag: () => !promotingRef.current && !model.readonly && model.turn === piece.side,
|
|
70
68
|
onDragStart: () => setDragging(true),
|
|
71
69
|
onDrop: ({ location: { current } }) => {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
70
|
+
// TODO(burdon): Create wrapper function to catch errors.
|
|
71
|
+
try {
|
|
72
|
+
const location = current.dropTargets[0]?.data.location;
|
|
73
|
+
if (isLocation(location)) {
|
|
74
|
+
setCurrent((current) => ({ ...current, location }));
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// Ignore.
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
setDragging(false);
|
|
@@ -88,7 +91,7 @@ export const Piece = memo(({ classNames, piece, orientation, bounds, label, Comp
|
|
|
88
91
|
|
|
89
92
|
// Check if piece moved.
|
|
90
93
|
if (!current.location || !isEqualLocation(current.location, piece.location)) {
|
|
91
|
-
ref.current.style.transition = 'top
|
|
94
|
+
ref.current.style.transition = 'top 250ms ease-out, left 250ms ease-out';
|
|
92
95
|
ref.current.style.top = bounds.top + 'px';
|
|
93
96
|
ref.current.style.left = bounds.left + 'px';
|
|
94
97
|
setCurrent({ location: piece.location, bounds });
|
|
@@ -112,10 +115,10 @@ export const Piece = memo(({ classNames, piece, orientation, bounds, label, Comp
|
|
|
112
115
|
className={mx(
|
|
113
116
|
'absolute',
|
|
114
117
|
classNames,
|
|
115
|
-
// orientation === 'black' && '_rotate-180',
|
|
116
118
|
dragging && 'opacity-20', // Must not unmount component while dragging.
|
|
117
119
|
isDragging && 'pointer-events-none', // Don't block the square's drop target.
|
|
118
120
|
)}
|
|
121
|
+
onClick={onClick}
|
|
119
122
|
>
|
|
120
123
|
<Component className='grow' />
|
|
121
124
|
{label && <div className='absolute inset-1 text-xs text-black'>{label}</div>}
|
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
//
|
|
4
4
|
|
|
5
5
|
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
|
6
|
-
import React, {
|
|
6
|
+
import React, { memo, useEffect, useRef, useState } from 'react';
|
|
7
7
|
|
|
8
8
|
import { invariant } from '@dxos/invariant';
|
|
9
9
|
import { log } from '@dxos/log';
|
|
10
10
|
import { type ThemedClassName } from '@dxos/react-ui';
|
|
11
11
|
import { mx } from '@dxos/react-ui-theme';
|
|
12
12
|
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
13
|
+
import { useGameboardContext } from './Gameboard';
|
|
14
|
+
import { type Location, isPiece } from './types';
|
|
15
15
|
import { type DOMRectBounds } from './util';
|
|
16
16
|
|
|
17
17
|
type HoveredState = 'idle' | 'validMove' | 'invalidMove';
|
|
@@ -25,7 +25,7 @@ export type SquareProps = ThemedClassName<{
|
|
|
25
25
|
export const Square = memo(({ location, bounds, label, classNames }: SquareProps) => {
|
|
26
26
|
const ref = useRef<HTMLDivElement>(null);
|
|
27
27
|
const [state, setState] = useState<HoveredState>('idle');
|
|
28
|
-
const { model } =
|
|
28
|
+
const { model } = useGameboardContext(Square.displayName!);
|
|
29
29
|
|
|
30
30
|
useEffect(() => {
|
|
31
31
|
const el = ref.current;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { type ReadonlySignal } from '@preact/signals-core';
|
|
6
6
|
|
|
7
|
+
// TODO(burdon): Don't make this assumption.
|
|
7
8
|
export type Player = 'black' | 'white';
|
|
8
9
|
|
|
9
10
|
export type Location = [number, number];
|
|
@@ -50,8 +51,10 @@ export const isEqualLocation = (l1: Location, l2: Location): boolean => l1[0] ==
|
|
|
50
51
|
/**
|
|
51
52
|
* Generic board model.
|
|
52
53
|
*/
|
|
53
|
-
export interface
|
|
54
|
+
export interface GameboardModel<T extends PieceType = PieceType> {
|
|
55
|
+
readonly: boolean;
|
|
54
56
|
turn: Player;
|
|
57
|
+
/** @reactive */
|
|
55
58
|
pieces: ReadonlySignal<PieceMap<T>>;
|
|
56
59
|
isValidMove: (move: Move) => boolean;
|
|
57
60
|
canPromote?: (move: Move) => boolean;
|
package/src/index.ts
CHANGED