From b5ddee7d12ac64a7bd143f7df9f94a21d6037d66 Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Thu, 15 Aug 2024 17:17:29 +0200 Subject: [PATCH 01/21] WIP on handling premove outside of chessground --- example/lib/main.dart | 413 +++++++++++++++++------------- lib/src/board_state.dart | 6 +- lib/src/widgets/board.dart | 150 ++++++----- lib/src/widgets/board_editor.dart | 2 +- test/widgets/board_test.dart | 142 +++++++++- 5 files changed, 445 insertions(+), 268 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 28fb7ca..1cb62f3 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'package:board_example/board_editor_page.dart'; import 'package:flutter/material.dart'; @@ -42,6 +43,7 @@ String pieceShiftMethodLabel(PieceShiftMethod method) { enum Mode { botPlay, + inputMove, freePlay, } @@ -76,10 +78,151 @@ class _HomePageState extends State { Widget build(BuildContext context) { final double screenWidth = MediaQuery.of(context).size.width; + final settingsWidgets = [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + ElevatedButton( + child: Text('Orientation: ${orientation.name}'), + onPressed: () { + setState(() { + orientation = orientation.opposite; + }); + }, + ), + const SizedBox(width: 8), + ElevatedButton( + child: Text("Magnify drag: ${dragMagnify ? 'ON' : 'OFF'}"), + onPressed: () { + setState(() { + dragMagnify = !dragMagnify; + }); + }, + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + ElevatedButton( + child: Text("Drawing mode: ${drawMode ? 'ON' : 'OFF'}"), + onPressed: () { + setState(() { + drawMode = !drawMode; + }); + }, + ), + const SizedBox(width: 8), + ElevatedButton( + child: Text("Piece animation: ${pieceAnimation ? 'ON' : 'OFF'}"), + onPressed: () { + setState(() { + pieceAnimation = !pieceAnimation; + }); + }, + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + ElevatedButton( + child: Text('Piece set: ${pieceSet.label}'), + onPressed: () => _showChoicesPicker( + context, + choices: PieceSet.values, + selectedItem: pieceSet, + labelBuilder: (t) => Text(t.label), + onSelectedItemChanged: (PieceSet? value) { + setState(() { + if (value != null) { + pieceSet = value; + } + }); + }, + ), + ), + const SizedBox(width: 8), + ElevatedButton( + child: Text('Board theme: ${boardTheme.label}'), + onPressed: () => _showChoicesPicker( + context, + choices: BoardTheme.values, + selectedItem: boardTheme, + labelBuilder: (t) => Text(t.label), + onSelectedItemChanged: (BoardTheme? value) { + setState(() { + if (value != null) { + boardTheme = value; + } + }); + }, + ), + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + ElevatedButton( + child: Text( + 'Piece shift method: ${pieceShiftMethodLabel(pieceShiftMethod)}'), + onPressed: () => _showChoicesPicker( + context, + choices: PieceShiftMethod.values, + selectedItem: pieceShiftMethod, + labelBuilder: (t) => Text(pieceShiftMethodLabel(t)), + onSelectedItemChanged: (PieceShiftMethod? value) { + setState(() { + if (value != null) { + pieceShiftMethod = value; + } + }); + }, + ), + ), + const SizedBox(width: 8), + ], + ), + if (playMode == Mode.freePlay) + Center( + child: IconButton( + onPressed: lastPos != null + ? () => setState(() { + position = lastPos!; + fen = position.fen; + validMoves = makeLegalMoves(position); + lastPos = null; + }) + : null, + icon: const Icon(Icons.chevron_left_sharp))), + ]; + + final inputMoveWidgets = [ + Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + decoration: const InputDecoration( + labelText: 'Enter move in UCI format', + ), + onSubmitted: (String value) { + final move = NormalMove.fromUci(value); + _playMove(move); + _tryPlayPremove(); + }, + ), + ), + ]; + return Scaffold( appBar: AppBar( title: switch (playMode) { Mode.botPlay => const Text('Random Bot'), + Mode.inputMove => const Text('Enter opponent move'), Mode.freePlay => const Text('Free Play'), }), drawer: Drawer( @@ -97,6 +240,15 @@ class _HomePageState extends State { Navigator.pop(context); }, ), + ListTile( + title: const Text('Enter opponent move'), + onTap: () { + setState(() { + playMode = Mode.inputMove; + }); + Navigator.pop(context); + }, + ), ListTile( title: const Text('Free Play'), onTap: () { @@ -130,187 +282,76 @@ class _HomePageState extends State { ), ], )), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Chessboard( - size: screenWidth, - settings: ChessboardSettings( - pieceAssets: pieceSet.assets, - colorScheme: boardTheme.colors, - enableCoordinates: true, - animationDuration: pieceAnimation - ? const Duration(milliseconds: 200) - : Duration.zero, - dragFeedbackScale: dragMagnify ? 2.0 : 1.0, - drawShape: DrawShapeOptions( - enable: drawMode, - onCompleteShape: _onCompleteShape, - onClearShapes: () { - setState(() { - shapes = ISet(); - }); - }, - ), - pieceShiftMethod: pieceShiftMethod, - ), - state: ChessboardState( - interactableSide: playMode == Mode.botPlay - ? InteractableSide.white - : (position.turn == Side.white - ? InteractableSide.white - : InteractableSide.black), - validMoves: validMoves, - orientation: orientation, - opponentsPiecesUpsideDown: playMode == Mode.freePlay, - fen: fen, - lastMove: lastMove, - sideToMove: - position.turn == Side.white ? Side.white : Side.black, - isCheck: position.isCheck, - premove: premove, - shapes: shapes.isNotEmpty ? shapes : null, + body: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Chessboard( + size: screenWidth, + settings: ChessboardSettings( + pieceAssets: pieceSet.assets, + colorScheme: boardTheme.colors, + enableCoordinates: true, + animationDuration: pieceAnimation + ? const Duration(milliseconds: 200) + : Duration.zero, + dragFeedbackScale: dragMagnify ? 2.0 : 1.0, + drawShape: DrawShapeOptions( + enable: drawMode, + onCompleteShape: _onCompleteShape, + onClearShapes: () { + setState(() { + shapes = ISet(); + }); + }, ), - onMove: playMode == Mode.botPlay - ? _onUserMoveAgainstBot - : _onUserMoveFreePlay, - onPremove: _onSetPremove, + pieceShiftMethod: pieceShiftMethod, + autoQueenPromotionOnPremove: false, ), - Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - children: [ - ElevatedButton( - child: Text('Orientation: ${orientation.name}'), - onPressed: () { - setState(() { - orientation = orientation.opposite; - }); - }, - ), - const SizedBox(width: 8), - ElevatedButton( - child: - Text("Magnify drag: ${dragMagnify ? 'ON' : 'OFF'}"), - onPressed: () { - setState(() { - dragMagnify = !dragMagnify; - }); - }, - ), - ], - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - children: [ - ElevatedButton( - child: Text("Drawing mode: ${drawMode ? 'ON' : 'OFF'}"), - onPressed: () { - setState(() { - drawMode = !drawMode; - }); - }, - ), - const SizedBox(width: 8), - ElevatedButton( - child: Text( - "Piece animation: ${pieceAnimation ? 'ON' : 'OFF'}"), - onPressed: () { - setState(() { - pieceAnimation = !pieceAnimation; - }); - }, - ), - ], - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - children: [ - ElevatedButton( - child: Text('Piece set: ${pieceSet.label}'), - onPressed: () => _showChoicesPicker( - context, - choices: PieceSet.values, - selectedItem: pieceSet, - labelBuilder: (t) => Text(t.label), - onSelectedItemChanged: (PieceSet? value) { - setState(() { - if (value != null) { - pieceSet = value; - } - }); - }, - ), - ), - const SizedBox(width: 8), - ElevatedButton( - child: Text('Board theme: ${boardTheme.label}'), - onPressed: () => _showChoicesPicker( - context, - choices: BoardTheme.values, - selectedItem: boardTheme, - labelBuilder: (t) => Text(t.label), - onSelectedItemChanged: (BoardTheme? value) { - setState(() { - if (value != null) { - boardTheme = value; - } - }); - }, - ), - ), - ], - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - children: [ - ElevatedButton( - child: Text( - 'Piece shift method: ${pieceShiftMethodLabel(pieceShiftMethod)}'), - onPressed: () => _showChoicesPicker( - context, - choices: PieceShiftMethod.values, - selectedItem: pieceShiftMethod, - labelBuilder: (t) => Text(pieceShiftMethodLabel(t)), - onSelectedItemChanged: (PieceShiftMethod? value) { - setState(() { - if (value != null) { - pieceShiftMethod = value; - } - }); - }, - ), - ), - const SizedBox(width: 8), - ], - ), - if (playMode == Mode.freePlay) - Center( - child: IconButton( - onPressed: lastPos != null - ? () => setState(() { - position = lastPos!; - fen = position.fen; - validMoves = makeLegalMoves(position); - lastPos = null; - }) - : null, - icon: const Icon(Icons.chevron_left_sharp))), - ], + state: ChessboardState( + interactableSide: + (playMode == Mode.botPlay || playMode == Mode.inputMove) + ? InteractableSide.white + : (position.turn == Side.white + ? InteractableSide.white + : InteractableSide.black), + validMoves: validMoves, + orientation: orientation, + opponentsPiecesUpsideDown: playMode == Mode.freePlay, + fen: fen, + lastMove: lastMove, + sideToMove: position.turn == Side.white ? Side.white : Side.black, + isCheck: position.isCheck, + premove: premove, + shapes: shapes.isNotEmpty ? shapes : null, ), - ], - ), + onMove: + playMode == Mode.botPlay ? _onUserMoveAgainstBot : _playMove, + onSetPremove: _onSetPremove, + ), + Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: + playMode == Mode.inputMove ? inputMoveWidgets : settingsWidgets, + ), + ], ), ); } + void _tryPlayPremove() { + if (premove != null) { + // if premove autoqueen if off, detect pawn promotion + final isPawnPromotion = premove!.promotion == null && + position.board.roleAt(premove!.from) == Role.pawn && + (premove!.to.rank == Rank.first || premove!.to.rank == Rank.eighth); + if (!isPawnPromotion) { + Timer.run(() { + _playMove(premove!, isPremove: true); + }); + } + } + } + void _onCompleteShape(Shape shape) { if (shapes.any((element) => element == shape)) { setState(() { @@ -374,28 +415,32 @@ class _HomePageState extends State { }); } - void _onUserMoveFreePlay(NormalMove move, {bool? isDrop, bool? isPremove}) { + void _playMove(NormalMove move, {bool? isDrop, bool? isPremove}) { lastPos = position; - final m = NormalMove.fromUci(move.uci); - setState(() { - position = position.playUnchecked(m); - lastMove = move; - fen = position.fen; - validMoves = makeLegalMoves(position); - }); + if (position.isLegal(move)) { + setState(() { + position = position.playUnchecked(move); + lastMove = move; + fen = position.fen; + validMoves = makeLegalMoves(position); + if (isPremove == true) { + premove = null; + } + }); + } } void _onUserMoveAgainstBot(NormalMove move, {bool? isDrop, bool? isPremove}) async { lastPos = position; - final m = NormalMove.fromUci(move.uci); setState(() { - position = position.playUnchecked(m); + position = position.playUnchecked(move); lastMove = move; fen = position.fen; validMoves = IMap(const {}); }); await _playBlackMove(); + _tryPlayPremove(); } Future _playBlackMove() async { diff --git a/lib/src/board_state.dart b/lib/src/board_state.dart index cfca62b..e967d2d 100644 --- a/lib/src/board_state.dart +++ b/lib/src/board_state.dart @@ -61,7 +61,11 @@ abstract class ChessboardState { /// FEN string describing the position of the board. final String fen; - /// Registered premove. Will be played right after the next opponent move. + /// Registered premove. + /// + /// Will be shown on the board as a preview move. + /// + /// Chessground will not play the premove automatically, it is up to the library user to play it. final NormalMove? premove; /// Last move played, used to highlight corresponding squares. diff --git a/lib/src/widgets/board.dart b/lib/src/widgets/board.dart index 840a4e8..bf05e07 100644 --- a/lib/src/widgets/board.dart +++ b/lib/src/widgets/board.dart @@ -35,7 +35,7 @@ class Chessboard extends StatefulWidget with ChessboardGeometry { required this.state, this.settings = const ChessboardSettings(), this.onMove, - this.onPremove, + this.onSetPremove, }); @override @@ -51,12 +51,12 @@ class Chessboard extends StatefulWidget with ChessboardGeometry { final ChessboardState state; /// Callback called after a move has been made. - final void Function(NormalMove, {bool? isDrop, bool? isPremove})? onMove; + final void Function(NormalMove, {bool? isDrop})? onMove; /// Callback called after a premove has been set/unset. /// /// If the callback is null, the board will not allow premoves. - final void Function(NormalMove?)? onPremove; + final void Function(NormalMove?)? onSetPremove; @override // ignore: library_private_types_in_public_api @@ -68,8 +68,9 @@ class _BoardState extends State { Pieces pieces = {}; /// Pieces that are currently being translated from one square to another. - Map translatingPieces = - {}; + /// + /// The key is the target square of the piece. + Map translatingPieces = {}; /// Pieces that are currently fading out. Map fadingPieces = {}; @@ -169,7 +170,8 @@ class _BoardState extends State { child: SquareHighlight(details: colorScheme.lastMove), ), if (premove != null && - widget.state.interactableSide != InteractableSide.none) + widget.state.interactableSide.name == + widget.state.sideToMove?.opposite.name) for (final square in premove.squares) PositionedSquare( key: ValueKey('${square.name}-premove'), @@ -237,7 +239,9 @@ class _BoardState extends State { blindfoldMode: widget.settings.blindfoldMode, upsideDown: _isUpsideDown(entry.value), onComplete: () { - fadingPieces.remove(entry.key); + setState(() { + fadingPieces.remove(entry.key); + }); }, ), ), @@ -259,24 +263,26 @@ class _BoardState extends State { ), for (final entry in translatingPieces.entries) PositionedSquare( - key: ValueKey('${entry.key.name}-${entry.value.from.$1}'), + key: ValueKey('${entry.key.name}-${entry.value.piece}-translating'), size: widget.size, orientation: widget.state.orientation, square: entry.key, child: AnimatedPieceTranslation( - fromSquare: entry.value.from.$2, - toSquare: entry.value.to.$2, + fromSquare: entry.value.from, + toSquare: entry.key, orientation: widget.state.orientation, duration: widget.settings.animationDuration, onComplete: () { - translatingPieces.remove(entry.key); + setState(() { + translatingPieces.remove(entry.key); + }); }, child: PieceWidget( - piece: entry.value.from.$1, + piece: entry.value.piece, size: widget.squareSize, pieceAssets: widget.settings.pieceAssets, blindfoldMode: widget.settings.blindfoldMode, - upsideDown: _isUpsideDown(entry.value.from.$1), + upsideDown: _isUpsideDown(entry.value.piece), ), ), ), @@ -321,6 +327,7 @@ class _BoardState extends State { if (widget.settings.boxShadow.isNotEmpty || widget.settings.borderRadius != BorderRadius.zero) Container( + key: const ValueKey('background-container'), clipBehavior: Clip.hardEdge, decoration: BoxDecoration( borderRadius: widget.settings.borderRadius, @@ -381,22 +388,43 @@ class _BoardState extends State { if (oldBoard.state.sideToMove != widget.state.sideToMove) { _premoveDests = null; _promotionMove = null; - if (widget.onPremove != null && - widget.state.premove != null && - widget.state.sideToMove?.name == widget.state.interactableSide.name) { - Timer.run(() { - if (mounted) _tryPlayPremove(); - }); - } } if (oldBoard.state.fen == widget.state.fen) { _lastDrop = null; // as long as the fen is the same as before let's keep animations return; } - translatingPieces = {}; - fadingPieces = {}; + final newPieces = readFen(widget.state.fen); + + // Handles premove promotion, where we don't want auto queen promotion. + // If the premove is a pawn move to the last rank, open the promotion selector + // to allow the user to select the promotion piece. + // In order to work, the library user must NOT play the move AND keep the + // `premove` field set. + final premove = widget.state.premove; + if (premove != null && + widget.state.interactableSide.name == widget.state.sideToMove?.name) { + final piece = newPieces[premove.from]; + if (piece != null && + piece.role == Role.pawn && + (premove.to.rank == Rank.eighth || premove.to.rank == Rank.first)) { + final pawn = newPieces.remove(premove.from); + newPieces[premove.to] = pawn!; + _promotionMove = premove; + } + } + + if (widget.settings.animationDuration > Duration.zero) { + _preparePieceAnimations(newPieces); + } + + _lastDrop = null; + pieces = newPieces; + } + + /// Detects pieces that changed squares and prepares animations for them. + void _preparePieceAnimations(Pieces newPieces) { final List<(Piece, Square)> newOnSquare = []; final List<(Piece, Square)> missingOnSquare = []; final Set animatedOrigins = {}; @@ -419,23 +447,22 @@ class _BoardState extends State { missingOnSquare.add((oldP, s)); } } - for (final newPiece in newOnSquare) { - final fromP = _closestPiece( - newPiece.$2, - missingOnSquare.where((m) => m.$1 == newPiece.$1).toList(), + for (final (newPiece, newPieceSquare) in newOnSquare) { + // find the closest square that the piece was on before + final fromSquare = _closestSquare( + newPieceSquare, + missingOnSquare.where((m) => m.$1 == newPiece).map((e) => e.$2), ); - if (fromP != null) { - translatingPieces[newPiece.$2] = (from: fromP, to: newPiece); - animatedOrigins.add(fromP.$2); + if (fromSquare != null) { + translatingPieces[newPieceSquare] = (piece: newPiece, from: fromSquare); + animatedOrigins.add(fromSquare); } } - for (final m in missingOnSquare) { - if (!animatedOrigins.contains(m.$2)) { - fadingPieces[m.$2] = m.$1; + for (final (missingPiece, missingPieceSquare) in missingOnSquare) { + if (!animatedOrigins.contains(missingPieceSquare)) { + fadingPieces[missingPieceSquare] = missingPiece; } } - _lastDrop = null; - pieces = newPieces; } Square? _getKingSquare() { @@ -568,7 +595,7 @@ class _BoardState extends State { // - cancel premove // - unselect piece else if (widget.state.premove != null) { - widget.onPremove?.call(null); + widget.onSetPremove?.call(null); setState(() { selected = null; _premoveDests = null; @@ -650,12 +677,12 @@ class _BoardState extends State { final couldMove = _tryMoveOrPremoveTo(square, drop: true); // if the premove was not possible, cancel the current premove if (!couldMove && widget.state.premove != null) { - widget.onPremove?.call(null); + widget.onSetPremove?.call(null); } } // if the user drags a piece to an empty square, cancel the premove else if (widget.state.premove != null) { - widget.onPremove?.call(null); + widget.onSetPremove?.call(null); } _onDragEnd(); setState(() { @@ -678,7 +705,7 @@ class _BoardState extends State { widget.state.premove != null && widget.state.premove!.from == square) { _shouldCancelPremoveOnTapUp = false; - widget.onPremove?.call(null); + widget.onSetPremove?.call(null); } _shouldDeselectOnTapUp = false; @@ -814,7 +841,7 @@ class _BoardState extends State { /// Whether the piece is premovable by the current side to move. bool _isPremovable(Piece? piece) { return piece != null && - (widget.onPremove != null && + (widget.onSetPremove != null && widget.state.interactableSide.name == piece.color.name && widget.state.sideToMove != piece.color); } @@ -835,6 +862,7 @@ class _BoardState extends State { ).contains(dest); } + /// Whether the move is pawn move to the first or eighth rank. bool _isPromoMove(Piece piece, Square targetSquare) { final rank = targetSquare.rank; return piece.role == Role.pawn && @@ -863,36 +891,15 @@ class _BoardState extends State { return true; } else if (_isPremovable(selectedPiece) && _canPremoveTo(selected!, square)) { - widget.onPremove?.call(NormalMove(from: selected!, to: square)); + final premove = widget.settings.autoQueenPromotionOnPremove && + _isPromoMove(selectedPiece!, square) + ? NormalMove(from: selected!, to: square, promotion: Role.queen) + : NormalMove(from: selected!, to: square); + widget.onSetPremove?.call(premove); return true; } return false; } - - /// Tries to play the premove if it is set and still valid. - void _tryPlayPremove() { - final premove = widget.state.premove; - if (premove == null) { - return; - } - final fromPiece = pieces[premove.from]; - if (fromPiece != null && _canMoveTo(premove.from, premove.to)) { - if (_isPromoMove(fromPiece, premove.to)) { - if (widget.settings.autoQueenPromotion || - widget.settings.autoQueenPromotionOnPremove) { - widget.onMove?.call( - premove.withPromotion(Role.queen), - isPremove: true, - ); - } else { - _openPromotionSelector(premove); - } - } else { - widget.onMove?.call(premove, isPremove: true); - } - } - widget.onPremove?.call(null); - } } // For the logic behind this see: @@ -981,11 +988,14 @@ const ISet _emptyValidMoves = ISetConst({}); const ISet _emptyShapes = ISetConst({}); const IMap _emptyAnnotations = IMapConst({}); -(Piece, Square)? _closestPiece(Square square, List<(Piece, Square)> pieces) { - pieces.sort( - (p1, p2) => _distanceSq(square, p1.$2) - _distanceSq(square, p2.$2), - ); - return pieces.isNotEmpty ? pieces[0] : null; +/// Returns the closest square to the target square from a list of squares. +Square? _closestSquare(Square square, Iterable squares) { + if (squares.isEmpty) return null; + return squares.reduce((a, b) { + final aDist = _distanceSq(square, a); + final bDist = _distanceSq(square, b); + return aDist < bDist ? a : b; + }); } int _distanceSq(Square pos1, Square pos2) { diff --git a/lib/src/widgets/board_editor.dart b/lib/src/widgets/board_editor.dart index 35abac5..612366a 100644 --- a/lib/src/widgets/board_editor.dart +++ b/lib/src/widgets/board_editor.dart @@ -1,4 +1,3 @@ -import 'package:chessground/chessground.dart'; import 'package:chessground/src/widgets/geometry.dart'; import 'package:dartchess/dartchess.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; @@ -7,6 +6,7 @@ import 'package:flutter/widgets.dart'; import '../board_settings.dart'; import '../models.dart'; import '../fen.dart'; +import 'highlight.dart'; import 'piece.dart'; import 'positioned_square.dart'; diff --git a/test/widgets/board_test.dart b/test/widgets/board_test.dart index 5392b05..80d42ca 100644 --- a/test/widgets/board_test.dart +++ b/test/widgets/board_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:chessground/src/widgets/promotion.dart'; import 'package:chessground/src/widgets/shape.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/material.dart'; @@ -98,8 +99,13 @@ void main() { await tester.tapAt(squareOffset(Square.e4)); await tester.pump(); + + expect(find.byKey(const Key('e4-whitepawn-translating')), findsOneWidget); expect(find.byKey(const Key('e2-selected')), findsNothing); expect(find.byType(ValidMoveHighlight), findsNothing); + + await tester.pumpAndSettle(); + expect(find.byKey(const Key('e4-whitepawn')), findsOneWidget); expect(find.byKey(const Key('e2-whitepawn')), findsNothing); expect(find.byKey(const Key('e2-lastMove')), findsOneWidget); @@ -133,7 +139,7 @@ void main() { expect(find.byKey(const Key('e4-lastMove')), findsOneWidget); }); - testWidgets('castling by taping king then rook is possible', + testWidgets('castling by selecting king then rook is possible', (WidgetTester tester) async { await tester.pumpWidget( buildBoard( @@ -146,6 +152,10 @@ void main() { await tester.pump(); await tester.tap(find.byKey(const Key('h1-whiterook'))); await tester.pump(); + + // wait for the animations to finish + await tester.pumpAndSettle(); + expect(find.byKey(const Key('e1-whiteking')), findsNothing); expect(find.byKey(const Key('h1-whiterook')), findsNothing); expect(find.byKey(const Key('g1-whiteking')), findsOneWidget); @@ -202,8 +212,11 @@ void main() { ) async { await tester.pumpWidget( buildBoard( - initialInteractableSide: InteractableSide.both, - pieceShiftMethod: PieceShiftMethod.tapTwoSquares, + settings: const ChessboardSettings( + animationDuration: Duration.zero, + pieceShiftMethod: PieceShiftMethod.tapTwoSquares, + ), + initialInteractableSide: InteractableSide.white, ), ); await tester.dragFrom( @@ -359,6 +372,15 @@ void main() { await tester.pump(); await tester.tapAt(squareOffset(Square.f8)); await tester.pump(); + + // promotion dialog is shown + expect(find.byType(PromotionSelector), findsOneWidget); + + // pawn is on the eighth rank + expect(find.byKey(const Key('f8-whitepawn')), findsOneWidget); + expect(find.byKey(const Key('f7-whitepawn')), findsNothing); + + // tap on the knight await tester.tapAt(squareOffset(Square.f7)); await tester.pump(); expect(find.byKey(const Key('f8-whiteknight')), findsOneWidget); @@ -713,7 +735,8 @@ void main() { expect(find.byKey(const Key('e4-selected')), findsNothing); }); - testWidgets('select pieces from same side', (WidgetTester tester) async { + testWidgets('select another piece from same side does not unset', + (WidgetTester tester) async { await tester.pumpWidget( buildBoard( initialFen: @@ -725,11 +748,17 @@ void main() { await makeMove(tester, Square.d1, Square.c2); expect(find.byKey(const Key('d1-premove')), findsOneWidget); expect(find.byKey(const Key('c2-premove')), findsOneWidget); + + await tester.tapAt(squareOffset(Square.e1)); + await tester.pump(); + expect(find.byKey(const Key('d1-premove')), findsOneWidget); + expect(find.byKey(const Key('c2-premove')), findsOneWidget); }); testWidgets('play premove', (WidgetTester tester) async { await tester.pumpWidget( buildBoard( + settings: const ChessboardSettings(animationDuration: Duration.zero), initialInteractableSide: InteractableSide.white, shouldPlayOpponentMove: true, ), @@ -748,7 +777,7 @@ void main() { expect(find.byKey(const Key('a5-blackpawn')), findsOneWidget); // wait for the premove to be played - await tester.pumpAndSettle(); + await tester.pump(); expect(find.byKey(const Key('d1-premove')), findsNothing); expect(find.byKey(const Key('f3-premove')), findsNothing); @@ -757,6 +786,78 @@ void main() { expect(find.byKey(const Key('d1-whitequeen')), findsNothing); expect(find.byKey(const Key('f3-whitequeen')), findsOneWidget); }); + + testWidgets('play a premove with promotion', (WidgetTester tester) async { + await tester.pumpWidget( + buildBoard( + settings: const ChessboardSettings(animationDuration: Duration.zero), + initialInteractableSide: InteractableSide.white, + initialFen: '8/5P2/2RK2P1/8/4k3/8/8/8 w - - 0 1', + shouldPlayOpponentMove: true, + ), + ); + + await makeMove(tester, Square.g6, Square.g7); + await makeMove(tester, Square.g7, Square.g8); + expect(find.byKey(const Key('g7-premove')), findsOneWidget); + expect(find.byKey(const Key('g8-premove')), findsOneWidget); + + // wait for opponent move to be played + await tester.pump(const Duration(milliseconds: 200)); + + // opponent move is played + expect(find.byKey(const Key('d3-blackking')), findsOneWidget); + + // pawn was promoted to queen + expect(find.byKey(const Key('g7-premove')), findsNothing); + expect(find.byKey(const Key('g8-premove')), findsNothing); + expect(find.byKey(const Key('g7-whitepawn')), findsNothing); + expect(find.byKey(const Key('g8-whitequeen')), findsOneWidget); + }); + + testWidgets('play a premove with promotion, autoqueen disabled', + (WidgetTester tester) async { + await tester.pumpWidget( + buildBoard( + settings: const ChessboardSettings( + autoQueenPromotionOnPremove: false, + animationDuration: Duration.zero, + ), + initialInteractableSide: InteractableSide.white, + initialFen: '8/5P2/2RK2P1/8/4k3/8/8/8 w - - 0 1', + shouldPlayOpponentMove: true, + ), + ); + + await makeMove(tester, Square.g6, Square.g7); + await makeMove(tester, Square.g7, Square.g8); + expect(find.byKey(const Key('g7-premove')), findsOneWidget); + expect(find.byKey(const Key('g8-premove')), findsOneWidget); + + // wait for opponent move to be played + await tester.pump(const Duration(milliseconds: 200)); + + // promotion dialog is shown + expect(find.byType(PromotionSelector), findsOneWidget); + + // premove highlight are not shown anymore + expect(find.byKey(const Key('g7-premove')), findsNothing); + expect(find.byKey(const Key('g8-premove')), findsNothing); + + // pawn is on the last rank + expect(find.byKey(const Key('g8-whitepawn')), findsOneWidget); + expect(find.byKey(const Key('g7-whitepawn')), findsNothing); + + // select knight + await tester.tapAt(squareOffset(Square.g7)); + await tester.pump(); + + expect(find.byKey(const Key('g8-whiteknight')), findsOneWidget); + expect(find.byKey(const Key('g7-whitepawn')), findsNothing); + + // wait for other opponent move to be played + await tester.pump(const Duration(milliseconds: 200)); + }); }); group('drawing shapes', () { @@ -1074,23 +1175,40 @@ Widget buildBoard({ if (shouldPlayOpponentMove) { Timer(const Duration(milliseconds: 200), () { + final allMoves = [ + for (final entry in position.legalMoves.entries) + for (final dest in entry.value.squares) + NormalMove(from: entry.key, to: dest), + ]; + final opponentMove = allMoves.first; setState(() { - final allMoves = [ - for (final entry in position.legalMoves.entries) - for (final dest in entry.value.squares) - NormalMove(from: entry.key, to: dest), - ]; - final opponentMove = allMoves.first; position = position.playUnchecked(opponentMove); if (position.isGameOver) { interactableSide = InteractableSide.none; } lastMove = NormalMove.fromUci(opponentMove.uci); }); + + if (premove != null && position.isLegal(premove!)) { + // if premove autoqueen if off, detect pawn promotion + final isPawnPromotion = premove!.promotion == null && + position.board.roleAt(premove!.from) == Role.pawn && + (premove!.to.rank == Rank.first || + premove!.to.rank == Rank.eighth); + + if (!isPawnPromotion) { + Timer.run(() { + setState(() { + position = position.playUnchecked(premove!); + premove = null; + }); + }); + } + } }); } }, - onPremove: (NormalMove? move) { + onSetPremove: (NormalMove? move) { setState(() { premove = move; }); From 07e5f9d6f2b2eff281900434ede9d9afadfc5893 Mon Sep 17 00:00:00 2001 From: tom-anders <13141438+tom-anders@users.noreply.github.com> Date: Sat, 17 Aug 2024 15:04:17 +0200 Subject: [PATCH 02/21] fix: flip dragFeedbackOffset.y when piece is flipped Noticed this while building the "over the board" screen for lichess mobile v2. When pieces are flipped, the person dragging them will probably look at the app in such a way, that the piece is *not* flipped from their POV. This means with the previous behavior, the drag feedback offset would be shifted into the wrong vertical direction. --- CHANGELOG.md | 1 + lib/src/widgets/board.dart | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51dd719..0a36b21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## 4.1.0 - `ChessboardEditor` now supports highlighting squares. +- Flip `BoardSettings.dragFeedbackOffset.dy` for flipped pieces. ### Breaking changes: - Added required parameters `piece` and `pieceAssets` to `PieceShape`, removed `role`. Added optional diff --git a/lib/src/widgets/board.dart b/lib/src/widgets/board.dart index 840a4e8..9b8fcea 100644 --- a/lib/src/widgets/board.dart +++ b/lib/src/widgets/board.dart @@ -723,6 +723,10 @@ class _BoardState extends State { _draggedPieceSquare = square; }); _renderBox ??= context.findRenderObject()! as RenderBox; + + final dragFeedbackOffsetY = (_isUpsideDown(piece) ? -1 : 1) * + widget.settings.dragFeedbackOffset.dy; + _dragAvatar = _DragAvatar( overlayState: Overlay.of(context, debugRequiredFor: widget), initialPosition: origin.position, @@ -739,7 +743,7 @@ class _BoardState extends State { pieceFeedback: Transform.translate( offset: Offset( ((widget.settings.dragFeedbackOffset.dx - 1) * feedbackSize) / 2, - ((widget.settings.dragFeedbackOffset.dy - 1) * feedbackSize) / 2, + ((dragFeedbackOffsetY - 1) * feedbackSize) / 2, ), child: PieceWidget( piece: piece, From 09e9826308ce9da7074564026bd4684303d62683 Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Thu, 29 Aug 2024 11:13:43 +0200 Subject: [PATCH 03/21] Add promotion cancel test --- test/widgets/board_test.dart | 123 ++++++++++++++++++++++------------- 1 file changed, 78 insertions(+), 45 deletions(-) diff --git a/test/widgets/board_test.dart b/test/widgets/board_test.dart index 80d42ca..3dfcb5a 100644 --- a/test/widgets/board_test.dart +++ b/test/widgets/board_test.dart @@ -360,50 +360,6 @@ void main() { expectSync(find.byKey(const Key('e2-selected')), findsNothing); }); - testWidgets('promotion', (WidgetTester tester) async { - await tester.pumpWidget( - buildBoard( - initialInteractableSide: InteractableSide.both, - initialFen: '8/5P2/2RK2P1/8/4k3/8/8/7r w - - 0 1', - ), - ); - - await tester.tap(find.byKey(const Key('f7-whitepawn'))); - await tester.pump(); - await tester.tapAt(squareOffset(Square.f8)); - await tester.pump(); - - // promotion dialog is shown - expect(find.byType(PromotionSelector), findsOneWidget); - - // pawn is on the eighth rank - expect(find.byKey(const Key('f8-whitepawn')), findsOneWidget); - expect(find.byKey(const Key('f7-whitepawn')), findsNothing); - - // tap on the knight - await tester.tapAt(squareOffset(Square.f7)); - await tester.pump(); - expect(find.byKey(const Key('f8-whiteknight')), findsOneWidget); - expect(find.byKey(const Key('f7-whitepawn')), findsNothing); - }); - - testWidgets('promotion, auto queen enabled', (WidgetTester tester) async { - await tester.pumpWidget( - buildBoard( - settings: const ChessboardSettings(autoQueenPromotion: true), - initialInteractableSide: InteractableSide.both, - initialFen: '8/5P2/2RK2P1/8/4k3/8/8/7r w - - 0 1', - ), - ); - - await tester.tap(find.byKey(const Key('f7-whitepawn'))); - await tester.pump(); - await tester.tapAt(squareOffset(Square.f8)); - await tester.pump(); - expect(find.byKey(const Key('f8-whitequeen')), findsOneWidget); - expect(find.byKey(const Key('f7-whitepawn')), findsNothing); - }); - testWidgets('king check square black', (WidgetTester tester) async { await tester.pumpWidget( buildBoard( @@ -499,6 +455,83 @@ void main() { }); }); + group('Promotion', () { + testWidgets('promote a knight', (WidgetTester tester) async { + await tester.pumpWidget( + buildBoard( + initialInteractableSide: InteractableSide.both, + initialFen: '8/5P2/2RK2P1/8/4k3/8/8/7r w - - 0 1', + ), + ); + + await tester.tap(find.byKey(const Key('f7-whitepawn'))); + await tester.pump(); + await tester.tapAt(squareOffset(Square.f8)); + await tester.pump(); + + // promotion dialog is shown + expect(find.byType(PromotionSelector), findsOneWidget); + + // pawn is on the eighth rank + expect(find.byKey(const Key('f8-whitepawn')), findsOneWidget); + expect(find.byKey(const Key('f7-whitepawn')), findsNothing); + + // tap on the knight + await tester.tapAt(squareOffset(Square.f7)); + await tester.pump(); + expect(find.byKey(const Key('f8-whiteknight')), findsOneWidget); + expect(find.byKey(const Key('f7-whitepawn')), findsNothing); + }); + + testWidgets('cancels promotion', (WidgetTester tester) async { + await tester.pumpWidget( + buildBoard( + initialInteractableSide: InteractableSide.both, + initialFen: '8/5P2/2RK2P1/8/4k3/8/8/7r w - - 0 1', + ), + ); + + await tester.tap(find.byKey(const Key('f7-whitepawn'))); + await tester.pump(); + await tester.tapAt(squareOffset(Square.f8)); + await tester.pump(); + + // promotion dialog is shown + expect(find.byType(PromotionSelector), findsOneWidget); + + // pawn is on the eighth rank + expect(find.byKey(const Key('f8-whitepawn')), findsOneWidget); + expect(find.byKey(const Key('f7-whitepawn')), findsNothing); + + // tap outside the promotion dialog + await tester.tapAt(squareOffset(Square.c4)); + + await tester.pump(); + + // promotion dialog is closed, move is cancelled + expect(find.byType(PromotionSelector), findsNothing); + expect(find.byKey(const Key('f8-whitepawn')), findsNothing); + expect(find.byKey(const Key('f7-whitepawn')), findsOneWidget); + }); + + testWidgets('promotion, auto queen enabled', (WidgetTester tester) async { + await tester.pumpWidget( + buildBoard( + settings: const ChessboardSettings(autoQueenPromotion: true), + initialInteractableSide: InteractableSide.both, + initialFen: '8/5P2/2RK2P1/8/4k3/8/8/7r w - - 0 1', + ), + ); + + await tester.tap(find.byKey(const Key('f7-whitepawn'))); + await tester.pump(); + await tester.tapAt(squareOffset(Square.f8)); + await tester.pump(); + expect(find.byKey(const Key('f8-whitequeen')), findsOneWidget); + expect(find.byKey(const Key('f7-whitepawn')), findsNothing); + }); + }); + group('premoves', () { testWidgets('select and deselect with empty square', (WidgetTester tester) async { @@ -860,7 +893,7 @@ void main() { }); }); - group('drawing shapes', () { + group('Drawing shapes', () { testWidgets('preconfigure board to draw a circle', (WidgetTester tester) async { await tester.pumpWidget( From a373a142653ba5c9dda82bddd071b1117ca3a304 Mon Sep 17 00:00:00 2001 From: tom-anders <13141438+tom-anders@users.noreply.github.com> Date: Wed, 21 Aug 2024 20:50:03 +0200 Subject: [PATCH 04/21] feat: support displaying all pieces upside down --- CHANGELOG.md | 2 + example/lib/main.dart | 4 +- lib/src/board_settings.dart | 21 +++++++ lib/src/board_state.dart | 12 ---- lib/src/widgets/board.dart | 27 +++++---- test/widgets/board_test.dart | 107 ++++++++++++++++++++++++++++++++++- 6 files changed, 146 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a36b21..5c47260 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ - `ChessboardEditor` now supports highlighting squares. - Flip `BoardSettings.dragFeedbackOffset.dy` for flipped pieces. +- Remove 'ChessboardState.opponentsPiecesUpsideDown' in favor of `ChessboardSettings.pieceOrientationBehavior`. + Support displaying all pieces upside down based on side to move. ### Breaking changes: - Added required parameters `piece` and `pieceAssets` to `PieceShape`, removed `role`. Added optional diff --git a/example/lib/main.dart b/example/lib/main.dart index 28fb7ca..cd408e7 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -153,6 +153,9 @@ class _HomePageState extends State { }); }, ), + pieceOrientationBehavior: playMode == Mode.freePlay + ? PieceOrientationBehavior.opponentUpsideDown + : PieceOrientationBehavior.default_, pieceShiftMethod: pieceShiftMethod, ), state: ChessboardState( @@ -163,7 +166,6 @@ class _HomePageState extends State { : InteractableSide.black), validMoves: validMoves, orientation: orientation, - opponentsPiecesUpsideDown: playMode == Mode.freePlay, fen: fen, lastMove: lastMove, sideToMove: diff --git a/lib/src/board_settings.dart b/lib/src/board_settings.dart index 00bf060..1314e76 100644 --- a/lib/src/board_settings.dart +++ b/lib/src/board_settings.dart @@ -17,6 +17,18 @@ enum PieceShiftMethod { either; } +/// Describes how pieces on the board are oriented. +enum PieceOrientationBehavior { + /// Pieces are always facing user + default_, + + /// Opponent's pieces are upside down, for over the board play face to face. + opponentUpsideDown, + + /// Piece orientation matches side to play, for over the board play where each user grabs the device in turn + sideToPlay, +} + /// Board settings that controls visual aspects and behavior of the board. /// /// This is meant for fixed settings that don't change during a game. Sensible @@ -37,6 +49,7 @@ class ChessboardSettings { this.blindfoldMode = false, this.dragFeedbackScale = 2.0, this.dragFeedbackOffset = const Offset(0.0, -1.0), + this.pieceOrientationBehavior = PieceOrientationBehavior.default_, // shape drawing this.drawShape = const DrawShapeOptions(), @@ -81,6 +94,9 @@ class ChessboardSettings { // Offset for the piece currently under drag final Offset dragFeedbackOffset; + /// Controls if any pieces are displayed upside down. + final PieceOrientationBehavior pieceOrientationBehavior; + /// Whether castling is enabled with a premove. final bool enablePremoveCastling; @@ -118,6 +134,7 @@ class ChessboardSettings { other.blindfoldMode == blindfoldMode && other.dragFeedbackScale == dragFeedbackScale && other.dragFeedbackOffset == dragFeedbackOffset && + other.pieceOrientationBehavior == pieceOrientationBehavior && other.enablePremoveCastling == enablePremoveCastling && other.autoQueenPromotion == autoQueenPromotion && other.autoQueenPromotionOnPremove == autoQueenPromotionOnPremove && @@ -138,6 +155,7 @@ class ChessboardSettings { blindfoldMode, dragFeedbackScale, dragFeedbackOffset, + pieceOrientationBehavior, enablePremoveCastling, autoQueenPromotion, autoQueenPromotionOnPremove, @@ -157,6 +175,7 @@ class ChessboardSettings { bool? blindfoldMode, double? dragFeedbackScale, Offset? dragFeedbackOffset, + PieceOrientationBehavior? pieceOrientationBehavior, bool? enablePremoveCastling, bool? autoQueenPromotion, bool? autoQueenPromotionOnPremove, @@ -175,6 +194,8 @@ class ChessboardSettings { blindfoldMode: blindfoldMode ?? this.blindfoldMode, dragFeedbackScale: dragFeedbackScale ?? this.dragFeedbackScale, dragFeedbackOffset: dragFeedbackOffset ?? this.dragFeedbackOffset, + pieceOrientationBehavior: + pieceOrientationBehavior ?? this.pieceOrientationBehavior, enablePremoveCastling: enablePremoveCastling ?? this.enablePremoveCastling, autoQueenPromotionOnPremove: diff --git a/lib/src/board_state.dart b/lib/src/board_state.dart index cfca62b..046989a 100644 --- a/lib/src/board_state.dart +++ b/lib/src/board_state.dart @@ -16,7 +16,6 @@ abstract class ChessboardState { required InteractableSide interactableSide, required Side orientation, required String fen, - bool opponentsPiecesUpsideDown, Side? sideToMove, NormalMove? premove, NormalMove? lastMove, @@ -30,7 +29,6 @@ abstract class ChessboardState { required this.interactableSide, required this.orientation, required this.fen, - required this.opponentsPiecesUpsideDown, this.sideToMove, this.premove, this.lastMove, @@ -52,9 +50,6 @@ abstract class ChessboardState { /// Side by which the board is oriented. final Side orientation; - /// If `true` the opponent`s pieces are displayed rotated by 180 degrees. - final bool opponentsPiecesUpsideDown; - /// Side which is to move. final Side? sideToMove; @@ -86,7 +81,6 @@ abstract class ChessboardState { runtimeType == other.runtimeType && interactableSide == other.interactableSide && orientation == other.orientation && - opponentsPiecesUpsideDown == other.opponentsPiecesUpsideDown && sideToMove == other.sideToMove && fen == other.fen && premove == other.premove && @@ -100,7 +94,6 @@ abstract class ChessboardState { int get hashCode => Object.hash( interactableSide, orientation, - opponentsPiecesUpsideDown, sideToMove, fen, premove, @@ -116,7 +109,6 @@ abstract class ChessboardState { InteractableSide? interactableSide, Side? orientation, String? fen, - bool? opponentsPiecesUpsideDown, Side? sideToMove, Move? premove, Move? lastMove, @@ -132,7 +124,6 @@ class _ChessboardState extends ChessboardState { required super.interactableSide, required super.orientation, required super.fen, - super.opponentsPiecesUpsideDown = false, super.sideToMove, super.premove, super.lastMove, @@ -147,7 +138,6 @@ class _ChessboardState extends ChessboardState { InteractableSide? interactableSide, Side? orientation, String? fen, - bool? opponentsPiecesUpsideDown, Object? sideToMove = _Undefined, Object? premove = _Undefined, Object? lastMove = _Undefined, @@ -159,8 +149,6 @@ class _ChessboardState extends ChessboardState { return ChessboardState( interactableSide: interactableSide ?? this.interactableSide, orientation: orientation ?? this.orientation, - opponentsPiecesUpsideDown: - opponentsPiecesUpsideDown ?? this.opponentsPiecesUpsideDown, fen: fen ?? this.fen, sideToMove: sideToMove == _Undefined ? this.sideToMove : sideToMove as Side?, diff --git a/lib/src/widgets/board.dart b/lib/src/widgets/board.dart index 9b8fcea..3c66cd9 100644 --- a/lib/src/widgets/board.dart +++ b/lib/src/widgets/board.dart @@ -235,7 +235,7 @@ class _BoardState extends State { size: widget.squareSize, pieceAssets: widget.settings.pieceAssets, blindfoldMode: widget.settings.blindfoldMode, - upsideDown: _isUpsideDown(entry.value), + upsideDown: _isUpsideDown(entry.value.color), onComplete: () { fadingPieces.remove(entry.key); }, @@ -254,7 +254,7 @@ class _BoardState extends State { size: widget.squareSize, pieceAssets: widget.settings.pieceAssets, blindfoldMode: widget.settings.blindfoldMode, - upsideDown: _isUpsideDown(entry.value), + upsideDown: _isUpsideDown(entry.value.color), ), ), for (final entry in translatingPieces.entries) @@ -276,7 +276,7 @@ class _BoardState extends State { size: widget.squareSize, pieceAssets: widget.settings.pieceAssets, blindfoldMode: widget.settings.blindfoldMode, - upsideDown: _isUpsideDown(entry.value.from.$1), + upsideDown: _isUpsideDown(entry.value.from.$1.color), ), ), ), @@ -338,8 +338,7 @@ class _BoardState extends State { size: widget.size, color: widget.state.sideToMove!, orientation: widget.state.orientation, - piecesUpsideDown: widget.state.opponentsPiecesUpsideDown && - widget.state.sideToMove! != widget.state.orientation, + piecesUpsideDown: _isUpsideDown(widget.state.sideToMove!), onSelect: _onPromotionSelect, onCancel: _onPromotionCancel, ), @@ -724,7 +723,7 @@ class _BoardState extends State { }); _renderBox ??= context.findRenderObject()! as RenderBox; - final dragFeedbackOffsetY = (_isUpsideDown(piece) ? -1 : 1) * + final dragFeedbackOffsetY = (_isUpsideDown(piece.color) ? -1 : 1) * widget.settings.dragFeedbackOffset.dy; _dragAvatar = _DragAvatar( @@ -750,7 +749,7 @@ class _BoardState extends State { size: feedbackSize, pieceAssets: widget.settings.pieceAssets, blindfoldMode: widget.settings.blindfoldMode, - upsideDown: _isUpsideDown(piece), + upsideDown: _isUpsideDown(piece.color), ), ), ); @@ -800,12 +799,16 @@ class _BoardState extends State { }); } - /// Whether the piece should be displayed upside down, according to the + /// Whether the piece with this color should be displayed upside down, according to the /// widget settings. - bool _isUpsideDown(Piece piece) { - return widget.state.opponentsPiecesUpsideDown && - piece.color != widget.state.orientation; - } + bool _isUpsideDown(Side pieceColor) => + switch (widget.settings.pieceOrientationBehavior) { + PieceOrientationBehavior.default_ => false, + PieceOrientationBehavior.opponentUpsideDown => + pieceColor == widget.state.orientation.opposite, + PieceOrientationBehavior.sideToPlay => + widget.state.sideToMove == widget.state.orientation.opposite, + }; /// Whether the piece is movable by the current side to move. bool _isMovable(Piece? piece) { diff --git a/test/widgets/board_test.dart b/test/widgets/board_test.dart index 5392b05..e85d1f3 100644 --- a/test/widgets/board_test.dart +++ b/test/widgets/board_test.dart @@ -1005,12 +1005,115 @@ void main() { expect(find.byType(ShapeWidget), findsNothing); }); }); + + group('piece orientation behavior', () { + void checkUpsideDownPieces( + WidgetTester tester, { + required bool expectWhiteUpsideDown, + required bool expectBlackUpsideDown, + }) { + final pieceWidgets = + tester.widgetList(find.byType(PieceWidget)); + expect(pieceWidgets, hasLength(32)); + for (final pieceWidget in pieceWidgets) { + if (pieceWidget.piece.color == Side.white) { + expect(pieceWidget.upsideDown, expectWhiteUpsideDown); + } else { + expect(pieceWidget.upsideDown, expectBlackUpsideDown); + } + } + } + + testWidgets('default', (WidgetTester tester) async { + for (final orientation in Side.values) { + await tester.pumpWidget( + buildBoard( + initialInteractableSide: InteractableSide.both, + orientation: orientation, + ), + ); + + checkUpsideDownPieces( + tester, + expectWhiteUpsideDown: false, + expectBlackUpsideDown: false, + ); + + await makeMove(tester, Square.e2, Square.e4); + + checkUpsideDownPieces( + tester, + expectWhiteUpsideDown: false, + expectBlackUpsideDown: false, + ); + } + }); + + testWidgets('opponent upside down', (WidgetTester tester) async { + for (final orientation in Side.values) { + await tester.pumpWidget( + buildBoard( + initialInteractableSide: InteractableSide.both, + orientation: orientation, + settings: const ChessboardSettings( + pieceOrientationBehavior: + PieceOrientationBehavior.opponentUpsideDown, + ), + ), + ); + + checkUpsideDownPieces( + tester, + expectWhiteUpsideDown: orientation != Side.white, + expectBlackUpsideDown: orientation == Side.white, + ); + + await makeMove(tester, Square.e2, Square.e4); + + checkUpsideDownPieces( + tester, + expectWhiteUpsideDown: orientation != Side.white, + expectBlackUpsideDown: orientation == Side.white, + ); + } + }); + + testWidgets('side to play', (WidgetTester tester) async { + for (final orientation in Side.values) { + await tester.pumpWidget( + buildBoard( + initialInteractableSide: InteractableSide.both, + orientation: orientation, + settings: const ChessboardSettings( + pieceOrientationBehavior: PieceOrientationBehavior.sideToPlay, + ), + ), + ); + + checkUpsideDownPieces( + tester, + expectWhiteUpsideDown: orientation != Side.white, + expectBlackUpsideDown: orientation != Side.white, + ); + + await makeMove(tester, Square.e2, Square.e4); + + checkUpsideDownPieces( + tester, + expectWhiteUpsideDown: orientation == Side.white, + expectBlackUpsideDown: orientation == Side.white, + ); + } + }); + }); } Future makeMove(WidgetTester tester, Square from, Square to) async { - await tester.tapAt(squareOffset(from)); + final orientation = + tester.widget(find.byType(Chessboard)).state.orientation; + await tester.tapAt(squareOffset(from, orientation: orientation)); await tester.pump(); - await tester.tapAt(squareOffset(to)); + await tester.tapAt(squareOffset(to, orientation: orientation)); await tester.pump(); } From e39c440e72a5b93b4d9399b1042137db5ac88d04 Mon Sep 17 00:00:00 2001 From: tom-anders <13141438+tom-anders@users.noreply.github.com> Date: Thu, 29 Aug 2024 11:02:20 +0200 Subject: [PATCH 05/21] fix: raise kotlin version for example 1.6.10 refuses to run with recent flutter versions --- example/android/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/android/build.gradle b/example/android/build.gradle index 8764a91..713d7f6 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -1,5 +1,5 @@ buildscript { - ext.kotlin_version = '1.6.10' + ext.kotlin_version = '1.7.10' repositories { google() mavenCentral() From 58d46abbbae96f2fe2d97eb8fb56b8c2866df05a Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Thu, 29 Aug 2024 11:52:02 +0200 Subject: [PATCH 06/21] Ensure board background is constrained to size of board --- lib/src/widgets/board.dart | 6 +++++- test/widgets/board_test.dart | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/src/widgets/board.dart b/lib/src/widgets/board.dart index bf05e07..f767701 100644 --- a/lib/src/widgets/board.dart +++ b/lib/src/widgets/board.dart @@ -158,7 +158,11 @@ class _BoardState extends State { : colorScheme.background; final List highlightedBackground = [ - background, + SizedBox.square( + key: const ValueKey('board-background'), + dimension: widget.size, + child: background, + ), if (widget.settings.showLastMove && widget.state.lastMove != null) for (final square in widget.state.lastMove!.squares) if (premove == null || !premove.hasSquare(square)) diff --git a/test/widgets/board_test.dart b/test/widgets/board_test.dart index 3dfcb5a..06cb4de 100644 --- a/test/widgets/board_test.dart +++ b/test/widgets/board_test.dart @@ -41,6 +41,18 @@ void main() { expect(find.byKey(const Key('e2-selected')), findsNothing); }); + + testWidgets('background is constrained to the size of the board', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(viewOnlyBoard); + + final background = tester.widget( + find.byKey(const Key('board-background')), + ); + expect(background.width, boardSize); + expect(background.height, boardSize); + }); }); group('Interactable board', () { From 964d03d4e5a31910cfd9b0eaaab7cf5e7b0e154d Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Thu, 29 Aug 2024 13:26:19 +0200 Subject: [PATCH 07/21] More wip: lift promotion state up --- example/lib/main.dart | 57 ++++++++++++++++----- example/pubspec.lock | 14 ++--- lib/src/board_state.dart | 17 +++++- lib/src/widgets/board.dart | 94 +++++++++++++--------------------- lib/src/widgets/promotion.dart | 8 +-- test/widgets/board_test.dart | 64 +++++++++++++++++------ 6 files changed, 156 insertions(+), 98 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 1cb62f3..d33163c 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -61,6 +61,7 @@ class _HomePageState extends State { Side orientation = Side.white; String fen = kInitialBoardFEN; NormalMove? lastMove; + NormalMove? promotionMove; NormalMove? premove; ValidMoves validMoves = IMap(const {}); Side sideToMove = Side.white; @@ -321,11 +322,14 @@ class _HomePageState extends State { lastMove: lastMove, sideToMove: position.turn == Side.white ? Side.white : Side.black, isCheck: position.isCheck, + promotionMove: promotionMove, premove: premove, shapes: shapes.isNotEmpty ? shapes : null, ), onMove: playMode == Mode.botPlay ? _onUserMoveAgainstBot : _playMove, + onPromotionSelect: _onPromotionSelect, + onPromotionCancel: _onPromotionCancel, onSetPremove: _onSetPremove, ), Column( @@ -409,15 +413,36 @@ class _HomePageState extends State { super.initState(); } - void _onSetPremove(NormalMove? move) { + void _onSetPremove(NormalMove? move, {bool? shouldPromote}) { setState(() { premove = move; }); } - void _playMove(NormalMove move, {bool? isDrop, bool? isPremove}) { + void _onPromotionSelect(Role role) { + if (promotionMove != null) { + if (playMode == Mode.botPlay) { + _onUserMoveAgainstBot(promotionMove!.withPromotion(role)); + } else { + _playMove(promotionMove!.withPromotion(role)); + } + } + } + + void _onPromotionCancel() { + setState(() { + promotionMove = null; + }); + } + + void _playMove(NormalMove move, + {bool? isDrop, bool? isPremove, bool? shouldPromote}) { lastPos = position; - if (position.isLegal(move)) { + if (shouldPromote == true) { + setState(() { + promotionMove = move; + }); + } else if (position.isLegal(move)) { setState(() { position = position.playUnchecked(move); lastMove = move; @@ -426,21 +451,29 @@ class _HomePageState extends State { if (isPremove == true) { premove = null; } + promotionMove = null; }); } } void _onUserMoveAgainstBot(NormalMove move, - {bool? isDrop, bool? isPremove}) async { + {bool? isDrop, bool? shouldPromote}) async { lastPos = position; - setState(() { - position = position.playUnchecked(move); - lastMove = move; - fen = position.fen; - validMoves = IMap(const {}); - }); - await _playBlackMove(); - _tryPlayPremove(); + if (shouldPromote == true) { + setState(() { + promotionMove = move; + }); + } else { + setState(() { + position = position.playUnchecked(move); + lastMove = move; + fen = position.fen; + validMoves = IMap(const {}); + promotionMove = null; + }); + await _playBlackMove(); + _tryPlayPremove(); + } } Future _playBlackMove() async { diff --git a/example/pubspec.lock b/example/pubspec.lock index 45d8cb1..92e9149 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -44,10 +44,10 @@ packages: dependency: transitive description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" cupertino_icons: dependency: "direct main" description: @@ -195,10 +195,10 @@ packages: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" term_glyph: dependency: transitive description: @@ -211,10 +211,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.3" vector_math: dependency: transitive description: @@ -232,5 +232,5 @@ packages: source: hosted version: "14.2.4" sdks: - dart: ">=3.3.0 <4.0.0" + dart: ">=3.4.0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/lib/src/board_state.dart b/lib/src/board_state.dart index e967d2d..acfdbd5 100644 --- a/lib/src/board_state.dart +++ b/lib/src/board_state.dart @@ -18,6 +18,7 @@ abstract class ChessboardState { required String fen, bool opponentsPiecesUpsideDown, Side? sideToMove, + NormalMove? promotionMove, NormalMove? premove, NormalMove? lastMove, ValidMoves? validMoves, @@ -32,6 +33,7 @@ abstract class ChessboardState { required this.fen, required this.opponentsPiecesUpsideDown, this.sideToMove, + this.promotionMove, this.premove, this.lastMove, this.validMoves, @@ -61,6 +63,11 @@ abstract class ChessboardState { /// FEN string describing the position of the board. final String fen; + /// A pawn move that should be promoted. + /// + /// Setting this will show a promotion dialog. + final NormalMove? promotionMove; + /// Registered premove. /// /// Will be shown on the board as a preview move. @@ -122,8 +129,9 @@ abstract class ChessboardState { String? fen, bool? opponentsPiecesUpsideDown, Side? sideToMove, - Move? premove, - Move? lastMove, + NormalMove? promotionMove, + NormalMove? premove, + NormalMove? lastMove, ValidMoves? validMoves, bool? isCheck, ISet? shapes, @@ -139,6 +147,7 @@ class _ChessboardState extends ChessboardState { super.opponentsPiecesUpsideDown = false, super.sideToMove, super.premove, + super.promotionMove, super.lastMove, super.validMoves, super.isCheck, @@ -153,6 +162,7 @@ class _ChessboardState extends ChessboardState { String? fen, bool? opponentsPiecesUpsideDown, Object? sideToMove = _Undefined, + Object? promotionMove = _Undefined, Object? premove = _Undefined, Object? lastMove = _Undefined, Object? validMoves = _Undefined, @@ -168,6 +178,9 @@ class _ChessboardState extends ChessboardState { fen: fen ?? this.fen, sideToMove: sideToMove == _Undefined ? this.sideToMove : sideToMove as Side?, + promotionMove: promotionMove == _Undefined + ? this.promotionMove + : promotionMove as NormalMove?, premove: premove == _Undefined ? this.premove : premove as NormalMove?, lastMove: lastMove == _Undefined ? this.lastMove : lastMove as NormalMove?, diff --git a/lib/src/widgets/board.dart b/lib/src/widgets/board.dart index f767701..683db90 100644 --- a/lib/src/widgets/board.dart +++ b/lib/src/widgets/board.dart @@ -36,6 +36,8 @@ class Chessboard extends StatefulWidget with ChessboardGeometry { this.settings = const ChessboardSettings(), this.onMove, this.onSetPremove, + this.onPromotionSelect, + this.onPromotionCancel, }); @override @@ -51,12 +53,27 @@ class Chessboard extends StatefulWidget with ChessboardGeometry { final ChessboardState state; /// Callback called after a move has been made. - final void Function(NormalMove, {bool? isDrop})? onMove; + /// + /// If the move is a pawn move that should trigger a promotion, `shouldPromote` will be true. + /// + /// If the move has been made with drag-n-drop, `isDrop` will be true. + final void Function(NormalMove, {bool? isDrop, bool? shouldPromote})? onMove; + + /// Callback called after a piece has been selected for promotion. + /// + /// The move is guaranteed to be a promotion move. + final void Function(Role)? onPromotionSelect; + + /// Callback called after a promotion has been canceled. + final void Function()? onPromotionCancel; /// Callback called after a premove has been set/unset. /// /// If the callback is null, the board will not allow premoves. - final void Function(NormalMove?)? onSetPremove; + /// + /// If the premove is a pawn move that should trigger a promotion, `shouldPromote` + /// will be true. This is useful to show a promotion selector to the user. + final void Function(NormalMove?, {bool? shouldPromote})? onSetPremove; @override // ignore: library_private_types_in_public_api @@ -78,9 +95,6 @@ class _BoardState extends State { /// Currently selected square. Square? selected; - /// Move currently being promoted - NormalMove? _promotionMove; - /// Last move that was played using drag and drop. NormalMove? _lastDrop; @@ -342,17 +356,20 @@ class _BoardState extends State { else ...highlightedBackground, ...objects, - if (_promotionMove != null && widget.state.sideToMove != null) + if (widget.state.promotionMove != null && + widget.onPromotionSelect != null && + widget.onPromotionCancel != null && + widget.state.sideToMove != null) PromotionSelector( pieceAssets: widget.settings.pieceAssets, - move: _promotionMove!, + move: widget.state.promotionMove!, size: widget.size, color: widget.state.sideToMove!, orientation: widget.state.orientation, piecesUpsideDown: widget.state.opponentsPiecesUpsideDown && widget.state.sideToMove! != widget.state.orientation, - onSelect: _onPromotionSelect, - onCancel: _onPromotionCancel, + onSelect: widget.onPromotionSelect!, + onCancel: widget.onPromotionCancel!, ), ], ), @@ -391,7 +408,6 @@ class _BoardState extends State { } if (oldBoard.state.sideToMove != widget.state.sideToMove) { _premoveDests = null; - _promotionMove = null; } if (oldBoard.state.fen == widget.state.fen) { _lastDrop = null; @@ -401,24 +417,6 @@ class _BoardState extends State { final newPieces = readFen(widget.state.fen); - // Handles premove promotion, where we don't want auto queen promotion. - // If the premove is a pawn move to the last rank, open the promotion selector - // to allow the user to select the promotion piece. - // In order to work, the library user must NOT play the move AND keep the - // `premove` field set. - final premove = widget.state.premove; - if (premove != null && - widget.state.interactableSide.name == widget.state.sideToMove?.name) { - final piece = newPieces[premove.from]; - if (piece != null && - piece.role == Role.pawn && - (premove.to.rank == Rank.eighth || premove.to.rank == Rank.first)) { - final pawn = newPieces.remove(premove.from); - newPieces[premove.to] = pawn!; - _promotionMove = premove; - } - } - if (widget.settings.animationDuration > Duration.zero) { _preparePieceAnimations(newPieces); } @@ -804,29 +802,6 @@ class _BoardState extends State { _shouldCancelPremoveOnTapUp = false; } - void _onPromotionSelect(NormalMove move, Piece promoted) { - setState(() { - pieces[move.to] = promoted; - _promotionMove = null; - }); - widget.onMove?.call(move.withPromotion(promoted.role), isDrop: true); - } - - void _onPromotionCancel(Move move) { - setState(() { - pieces = readFen(widget.state.fen); - _promotionMove = null; - }); - } - - void _openPromotionSelector(NormalMove move) { - setState(() { - final pawn = pieces.remove(move.from); - pieces[move.to] = pawn!; - _promotionMove = move; - }); - } - /// Whether the piece should be displayed upside down, according to the /// widget settings. bool _isUpsideDown(Piece piece) { @@ -887,7 +862,7 @@ class _BoardState extends State { if (widget.settings.autoQueenPromotion) { widget.onMove?.call(move.withPromotion(Role.queen), isDrop: drop); } else { - _openPromotionSelector(move); + widget.onMove?.call(move, isDrop: drop, shouldPromote: true); } } else { widget.onMove?.call(move, isDrop: drop); @@ -895,11 +870,16 @@ class _BoardState extends State { return true; } else if (_isPremovable(selectedPiece) && _canPremoveTo(selected!, square)) { - final premove = widget.settings.autoQueenPromotionOnPremove && - _isPromoMove(selectedPiece!, square) - ? NormalMove(from: selected!, to: square, promotion: Role.queen) - : NormalMove(from: selected!, to: square); - widget.onSetPremove?.call(premove); + final isPromoPremove = _isPromoMove(selectedPiece!, square); + final premove = + widget.settings.autoQueenPromotionOnPremove && isPromoPremove + ? NormalMove(from: selected!, to: square, promotion: Role.queen) + : NormalMove(from: selected!, to: square); + widget.onSetPremove?.call( + premove, + shouldPromote: + !widget.settings.autoQueenPromotionOnPremove && isPromoPremove, + ); return true; } return false; diff --git a/lib/src/widgets/promotion.dart b/lib/src/widgets/promotion.dart index 2707292..5e57ec1 100644 --- a/lib/src/widgets/promotion.dart +++ b/lib/src/widgets/promotion.dart @@ -42,10 +42,10 @@ class PromotionSelector extends StatelessWidget with ChessboardGeometry { final bool piecesUpsideDown; /// Callback when a piece is selected. - final void Function(NormalMove, Piece) onSelect; + final void Function(Role) onSelect; /// Callback when the promotion is canceled. - final void Function(NormalMove) onCancel; + final void Function() onCancel; /// The square the pawn is moving to. Square get square => move.to; @@ -64,7 +64,7 @@ class PromotionSelector extends StatelessWidget with ChessboardGeometry { final offset = squareOffset(anchorSquare); return GestureDetector( - onTap: () => onCancel(move), + onTap: () => onCancel(), child: Container( width: double.infinity, height: double.infinity, @@ -100,7 +100,7 @@ class PromotionSelector extends StatelessWidget with ChessboardGeometry { ), ].map((Piece piece) { return GestureDetector( - onTap: () => onSelect(move, piece), + onTap: () => onSelect(piece.role), child: Stack( children: [ Container( diff --git a/test/widgets/board_test.dart b/test/widgets/board_test.dart index 06cb4de..fd551b8 100644 --- a/test/widgets/board_test.dart +++ b/test/widgets/board_test.dart @@ -468,6 +468,20 @@ void main() { }); group('Promotion', () { + testWidgets('can display the selector', (WidgetTester tester) async { + await tester.pumpWidget( + buildBoard( + initialInteractableSide: InteractableSide.both, + initialFen: '8/5P2/2RK2P1/8/4k3/8/8/7r w - - 0 1', + initialPromotionMove: + const NormalMove(from: Square.f7, to: Square.f8), + ), + ); + + // promotion dialog is shown + expect(find.byType(PromotionSelector), findsOneWidget); + }); + testWidgets('promote a knight', (WidgetTester tester) async { await tester.pumpWidget( buildBoard( @@ -481,12 +495,12 @@ void main() { await tester.tapAt(squareOffset(Square.f8)); await tester.pump(); - // promotion dialog is shown + // wait for promotion selector to show + await tester.pump(); expect(find.byType(PromotionSelector), findsOneWidget); - // pawn is on the eighth rank - expect(find.byKey(const Key('f8-whitepawn')), findsOneWidget); - expect(find.byKey(const Key('f7-whitepawn')), findsNothing); + // pawn is still on the seventh rank + expect(find.byKey(const Key('f7-whitepawn')), findsOneWidget); // tap on the knight await tester.tapAt(squareOffset(Square.f7)); @@ -508,13 +522,10 @@ void main() { await tester.tapAt(squareOffset(Square.f8)); await tester.pump(); - // promotion dialog is shown + // wait for promotion selector to show + await tester.pump(); expect(find.byType(PromotionSelector), findsOneWidget); - // pawn is on the eighth rank - expect(find.byKey(const Key('f8-whitepawn')), findsOneWidget); - expect(find.byKey(const Key('f7-whitepawn')), findsNothing); - // tap outside the promotion dialog await tester.tapAt(squareOffset(Square.c4)); @@ -522,7 +533,6 @@ void main() { // promotion dialog is closed, move is cancelled expect(find.byType(PromotionSelector), findsNothing); - expect(find.byKey(const Key('f8-whitepawn')), findsNothing); expect(find.byKey(const Key('f7-whitepawn')), findsOneWidget); }); @@ -1165,6 +1175,7 @@ Widget buildBoard({ ChessboardSettings? settings, Side orientation = Side.white, String initialFen = kInitialFEN, + NormalMove? initialPromotionMove, ISet? initialShapes, bool enableDrawingShapes = false, PieceShiftMethod pieceShiftMethod = PieceShiftMethod.either, @@ -1176,8 +1187,17 @@ Widget buildBoard({ Position position = Chess.fromSetup(Setup.parseFen(initialFen)); NormalMove? lastMove; NormalMove? premove; + NormalMove? promotionMove = initialPromotionMove; ISet shapes = initialShapes ?? ISet(); + void playMove(NormalMove move) { + position = position.playUnchecked(move); + if (position.isGameOver) { + interactableSide = InteractableSide.none; + } + lastMove = move; + } + return MaterialApp( home: StatefulBuilder( builder: (BuildContext context, StateSetter setState) { @@ -1206,16 +1226,17 @@ Widget buildBoard({ isCheck: position.isCheck, sideToMove: position.turn == Side.white ? Side.white : Side.black, validMoves: makeLegalMoves(position), + promotionMove: promotionMove, premove: premove, shapes: shapes, ), - onMove: (NormalMove move, {bool? isDrop, bool? isPremove}) { + onMove: (NormalMove move, {bool? isDrop, bool? shouldPromote}) { setState(() { - position = position.playUnchecked(NormalMove.fromUci(move.uci)); - if (position.isGameOver) { - interactableSide = InteractableSide.none; + if (shouldPromote == true) { + promotionMove = move; + } else { + playMove(move); } - lastMove = move; }); if (shouldPlayOpponentMove) { @@ -1253,7 +1274,18 @@ Widget buildBoard({ }); } }, - onSetPremove: (NormalMove? move) { + onPromotionSelect: (Role role) { + setState(() { + playMove(promotionMove!.withPromotion(role)); + promotionMove = null; + }); + }, + onPromotionCancel: () { + setState(() { + promotionMove = null; + }); + }, + onSetPremove: (NormalMove? move, {bool? shouldPromote}) { setState(() { premove = move; }); From 57bad09c37f6f1beaa3b1976c78a550787302162 Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Thu, 29 Aug 2024 13:36:13 +0200 Subject: [PATCH 08/21] Wrap chessboard in Align in tests --- test/widgets/board_test.dart | 145 ++++++++++++++++++----------------- 1 file changed, 74 insertions(+), 71 deletions(-) diff --git a/test/widgets/board_test.dart b/test/widgets/board_test.dart index fd551b8..feb3b5f 100644 --- a/test/widgets/board_test.dart +++ b/test/widgets/board_test.dart @@ -1215,81 +1215,84 @@ Widget buildBoard({ pieceShiftMethod: pieceShiftMethod, ); - return Chessboard( - size: boardSize, - settings: settings ?? defaultSettings, - state: ChessboardState( - interactableSide: interactableSide, - orientation: orientation, - fen: position.fen, - lastMove: lastMove, - isCheck: position.isCheck, - sideToMove: position.turn == Side.white ? Side.white : Side.black, - validMoves: makeLegalMoves(position), - promotionMove: promotionMove, - premove: premove, - shapes: shapes, - ), - onMove: (NormalMove move, {bool? isDrop, bool? shouldPromote}) { - setState(() { - if (shouldPromote == true) { - promotionMove = move; - } else { - playMove(move); - } - }); - - if (shouldPlayOpponentMove) { - Timer(const Duration(milliseconds: 200), () { - final allMoves = [ - for (final entry in position.legalMoves.entries) - for (final dest in entry.value.squares) - NormalMove(from: entry.key, to: dest), - ]; - final opponentMove = allMoves.first; - setState(() { - position = position.playUnchecked(opponentMove); - if (position.isGameOver) { - interactableSide = InteractableSide.none; - } - lastMove = NormalMove.fromUci(opponentMove.uci); - }); + return Align( + alignment: Alignment.topLeft, + child: Chessboard( + size: boardSize, + settings: settings ?? defaultSettings, + state: ChessboardState( + interactableSide: interactableSide, + orientation: orientation, + fen: position.fen, + lastMove: lastMove, + isCheck: position.isCheck, + sideToMove: position.turn == Side.white ? Side.white : Side.black, + validMoves: makeLegalMoves(position), + promotionMove: promotionMove, + premove: premove, + shapes: shapes, + ), + onMove: (NormalMove move, {bool? isDrop, bool? shouldPromote}) { + setState(() { + if (shouldPromote == true) { + promotionMove = move; + } else { + playMove(move); + } + }); - if (premove != null && position.isLegal(premove!)) { - // if premove autoqueen if off, detect pawn promotion - final isPawnPromotion = premove!.promotion == null && - position.board.roleAt(premove!.from) == Role.pawn && - (premove!.to.rank == Rank.first || - premove!.to.rank == Rank.eighth); - - if (!isPawnPromotion) { - Timer.run(() { - setState(() { - position = position.playUnchecked(premove!); - premove = null; + if (shouldPlayOpponentMove) { + Timer(const Duration(milliseconds: 200), () { + final allMoves = [ + for (final entry in position.legalMoves.entries) + for (final dest in entry.value.squares) + NormalMove(from: entry.key, to: dest), + ]; + final opponentMove = allMoves.first; + setState(() { + position = position.playUnchecked(opponentMove); + if (position.isGameOver) { + interactableSide = InteractableSide.none; + } + lastMove = NormalMove.fromUci(opponentMove.uci); + }); + + if (premove != null && position.isLegal(premove!)) { + // if premove autoqueen if off, detect pawn promotion + final isPawnPromotion = premove!.promotion == null && + position.board.roleAt(premove!.from) == Role.pawn && + (premove!.to.rank == Rank.first || + premove!.to.rank == Rank.eighth); + + if (!isPawnPromotion) { + Timer.run(() { + setState(() { + position = position.playUnchecked(premove!); + premove = null; + }); }); - }); + } } - } + }); + } + }, + onPromotionSelect: (Role role) { + setState(() { + playMove(promotionMove!.withPromotion(role)); + promotionMove = null; + }); + }, + onPromotionCancel: () { + setState(() { + promotionMove = null; }); - } - }, - onPromotionSelect: (Role role) { - setState(() { - playMove(promotionMove!.withPromotion(role)); - promotionMove = null; - }); - }, - onPromotionCancel: () { - setState(() { - promotionMove = null; - }); - }, - onSetPremove: (NormalMove? move, {bool? shouldPromote}) { - setState(() { - premove = move; - }); - }, + }, + onSetPremove: (NormalMove? move, {bool? shouldPromote}) { + setState(() { + premove = move; + }); + }, + ), ); }, ), From ad938381f8cdad40d1c8031f11a389de98464ae0 Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Fri, 30 Aug 2024 11:25:12 +0200 Subject: [PATCH 09/21] Refactor Chessboard API --- example/lib/board_thumbnails.dart | 9 +- example/lib/main.dart | 34 +++-- lib/chessground.dart | 1 - lib/src/board_state.dart | 199 ------------------------- lib/src/models.dart | 88 ++++++++++- lib/src/widgets/board.dart | 207 ++++++++++++++------------ test/board_data_test.dart | 141 ------------------ test/widgets/board_test.dart | 235 +++++++++++++++--------------- 8 files changed, 335 insertions(+), 579 deletions(-) delete mode 100644 lib/src/board_state.dart delete mode 100644 test/board_data_test.dart diff --git a/example/lib/board_thumbnails.dart b/example/lib/board_thumbnails.dart index aa2c1ef..796f2e1 100644 --- a/example/lib/board_thumbnails.dart +++ b/example/lib/board_thumbnails.dart @@ -19,7 +19,7 @@ class BoardThumbnailsPage extends StatelessWidget { children: [ for (final fen in positions) LayoutBuilder(builder: (context, constraints) { - return Chessboard( + return Chessboard.fixed( size: constraints.biggest.width, settings: ChessboardSettings( enableCoordinates: false, @@ -43,11 +43,8 @@ class BoardThumbnailsPage extends StatelessWidget { ), ], ), - state: ChessboardState( - interactableSide: InteractableSide.none, - orientation: Side.white, - fen: fen, - ), + orientation: Side.white, + fen: fen, ); }), ], diff --git a/example/lib/main.dart b/example/lib/main.dart index d33163c..7af979d 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -308,29 +308,31 @@ class _HomePageState extends State { pieceShiftMethod: pieceShiftMethod, autoQueenPromotionOnPremove: false, ), - state: ChessboardState( - interactableSide: + orientation: orientation, + opponentsPiecesUpsideDown: playMode == Mode.freePlay, + fen: fen, + lastMove: lastMove, + game: GameState( + playerSide: (playMode == Mode.botPlay || playMode == Mode.inputMove) - ? InteractableSide.white + ? PlayerSide.white : (position.turn == Side.white - ? InteractableSide.white - : InteractableSide.black), + ? PlayerSide.white + : PlayerSide.black), validMoves: validMoves, - orientation: orientation, - opponentsPiecesUpsideDown: playMode == Mode.freePlay, - fen: fen, - lastMove: lastMove, sideToMove: position.turn == Side.white ? Side.white : Side.black, isCheck: position.isCheck, promotionMove: promotionMove, - premove: premove, - shapes: shapes.isNotEmpty ? shapes : null, + onMove: + playMode == Mode.botPlay ? _onUserMoveAgainstBot : _playMove, + onPromotionSelect: _onPromotionSelect, + onPromotionCancel: _onPromotionCancel, + premovable: ( + onSetPremove: _onSetPremove, + premove: premove, + ), ), - onMove: - playMode == Mode.botPlay ? _onUserMoveAgainstBot : _playMove, - onPromotionSelect: _onPromotionSelect, - onPromotionCancel: _onPromotionCancel, - onSetPremove: _onSetPremove, + shapes: shapes.isNotEmpty ? shapes : null, ), Column( crossAxisAlignment: CrossAxisAlignment.center, diff --git a/lib/chessground.dart b/lib/chessground.dart index a8e7af3..1ca111f 100644 --- a/lib/chessground.dart +++ b/lib/chessground.dart @@ -6,7 +6,6 @@ library chessground; export 'src/board_color_scheme.dart'; export 'src/draw_shape_options.dart'; export 'src/board_settings.dart'; -export 'src/board_state.dart'; export 'src/fen.dart'; export 'src/models.dart'; export 'src/piece_set.dart'; diff --git a/lib/src/board_state.dart b/lib/src/board_state.dart deleted file mode 100644 index acfdbd5..0000000 --- a/lib/src/board_state.dart +++ /dev/null @@ -1,199 +0,0 @@ -import 'package:dartchess/dartchess.dart'; -import 'package:flutter/widgets.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; - -import 'models.dart'; - -/// The chessboard state. -/// -/// This state should be updated after every move during a game. -/// -/// To make a fixed board, set [interactableSide] to [InteractableSide.none]. -@immutable -abstract class ChessboardState { - /// Creates a new [ChessboardState] with the provided values. - const factory ChessboardState({ - required InteractableSide interactableSide, - required Side orientation, - required String fen, - bool opponentsPiecesUpsideDown, - Side? sideToMove, - NormalMove? promotionMove, - NormalMove? premove, - NormalMove? lastMove, - ValidMoves? validMoves, - bool? isCheck, - ISet? shapes, - IMap? annotations, - }) = _ChessboardState; - - const ChessboardState._({ - required this.interactableSide, - required this.orientation, - required this.fen, - required this.opponentsPiecesUpsideDown, - this.sideToMove, - this.promotionMove, - this.premove, - this.lastMove, - this.validMoves, - this.isCheck, - this.shapes, - this.annotations, - }) : assert( - (isCheck == null && interactableSide == InteractableSide.none) || - sideToMove != null, - 'sideToMove must be set when isCheck is set, or when the board is interactable.', - ); - - /// Which color is allowed to move? It can be both, none, white or black. - /// - /// If `none` is chosen the board will be non interactable. - final InteractableSide interactableSide; - - /// Side by which the board is oriented. - final Side orientation; - - /// If `true` the opponent`s pieces are displayed rotated by 180 degrees. - final bool opponentsPiecesUpsideDown; - - /// Side which is to move. - final Side? sideToMove; - - /// FEN string describing the position of the board. - final String fen; - - /// A pawn move that should be promoted. - /// - /// Setting this will show a promotion dialog. - final NormalMove? promotionMove; - - /// Registered premove. - /// - /// Will be shown on the board as a preview move. - /// - /// Chessground will not play the premove automatically, it is up to the library user to play it. - final NormalMove? premove; - - /// Last move played, used to highlight corresponding squares. - final NormalMove? lastMove; - - /// Set of moves allowed to be played by current side to move. - final ValidMoves? validMoves; - - /// Highlight the king of current side to move - final bool? isCheck; - - /// Optional set of [Shape] to be drawn on the board. - final ISet? shapes; - - /// Move annotations to be displayed on the board. - final IMap? annotations; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is ChessboardState && - runtimeType == other.runtimeType && - interactableSide == other.interactableSide && - orientation == other.orientation && - opponentsPiecesUpsideDown == other.opponentsPiecesUpsideDown && - sideToMove == other.sideToMove && - fen == other.fen && - premove == other.premove && - lastMove == other.lastMove && - validMoves == other.validMoves && - isCheck == other.isCheck && - shapes == other.shapes && - annotations == other.annotations; - - @override - int get hashCode => Object.hash( - interactableSide, - orientation, - opponentsPiecesUpsideDown, - sideToMove, - fen, - premove, - lastMove, - validMoves, - isCheck, - shapes, - annotations, - ); - - /// Creates a copy of this [ChessboardState] but with the given fields replaced with the new values. - ChessboardState copyWith({ - InteractableSide? interactableSide, - Side? orientation, - String? fen, - bool? opponentsPiecesUpsideDown, - Side? sideToMove, - NormalMove? promotionMove, - NormalMove? premove, - NormalMove? lastMove, - ValidMoves? validMoves, - bool? isCheck, - ISet? shapes, - IMap? annotations, - }); -} - -class _ChessboardState extends ChessboardState { - const _ChessboardState({ - required super.interactableSide, - required super.orientation, - required super.fen, - super.opponentsPiecesUpsideDown = false, - super.sideToMove, - super.premove, - super.promotionMove, - super.lastMove, - super.validMoves, - super.isCheck, - super.shapes, - super.annotations, - }) : super._(); - - @override - ChessboardState copyWith({ - InteractableSide? interactableSide, - Side? orientation, - String? fen, - bool? opponentsPiecesUpsideDown, - Object? sideToMove = _Undefined, - Object? promotionMove = _Undefined, - Object? premove = _Undefined, - Object? lastMove = _Undefined, - Object? validMoves = _Undefined, - Object? isCheck = _Undefined, - Object? shapes = _Undefined, - Object? annotations = _Undefined, - }) { - return ChessboardState( - interactableSide: interactableSide ?? this.interactableSide, - orientation: orientation ?? this.orientation, - opponentsPiecesUpsideDown: - opponentsPiecesUpsideDown ?? this.opponentsPiecesUpsideDown, - fen: fen ?? this.fen, - sideToMove: - sideToMove == _Undefined ? this.sideToMove : sideToMove as Side?, - promotionMove: promotionMove == _Undefined - ? this.promotionMove - : promotionMove as NormalMove?, - premove: premove == _Undefined ? this.premove : premove as NormalMove?, - lastMove: - lastMove == _Undefined ? this.lastMove : lastMove as NormalMove?, - validMoves: validMoves == _Undefined - ? this.validMoves - : validMoves as ValidMoves?, - isCheck: isCheck == _Undefined ? this.isCheck : isCheck as bool?, - shapes: shapes == _Undefined ? this.shapes : shapes as ISet?, - annotations: annotations == _Undefined - ? this.annotations - : annotations as IMap?, - ); - } -} - -class _Undefined {} diff --git a/lib/src/models.dart b/lib/src/models.dart index 9b3ac44..5228447 100644 --- a/lib/src/models.dart +++ b/lib/src/models.dart @@ -3,7 +3,93 @@ import 'package:flutter/widgets.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; /// The side that can interact with the board. -enum InteractableSide { both, none, white, black } +enum PlayerSide { + /// No side can interact with the board. + none, + + /// Both sides can interact with the board. + /// + /// This is used for games where both players can move the pieces as in over-the-board games. + both, + + /// Only white side can interact with the board. + white, + + /// Only black side can interact with the board. + black; +} + +/// State of a game in an interactive chessboard. +@immutable +class GameState { + /// Creates a new game state. + const GameState({ + required this.playerSide, + required this.sideToMove, + required this.validMoves, + required this.onMove, + required this.onPromotionSelect, + required this.onPromotionCancel, + this.promotionMove, + this.isCheck, + this.premovable, + }); + + /// Side that is allowed to move. + final PlayerSide playerSide; + + /// Side which is to move. + final Side sideToMove; + + /// A pawn move that should be promoted. + /// + /// Setting this will show a promotion dialog. + final NormalMove? promotionMove; + + /// Highlight the king of current side to move + final bool? isCheck; + + /// Set of moves allowed to be played by current side to move. + final ValidMoves validMoves; + + /// Callback called after a move has been made. + /// + /// If the move is a pawn move that should trigger a promotion, `shouldPromote` will be true. + /// + /// If the move has been made with drag-n-drop, `isDrop` will be true. + final void Function(NormalMove, {bool? isDrop, bool? shouldPromote}) onMove; + + /// Callback called after a piece has been selected for promotion. + /// + /// The move is guaranteed to be a promotion move. + final void Function(Role) onPromotionSelect; + + /// Callback called after a promotion has been canceled. + final void Function() onPromotionCancel; + + /// Optional premovable state of the board. + /// + /// If `null`, the board will not allow premoves. + final Premovable? premovable; +} + +/// State of a premovable chessboard. +typedef Premovable = ({ + /// Registered premove. + /// + /// Will be shown on the board as a preview move. + /// + /// Chessground will not play the premove automatically, it is up to the library user to play it. + NormalMove? premove, + + /// Callback called after a premove has been set/unset. + /// + /// If the callback is null, the board will not allow premoves. + /// + /// If the premove is a pawn move that should trigger a promotion, `shouldPromote` + /// will be true. This is useful to show a promotion selector to the user. + void Function(NormalMove?, {bool? shouldPromote}) onSetPremove, +}); /// Describes a set of piece assets. /// diff --git a/lib/src/widgets/board.dart b/lib/src/widgets/board.dart index 683db90..7544fe3 100644 --- a/lib/src/widgets/board.dart +++ b/lib/src/widgets/board.dart @@ -16,7 +16,6 @@ import '../models.dart'; import '../fen.dart'; import '../premove.dart'; import '../board_settings.dart'; -import '../board_state.dart'; /// Number of logical pixels that have to be dragged before a drag starts. const double _kDragDistanceThreshold = 3.0; @@ -25,55 +24,71 @@ const _kCancelShapesDoubleTapDelay = Duration(milliseconds: 200); /// A chessboard widget. /// -/// This widget can be used to display a static board, a dynamic board that -/// shows a live game, or a full user interactable board. +/// This widget can be used to display a static board or a full interactive board. class Chessboard extends StatefulWidget with ChessboardGeometry { - /// Creates a new chessboard widget. + /// Creates a new chessboard widget with interactive pieces. + /// + /// Provide a [game] state to enable interaction with the board. + /// The [fen] string should be updated when the position changes. const Chessboard({ super.key, required this.size, - required this.state, this.settings = const ChessboardSettings(), - this.onMove, - this.onSetPremove, - this.onPromotionSelect, - this.onPromotionCancel, + required this.orientation, + required this.fen, + this.opponentsPiecesUpsideDown = false, + this.lastMove, + required this.game, + this.shapes, + this.annotations, }); + /// Creates a new chessboard widget with fixed pieces. + /// + /// Provide a [fen] string to describe the position of the pieces on the board. + /// Pieces will be animated when the position changes. + const Chessboard.fixed({ + super.key, + required this.size, + this.settings = const ChessboardSettings(), + required this.orientation, + required this.fen, + this.lastMove, + this.shapes, + this.annotations, + }) : game = null, + opponentsPiecesUpsideDown = false; + + /// Size of the board in logical pixels. @override final double size; + /// Side by which the board is oriented. @override - Side get orientation => state.orientation; + final Side orientation; - /// Settings that control the theme, behavior and purpose of the board. + /// Settings that control the theme and behavior of the board. final ChessboardSettings settings; - /// Current state of the board. - final ChessboardState state; + /// If `true` the opponent`s pieces are displayed rotated by 180 degrees. + final bool opponentsPiecesUpsideDown; - /// Callback called after a move has been made. - /// - /// If the move is a pawn move that should trigger a promotion, `shouldPromote` will be true. - /// - /// If the move has been made with drag-n-drop, `isDrop` will be true. - final void Function(NormalMove, {bool? isDrop, bool? shouldPromote})? onMove; + /// FEN string describing the position of the board. + final String fen; + + /// Last move played, used to highlight corresponding squares. + final NormalMove? lastMove; - /// Callback called after a piece has been selected for promotion. + /// Game state of the board. /// - /// The move is guaranteed to be a promotion move. - final void Function(Role)? onPromotionSelect; + /// If `null`, the board cannot be interacted with. + final GameState? game; - /// Callback called after a promotion has been canceled. - final void Function()? onPromotionCancel; + /// Optional set of [Shape] to be drawn on the board. + final ISet? shapes; - /// Callback called after a premove has been set/unset. - /// - /// If the callback is null, the board will not allow premoves. - /// - /// If the premove is a pawn move that should trigger a promotion, `shouldPromote` - /// will be true. This is useful to show a promotion selector to the user. - final void Function(NormalMove?, {bool? shouldPromote})? onSetPremove; + /// Move annotations to be displayed on the board. + final IMap? annotations; @override // ignore: library_private_types_in_public_api @@ -155,18 +170,18 @@ class _BoardState extends State { final colorScheme = widget.settings.colorScheme; final ISet moveDests = widget.settings.showValidMoves && selected != null && - widget.state.validMoves != null - ? widget.state.validMoves![selected!] ?? _emptyValidMoves + widget.game?.validMoves != null + ? widget.game?.validMoves[selected!] ?? _emptyValidMoves : _emptyValidMoves; final Set premoveDests = widget.settings.showValidMoves ? _premoveDests ?? {} : {}; - final shapes = widget.state.shapes ?? _emptyShapes; - final annotations = widget.state.annotations ?? _emptyAnnotations; - final checkSquare = widget.state.isCheck == true ? _getKingSquare() : null; - final premove = widget.state.premove; + final shapes = widget.shapes ?? _emptyShapes; + final annotations = widget.annotations ?? _emptyAnnotations; + final checkSquare = widget.game?.isCheck == true ? _getKingSquare() : null; + final premove = widget.game?.premovable?.premove; final background = widget.settings.enableCoordinates - ? widget.state.orientation == Side.white + ? widget.orientation == Side.white ? colorScheme.whiteCoordBackground : colorScheme.blackCoordBackground : colorScheme.background; @@ -177,24 +192,23 @@ class _BoardState extends State { dimension: widget.size, child: background, ), - if (widget.settings.showLastMove && widget.state.lastMove != null) - for (final square in widget.state.lastMove!.squares) + if (widget.settings.showLastMove && widget.lastMove != null) + for (final square in widget.lastMove!.squares) if (premove == null || !premove.hasSquare(square)) PositionedSquare( key: ValueKey('${square.name}-lastMove'), size: widget.size, - orientation: widget.state.orientation, + orientation: widget.orientation, square: square, child: SquareHighlight(details: colorScheme.lastMove), ), if (premove != null && - widget.state.interactableSide.name == - widget.state.sideToMove?.opposite.name) + widget.game?.playerSide.name == widget.game?.sideToMove.opposite.name) for (final square in premove.squares) PositionedSquare( key: ValueKey('${square.name}-premove'), size: widget.size, - orientation: widget.state.orientation, + orientation: widget.orientation, square: square, child: SquareHighlight( details: HighlightDetails(solidColor: colorScheme.validPremoves), @@ -204,7 +218,7 @@ class _BoardState extends State { PositionedSquare( key: ValueKey('${selected!.name}-selected'), size: widget.size, - orientation: widget.state.orientation, + orientation: widget.orientation, square: selected!, child: SquareHighlight(details: colorScheme.selected), ), @@ -212,7 +226,7 @@ class _BoardState extends State { PositionedSquare( key: ValueKey('${dest.name}-dest'), size: widget.size, - orientation: widget.state.orientation, + orientation: widget.orientation, square: dest, child: ValidMoveHighlight( size: widget.squareSize, @@ -224,7 +238,7 @@ class _BoardState extends State { PositionedSquare( key: ValueKey('${dest.name}-premove-dest'), size: widget.size, - orientation: widget.state.orientation, + orientation: widget.orientation, square: dest, child: ValidMoveHighlight( size: widget.squareSize, @@ -236,7 +250,7 @@ class _BoardState extends State { PositionedSquare( key: ValueKey('${checkSquare.name}-check'), size: widget.size, - orientation: widget.state.orientation, + orientation: widget.orientation, square: checkSquare, child: CheckHighlight(size: widget.squareSize), ), @@ -247,7 +261,7 @@ class _BoardState extends State { PositionedSquare( key: ValueKey('${entry.key.name}-${entry.value}-fading'), size: widget.size, - orientation: widget.state.orientation, + orientation: widget.orientation, square: entry.key, child: AnimatedPieceFadeOut( duration: widget.settings.animationDuration, @@ -269,7 +283,7 @@ class _BoardState extends State { PositionedSquare( key: ValueKey('${entry.key.name}-${entry.value}'), size: widget.size, - orientation: widget.state.orientation, + orientation: widget.orientation, square: entry.key, child: PieceWidget( piece: entry.value, @@ -283,12 +297,12 @@ class _BoardState extends State { PositionedSquare( key: ValueKey('${entry.key.name}-${entry.value.piece}-translating'), size: widget.size, - orientation: widget.state.orientation, + orientation: widget.orientation, square: entry.key, child: AnimatedPieceTranslation( fromSquare: entry.value.from, toSquare: entry.key, - orientation: widget.state.orientation, + orientation: widget.orientation, duration: widget.settings.animationDuration, onComplete: () { setState(() { @@ -310,7 +324,7 @@ class _BoardState extends State { '${entry.key.name}-${entry.value.symbol}-${entry.value.color}', ), size: widget.size, - orientation: widget.state.orientation, + orientation: widget.orientation, square: entry.key, annotation: entry.value, ), @@ -318,18 +332,18 @@ class _BoardState extends State { ShapeWidget( shape: shape, size: widget.size, - orientation: widget.state.orientation, + orientation: widget.orientation, ), if (_shapeAvatar != null) ShapeWidget( shape: _shapeAvatar!, size: widget.size, - orientation: widget.state.orientation, + orientation: widget.orientation, ), ]; final interactable = - widget.state.interactableSide != InteractableSide.none || + (widget.game != null && widget.game!.playerSide != PlayerSide.none) || widget.settings.drawShape.enable; return Listener( @@ -356,20 +370,17 @@ class _BoardState extends State { else ...highlightedBackground, ...objects, - if (widget.state.promotionMove != null && - widget.onPromotionSelect != null && - widget.onPromotionCancel != null && - widget.state.sideToMove != null) + if (widget.game != null && widget.game?.promotionMove != null) PromotionSelector( pieceAssets: widget.settings.pieceAssets, - move: widget.state.promotionMove!, + move: widget.game!.promotionMove!, size: widget.size, - color: widget.state.sideToMove!, - orientation: widget.state.orientation, - piecesUpsideDown: widget.state.opponentsPiecesUpsideDown && - widget.state.sideToMove! != widget.state.orientation, - onSelect: widget.onPromotionSelect!, - onCancel: widget.onPromotionCancel!, + color: widget.game!.sideToMove, + orientation: widget.orientation, + piecesUpsideDown: widget.opponentsPiecesUpsideDown && + widget.game?.sideToMove != widget.orientation, + onSelect: widget.game!.onPromotionSelect, + onCancel: widget.game!.onPromotionCancel, ), ], ), @@ -380,7 +391,7 @@ class _BoardState extends State { @override void initState() { super.initState(); - pieces = readFen(widget.state.fen); + pieces = readFen(widget.fen); } @override @@ -398,7 +409,7 @@ class _BoardState extends State { _drawOrigin = null; _shapeAvatar = null; } - if (widget.state.interactableSide == InteractableSide.none) { + if (widget.game?.playerSide == PlayerSide.none) { _currentPointerDownEvent = null; _dragAvatar?.cancel(); _dragAvatar = null; @@ -406,16 +417,16 @@ class _BoardState extends State { selected = null; _premoveDests = null; } - if (oldBoard.state.sideToMove != widget.state.sideToMove) { + if (oldBoard.game?.sideToMove != widget.game?.sideToMove) { _premoveDests = null; } - if (oldBoard.state.fen == widget.state.fen) { + if (oldBoard.fen == widget.fen) { _lastDrop = null; // as long as the fen is the same as before let's keep animations return; } - final newPieces = readFen(widget.state.fen); + final newPieces = readFen(widget.fen); if (widget.settings.animationDuration > Duration.zero) { _preparePieceAnimations(newPieces); @@ -469,7 +480,7 @@ class _BoardState extends State { Square? _getKingSquare() { for (final square in pieces.keys) { - if (pieces[square]!.color == widget.state.sideToMove && + if (pieces[square]!.color == widget.game?.sideToMove && pieces[square]!.role == Role.king) { return square; } @@ -537,7 +548,7 @@ class _BoardState extends State { } } - if (widget.state.interactableSide == InteractableSide.none) return; + if (widget.game?.playerSide == PlayerSide.none) return; // From here on, we only allow 1 pointer to interact with the board. Other // pointers will cancel any current gesture. @@ -596,8 +607,8 @@ class _BoardState extends State { // pointer down on empty square: // - cancel premove // - unselect piece - else if (widget.state.premove != null) { - widget.onSetPremove?.call(null); + else if (widget.game?.premovable?.premove != null) { + widget.game?.premovable?.onSetPremove.call(null); setState(() { selected = null; _premoveDests = null; @@ -606,7 +617,8 @@ class _BoardState extends State { // there is a premove set from the touched square: // - cancel the premove on the next tap up event - if (widget.state.premove != null && widget.state.premove!.from == square) { + if (widget.game?.premovable?.premove != null && + widget.game?.premovable?.premove!.from == square) { _shouldCancelPremoveOnTapUp = true; } @@ -678,13 +690,13 @@ class _BoardState extends State { if (square != null && square != selected) { final couldMove = _tryMoveOrPremoveTo(square, drop: true); // if the premove was not possible, cancel the current premove - if (!couldMove && widget.state.premove != null) { - widget.onSetPremove?.call(null); + if (!couldMove && widget.game?.premovable?.premove != null) { + widget.game?.premovable?.onSetPremove.call(null); } } // if the user drags a piece to an empty square, cancel the premove - else if (widget.state.premove != null) { - widget.onSetPremove?.call(null); + else if (widget.game?.premovable?.premove != null) { + widget.game?.premovable?.onSetPremove.call(null); } _onDragEnd(); setState(() { @@ -704,10 +716,10 @@ class _BoardState extends State { // cancel premove if the user taps on the origin square of the premove if (_shouldCancelPremoveOnTapUp && - widget.state.premove != null && - widget.state.premove!.from == square) { + widget.game?.premovable?.premove != null && + widget.game?.premovable?.premove!.from == square) { _shouldCancelPremoveOnTapUp = false; - widget.onSetPremove?.call(null); + widget.game?.premovable?.onSetPremove.call(null); } _shouldDeselectOnTapUp = false; @@ -805,29 +817,29 @@ class _BoardState extends State { /// Whether the piece should be displayed upside down, according to the /// widget settings. bool _isUpsideDown(Piece piece) { - return widget.state.opponentsPiecesUpsideDown && - piece.color != widget.state.orientation; + return widget.opponentsPiecesUpsideDown && + piece.color != widget.orientation; } /// Whether the piece is movable by the current side to move. bool _isMovable(Piece? piece) { return piece != null && - (widget.state.interactableSide == InteractableSide.both || - widget.state.interactableSide.name == piece.color.name) && - widget.state.sideToMove == piece.color; + (widget.game?.playerSide == PlayerSide.both || + widget.game?.playerSide.name == piece.color.name) && + widget.game?.sideToMove == piece.color; } /// Whether the piece is premovable by the current side to move. bool _isPremovable(Piece? piece) { return piece != null && - (widget.onSetPremove != null && - widget.state.interactableSide.name == piece.color.name && - widget.state.sideToMove != piece.color); + (widget.game?.premovable != null && + widget.game?.playerSide.name == piece.color.name && + widget.game?.sideToMove != piece.color); } /// Whether the piece is allowed to be moved to the target square. bool _canMoveTo(Square orig, Square dest) { - final validDests = widget.state.validMoves?[orig]; + final validDests = widget.game?.validMoves[orig]; return orig != dest && validDests != null && validDests.contains(dest); } @@ -860,12 +872,13 @@ class _BoardState extends State { } if (_isPromoMove(selectedPiece, square)) { if (widget.settings.autoQueenPromotion) { - widget.onMove?.call(move.withPromotion(Role.queen), isDrop: drop); + widget.game?.onMove + .call(move.withPromotion(Role.queen), isDrop: drop); } else { - widget.onMove?.call(move, isDrop: drop, shouldPromote: true); + widget.game?.onMove.call(move, isDrop: drop, shouldPromote: true); } } else { - widget.onMove?.call(move, isDrop: drop); + widget.game?.onMove.call(move, isDrop: drop); } return true; } else if (_isPremovable(selectedPiece) && @@ -875,7 +888,7 @@ class _BoardState extends State { widget.settings.autoQueenPromotionOnPremove && isPromoPremove ? NormalMove(from: selected!, to: square, promotion: Role.queen) : NormalMove(from: selected!, to: square); - widget.onSetPremove?.call( + widget.game?.premovable?.onSetPremove.call( premove, shouldPromote: !widget.settings.autoQueenPromotionOnPremove && isPromoPremove, diff --git a/test/board_data_test.dart b/test/board_data_test.dart deleted file mode 100644 index c66c55e..0000000 --- a/test/board_data_test.dart +++ /dev/null @@ -1,141 +0,0 @@ -import 'package:dartchess/dartchess.dart' hide Move, Piece; -import 'package:flutter_test/flutter_test.dart'; -import 'package:chessground/chessground.dart'; - -void main() { - group('ChessboardState', () { - test('implements hashCode/==', () { - expect( - const ChessboardState( - interactableSide: InteractableSide.both, - orientation: Side.white, - sideToMove: Side.white, - fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1', - ), - const ChessboardState( - interactableSide: InteractableSide.both, - orientation: Side.white, - sideToMove: Side.white, - fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1', - ), - ); - expect( - const ChessboardState( - interactableSide: InteractableSide.both, - orientation: Side.white, - sideToMove: Side.white, - fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1', - ).hashCode, - const ChessboardState( - interactableSide: InteractableSide.both, - orientation: Side.white, - sideToMove: Side.white, - fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1', - ).hashCode, - ); - - expect( - const ChessboardState( - interactableSide: InteractableSide.both, - orientation: Side.white, - sideToMove: Side.white, - fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1', - ), - isNot( - const ChessboardState( - interactableSide: InteractableSide.both, - orientation: Side.white, - sideToMove: Side.white, - fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 2', - ), - ), - ); - - expect( - const ChessboardState( - interactableSide: InteractableSide.both, - orientation: Side.white, - sideToMove: Side.white, - fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1', - ).hashCode, - isNot( - const ChessboardState( - interactableSide: InteractableSide.both, - orientation: Side.white, - sideToMove: Side.white, - fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 2', - ).hashCode, - ), - ); - }); - - test('copyWith', () { - const boardData = ChessboardState( - interactableSide: InteractableSide.both, - orientation: Side.white, - sideToMove: Side.white, - fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1', - ); - - expect(boardData.copyWith(), boardData); - - expect( - boardData.copyWith( - interactableSide: InteractableSide.both, - orientation: Side.white, - sideToMove: Side.white, - fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1', - ), - boardData, - ); - - expect( - boardData - .copyWith(interactableSide: InteractableSide.white) - .interactableSide, - InteractableSide.white, - ); - - expect( - boardData.copyWith(orientation: Side.black).orientation, - Side.black, - ); - - expect( - boardData.copyWith(sideToMove: Side.black).sideToMove, - Side.black, - ); - - expect( - boardData.copyWith(fen: 'new_fen').fen, - 'new_fen', - ); - }); - - test('copyWith, nullable values', () { - const boardData = ChessboardState( - interactableSide: InteractableSide.both, - orientation: Side.white, - fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1', - sideToMove: Side.white, - lastMove: NormalMove(from: Square.e2, to: Square.e4), - ); - - // pass null values to non-nullable fields should not change the field - expect( - boardData.copyWith( - // ignore: avoid_redundant_argument_values - interactableSide: null, - ), - boardData, - ); - - // pass null values to nullable fields should set the field to null - expect( - // ignore: avoid_redundant_argument_values - boardData.copyWith(lastMove: null).lastMove, - null, - ); - }); - }); -} diff --git a/test/widgets/board_test.dart b/test/widgets/board_test.dart index feb3b5f..637c119 100644 --- a/test/widgets/board_test.dart +++ b/test/widgets/board_test.dart @@ -14,13 +14,10 @@ void main() { group('Non-interactable board', () { const viewOnlyBoard = Directionality( textDirection: TextDirection.ltr, - child: Chessboard( + child: Chessboard.fixed( size: boardSize, - state: ChessboardState( - interactableSide: InteractableSide.none, - orientation: Side.white, - fen: kInitialFEN, - ), + orientation: Side.white, + fen: kInitialFEN, settings: ChessboardSettings( drawShape: DrawShapeOptions(enable: true), ), @@ -59,7 +56,7 @@ void main() { testWidgets('selecting and deselecting a square', (WidgetTester tester) async { await tester.pumpWidget( - buildBoard(initialInteractableSide: InteractableSide.both), + buildBoard(initialPlayerSide: PlayerSide.both), ); await tester.tap(find.byKey(const Key('a2-whitepawn'))); await tester.pump(); @@ -101,7 +98,7 @@ void main() { testWidgets('play e2-e4 move by tap', (WidgetTester tester) async { await tester.pumpWidget( - buildBoard(initialInteractableSide: InteractableSide.both), + buildBoard(initialPlayerSide: PlayerSide.both), ); await tester.tap(find.byKey(const Key('e2-whitepawn'))); await tester.pump(); @@ -128,7 +125,7 @@ void main() { (WidgetTester tester) async { await tester.pumpWidget( buildBoard( - initialInteractableSide: InteractableSide.both, + initialPlayerSide: PlayerSide.both, pieceShiftMethod: PieceShiftMethod.drag, ), ); @@ -157,7 +154,7 @@ void main() { buildBoard( initialFen: 'r1bqk1nr/pppp1ppp/2n5/2b1p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 4 4', - initialInteractableSide: InteractableSide.both, + initialPlayerSide: PlayerSide.both, ), ); await tester.tap(find.byKey(const Key('e1-whiteking'))); @@ -178,7 +175,7 @@ void main() { testWidgets('dragging off target', (WidgetTester tester) async { await tester.pumpWidget( - buildBoard(initialInteractableSide: InteractableSide.both), + buildBoard(initialPlayerSide: PlayerSide.both), ); final e2 = squareOffset(Square.e2); @@ -191,7 +188,7 @@ void main() { testWidgets('dragging off board', (WidgetTester tester) async { await tester.pumpWidget( - buildBoard(initialInteractableSide: InteractableSide.both), + buildBoard(initialPlayerSide: PlayerSide.both), ); await tester.dragFrom( @@ -206,7 +203,7 @@ void main() { testWidgets('e2-e4 drag move', (WidgetTester tester) async { await tester.pumpWidget( - buildBoard(initialInteractableSide: InteractableSide.both), + buildBoard(initialPlayerSide: PlayerSide.both), ); await tester.dragFrom( squareOffset(Square.e2), @@ -228,7 +225,7 @@ void main() { animationDuration: Duration.zero, pieceShiftMethod: PieceShiftMethod.tapTwoSquares, ), - initialInteractableSide: InteractableSide.white, + initialPlayerSide: PlayerSide.white, ), ); await tester.dragFrom( @@ -262,7 +259,7 @@ void main() { WidgetTester tester, ) async { await tester.pumpWidget( - buildBoard(initialInteractableSide: InteractableSide.both), + buildBoard(initialPlayerSide: PlayerSide.both), ); await TestAsyncUtils.guard(() async { await tester.startGesture(squareOffset(Square.e2)); @@ -287,7 +284,7 @@ void main() { WidgetTester tester, ) async { await tester.pumpWidget( - buildBoard(initialInteractableSide: InteractableSide.both), + buildBoard(initialPlayerSide: PlayerSide.both), ); // drag a piece and tap on another own square while dragging @@ -352,7 +349,7 @@ void main() { WidgetTester tester, ) async { await tester.pumpWidget( - buildBoard(initialInteractableSide: InteractableSide.both), + buildBoard(initialPlayerSide: PlayerSide.both), ); final e2 = squareOffset(Square.e2); await tester.tapAt(e2); @@ -377,7 +374,7 @@ void main() { buildBoard( initialFen: 'rnbqkbnr/ppp2ppp/3p4/4p3/3PP3/8/PPP2PPP/RNBQKBNR w KQkq - 0 3', - initialInteractableSide: InteractableSide.white, + initialPlayerSide: PlayerSide.white, ), ); await makeMove(tester, Square.f1, Square.b5); @@ -389,7 +386,7 @@ void main() { buildBoard( initialFen: 'rnbqkbnr/pppp1ppp/8/4p3/3P4/4P3/PPP2PPP/RNBQKBNR b KQkq - 0 2', - initialInteractableSide: InteractableSide.black, + initialPlayerSide: PlayerSide.black, ), ); await makeMove(tester, Square.f8, Square.b4); @@ -402,7 +399,7 @@ void main() { await tester.pumpWidget( buildBoard( initialFen: kInitialBoardFEN, - initialInteractableSide: InteractableSide.white, + initialPlayerSide: PlayerSide.white, ), ); @@ -413,7 +410,7 @@ void main() { await tester.pumpWidget( buildBoard( initialFen: kInitialBoardFEN, - initialInteractableSide: InteractableSide.none, + initialPlayerSide: PlayerSide.none, ), ); @@ -426,7 +423,7 @@ void main() { await tester.pumpWidget( buildBoard( initialFen: kInitialBoardFEN, - initialInteractableSide: InteractableSide.white, + initialPlayerSide: PlayerSide.white, ), ); @@ -440,7 +437,7 @@ void main() { await tester.pumpWidget( buildBoard( initialFen: kInitialBoardFEN, - initialInteractableSide: InteractableSide.none, + initialPlayerSide: PlayerSide.none, ), ); @@ -455,7 +452,7 @@ void main() { await tester.pumpWidget( buildBoard( initialFen: kInitialBoardFEN, - initialInteractableSide: InteractableSide.white, + initialPlayerSide: PlayerSide.white, ), ); @@ -471,7 +468,7 @@ void main() { testWidgets('can display the selector', (WidgetTester tester) async { await tester.pumpWidget( buildBoard( - initialInteractableSide: InteractableSide.both, + initialPlayerSide: PlayerSide.both, initialFen: '8/5P2/2RK2P1/8/4k3/8/8/7r w - - 0 1', initialPromotionMove: const NormalMove(from: Square.f7, to: Square.f8), @@ -485,7 +482,7 @@ void main() { testWidgets('promote a knight', (WidgetTester tester) async { await tester.pumpWidget( buildBoard( - initialInteractableSide: InteractableSide.both, + initialPlayerSide: PlayerSide.both, initialFen: '8/5P2/2RK2P1/8/4k3/8/8/7r w - - 0 1', ), ); @@ -512,7 +509,7 @@ void main() { testWidgets('cancels promotion', (WidgetTester tester) async { await tester.pumpWidget( buildBoard( - initialInteractableSide: InteractableSide.both, + initialPlayerSide: PlayerSide.both, initialFen: '8/5P2/2RK2P1/8/4k3/8/8/7r w - - 0 1', ), ); @@ -540,7 +537,7 @@ void main() { await tester.pumpWidget( buildBoard( settings: const ChessboardSettings(autoQueenPromotion: true), - initialInteractableSide: InteractableSide.both, + initialPlayerSide: PlayerSide.both, initialFen: '8/5P2/2RK2P1/8/4k3/8/8/7r w - - 0 1', ), ); @@ -561,7 +558,7 @@ void main() { buildBoard( initialFen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1', - initialInteractableSide: InteractableSide.white, + initialPlayerSide: PlayerSide.white, ), ); @@ -582,7 +579,7 @@ void main() { buildBoard( initialFen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1', - initialInteractableSide: InteractableSide.white, + initialPlayerSide: PlayerSide.white, ), ); @@ -603,7 +600,7 @@ void main() { buildBoard( initialFen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1', - initialInteractableSide: InteractableSide.white, + initialPlayerSide: PlayerSide.white, ), ); @@ -624,7 +621,7 @@ void main() { buildBoard( initialFen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1', - initialInteractableSide: InteractableSide.white, + initialPlayerSide: PlayerSide.white, ), ); @@ -654,7 +651,7 @@ void main() { buildBoard( initialFen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1', - initialInteractableSide: InteractableSide.white, + initialPlayerSide: PlayerSide.white, ), ); @@ -679,7 +676,7 @@ void main() { buildBoard( initialFen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1', - initialInteractableSide: InteractableSide.white, + initialPlayerSide: PlayerSide.white, ), ); @@ -704,7 +701,7 @@ void main() { buildBoard( initialFen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1', - initialInteractableSide: InteractableSide.white, + initialPlayerSide: PlayerSide.white, ), ); @@ -725,7 +722,7 @@ void main() { buildBoard( initialFen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1', - initialInteractableSide: InteractableSide.white, + initialPlayerSide: PlayerSide.white, ), ); @@ -752,7 +749,7 @@ void main() { buildBoard( initialFen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1', - initialInteractableSide: InteractableSide.white, + initialPlayerSide: PlayerSide.white, ), ); @@ -776,7 +773,7 @@ void main() { buildBoard( initialFen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1', - initialInteractableSide: InteractableSide.white, + initialPlayerSide: PlayerSide.white, ), ); @@ -796,7 +793,7 @@ void main() { buildBoard( initialFen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1', - initialInteractableSide: InteractableSide.white, + initialPlayerSide: PlayerSide.white, ), ); @@ -814,7 +811,7 @@ void main() { await tester.pumpWidget( buildBoard( settings: const ChessboardSettings(animationDuration: Duration.zero), - initialInteractableSide: InteractableSide.white, + initialPlayerSide: PlayerSide.white, shouldPlayOpponentMove: true, ), ); @@ -846,7 +843,7 @@ void main() { await tester.pumpWidget( buildBoard( settings: const ChessboardSettings(animationDuration: Duration.zero), - initialInteractableSide: InteractableSide.white, + initialPlayerSide: PlayerSide.white, initialFen: '8/5P2/2RK2P1/8/4k3/8/8/8 w - - 0 1', shouldPlayOpponentMove: true, ), @@ -878,7 +875,7 @@ void main() { autoQueenPromotionOnPremove: false, animationDuration: Duration.zero, ), - initialInteractableSide: InteractableSide.white, + initialPlayerSide: PlayerSide.white, initialFen: '8/5P2/2RK2P1/8/4k3/8/8/8 w - - 0 1', shouldPlayOpponentMove: true, ), @@ -920,7 +917,7 @@ void main() { (WidgetTester tester) async { await tester.pumpWidget( buildBoard( - initialInteractableSide: InteractableSide.both, + initialPlayerSide: PlayerSide.both, initialShapes: ISet( {const Circle(orig: Square.e4, color: Color(0xFF0000FF))}, ), @@ -937,7 +934,7 @@ void main() { (WidgetTester tester) async { await tester.pumpWidget( buildBoard( - initialInteractableSide: InteractableSide.both, + initialPlayerSide: PlayerSide.both, initialShapes: ISet({ const Arrow( orig: Square.e2, @@ -960,7 +957,7 @@ void main() { (WidgetTester tester) async { await tester.pumpWidget( buildBoard( - initialInteractableSide: InteractableSide.both, + initialPlayerSide: PlayerSide.both, initialShapes: ISet({ const PieceShape( orig: Square.e4, @@ -981,7 +978,7 @@ void main() { testWidgets('cannot draw if not enabled', (WidgetTester tester) async { await tester.pumpWidget( buildBoard( - initialInteractableSide: InteractableSide.both, + initialPlayerSide: PlayerSide.both, ), ); @@ -1002,7 +999,7 @@ void main() { testWidgets('draw a circle by hand', (WidgetTester tester) async { await tester.pumpWidget( buildBoard( - initialInteractableSide: InteractableSide.both, + initialPlayerSide: PlayerSide.both, enableDrawingShapes: true, ), ); @@ -1030,7 +1027,7 @@ void main() { testWidgets('draw an arrow by hand', (WidgetTester tester) async { await tester.pumpWidget( buildBoard( - initialInteractableSide: InteractableSide.both, + initialPlayerSide: PlayerSide.both, enableDrawingShapes: true, ), ); @@ -1060,7 +1057,7 @@ void main() { (WidgetTester tester) async { await tester.pumpWidget( buildBoard( - initialInteractableSide: InteractableSide.none, + initialPlayerSide: PlayerSide.none, enableDrawingShapes: true, ), ); @@ -1089,7 +1086,7 @@ void main() { testWidgets('double tap to clear shapes', (WidgetTester tester) async { await tester.pumpWidget( buildBoard( - initialInteractableSide: InteractableSide.both, + initialPlayerSide: PlayerSide.both, enableDrawingShapes: true, ), ); @@ -1134,7 +1131,7 @@ void main() { (WidgetTester tester) async { await tester.pumpWidget( buildBoard( - initialInteractableSide: InteractableSide.both, + initialPlayerSide: PlayerSide.both, enableDrawingShapes: true, ), ); @@ -1171,7 +1168,7 @@ Future makeMove(WidgetTester tester, Square from, Square to) async { } Widget buildBoard({ - required InteractableSide initialInteractableSide, + required PlayerSide initialPlayerSide, ChessboardSettings? settings, Side orientation = Side.white, String initialFen = kInitialFEN, @@ -1183,7 +1180,7 @@ Widget buildBoard({ /// play the first available move for the opponent after a delay of 200ms bool shouldPlayOpponentMove = false, }) { - InteractableSide interactableSide = initialInteractableSide; + PlayerSide interactiveSide = initialPlayerSide; Position position = Chess.fromSetup(Setup.parseFen(initialFen)); NormalMove? lastMove; NormalMove? premove; @@ -1193,7 +1190,7 @@ Widget buildBoard({ void playMove(NormalMove move) { position = position.playUnchecked(move); if (position.isGameOver) { - interactableSide = InteractableSide.none; + interactiveSide = PlayerSide.none; } lastMove = move; } @@ -1220,78 +1217,80 @@ Widget buildBoard({ child: Chessboard( size: boardSize, settings: settings ?? defaultSettings, - state: ChessboardState( - interactableSide: interactableSide, - orientation: orientation, - fen: position.fen, - lastMove: lastMove, + orientation: orientation, + fen: position.fen, + lastMove: lastMove, + game: GameState( + playerSide: interactiveSide, isCheck: position.isCheck, sideToMove: position.turn == Side.white ? Side.white : Side.black, validMoves: makeLegalMoves(position), promotionMove: promotionMove, - premove: premove, - shapes: shapes, - ), - onMove: (NormalMove move, {bool? isDrop, bool? shouldPromote}) { - setState(() { - if (shouldPromote == true) { - promotionMove = move; - } else { - playMove(move); - } - }); - - if (shouldPlayOpponentMove) { - Timer(const Duration(milliseconds: 200), () { - final allMoves = [ - for (final entry in position.legalMoves.entries) - for (final dest in entry.value.squares) - NormalMove(from: entry.key, to: dest), - ]; - final opponentMove = allMoves.first; - setState(() { - position = position.playUnchecked(opponentMove); - if (position.isGameOver) { - interactableSide = InteractableSide.none; - } - lastMove = NormalMove.fromUci(opponentMove.uci); - }); + onMove: (NormalMove move, {bool? isDrop, bool? shouldPromote}) { + setState(() { + if (shouldPromote == true) { + promotionMove = move; + } else { + playMove(move); + } + }); - if (premove != null && position.isLegal(premove!)) { - // if premove autoqueen if off, detect pawn promotion - final isPawnPromotion = premove!.promotion == null && - position.board.roleAt(premove!.from) == Role.pawn && - (premove!.to.rank == Rank.first || - premove!.to.rank == Rank.eighth); - - if (!isPawnPromotion) { - Timer.run(() { - setState(() { - position = position.playUnchecked(premove!); - premove = null; + if (shouldPlayOpponentMove) { + Timer(const Duration(milliseconds: 200), () { + final allMoves = [ + for (final entry in position.legalMoves.entries) + for (final dest in entry.value.squares) + NormalMove(from: entry.key, to: dest), + ]; + final opponentMove = allMoves.first; + setState(() { + position = position.playUnchecked(opponentMove); + if (position.isGameOver) { + interactiveSide = PlayerSide.none; + } + lastMove = NormalMove.fromUci(opponentMove.uci); + }); + + if (premove != null && position.isLegal(premove!)) { + // if premove autoqueen if off, detect pawn promotion + final isPawnPromotion = premove!.promotion == null && + position.board.roleAt(premove!.from) == Role.pawn && + (premove!.to.rank == Rank.first || + premove!.to.rank == Rank.eighth); + + if (!isPawnPromotion) { + Timer.run(() { + setState(() { + position = position.playUnchecked(premove!); + premove = null; + }); }); - }); + } } - } + }); + } + }, + onPromotionSelect: (Role role) { + setState(() { + playMove(promotionMove!.withPromotion(role)); + promotionMove = null; }); - } - }, - onPromotionSelect: (Role role) { - setState(() { - playMove(promotionMove!.withPromotion(role)); - promotionMove = null; - }); - }, - onPromotionCancel: () { - setState(() { - promotionMove = null; - }); - }, - onSetPremove: (NormalMove? move, {bool? shouldPromote}) { - setState(() { - premove = move; - }); - }, + }, + onPromotionCancel: () { + setState(() { + promotionMove = null; + }); + }, + premovable: ( + premove: premove, + onSetPremove: (NormalMove? move, {bool? shouldPromote}) { + setState(() { + premove = move; + }); + }, + ), + ), + shapes: shapes, ), ); }, From 2d4af6e44082f6e05c9f49de4eb08c5f70b6fb3d Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Fri, 30 Aug 2024 11:51:14 +0200 Subject: [PATCH 10/21] Fix premove promotion, add another test --- lib/src/models.dart | 4 +-- lib/src/widgets/board.dart | 3 ++- test/widgets/board_test.dart | 50 +++++++++++++++++++++++++++++++++--- 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/lib/src/models.dart b/lib/src/models.dart index 5228447..bd7a506 100644 --- a/lib/src/models.dart +++ b/lib/src/models.dart @@ -30,7 +30,7 @@ class GameState { required this.onMove, required this.onPromotionSelect, required this.onPromotionCancel, - this.promotionMove, + required this.promotionMove, this.isCheck, this.premovable, }); @@ -43,7 +43,7 @@ class GameState { /// A pawn move that should be promoted. /// - /// Setting this will show a promotion dialog. + /// Will show a promotion dialog if not null. final NormalMove? promotionMove; /// Highlight the king of current side to move diff --git a/lib/src/widgets/board.dart b/lib/src/widgets/board.dart index 7544fe3..1a57fee 100644 --- a/lib/src/widgets/board.dart +++ b/lib/src/widgets/board.dart @@ -279,7 +279,8 @@ class _BoardState extends State { ), for (final entry in pieces.entries) if (!translatingPieces.containsKey(entry.key) && - entry.key != _draggedPieceSquare) + entry.key != _draggedPieceSquare && + entry.key != widget.game?.promotionMove?.from) PositionedSquare( key: ValueKey('${entry.key.name}-${entry.value}'), size: widget.size, diff --git a/test/widgets/board_test.dart b/test/widgets/board_test.dart index 637c119..c7d213d 100644 --- a/test/widgets/board_test.dart +++ b/test/widgets/board_test.dart @@ -496,8 +496,8 @@ void main() { await tester.pump(); expect(find.byType(PromotionSelector), findsOneWidget); - // pawn is still on the seventh rank - expect(find.byKey(const Key('f7-whitepawn')), findsOneWidget); + // promotion pawn is not visible + expect(find.byKey(const Key('f7-whitepawn')), findsNothing); // tap on the knight await tester.tapAt(squareOffset(Square.f7)); @@ -896,8 +896,7 @@ void main() { expect(find.byKey(const Key('g7-premove')), findsNothing); expect(find.byKey(const Key('g8-premove')), findsNothing); - // pawn is on the last rank - expect(find.byKey(const Key('g8-whitepawn')), findsOneWidget); + // promotion pawn is not visible expect(find.byKey(const Key('g7-whitepawn')), findsNothing); // select knight @@ -910,6 +909,44 @@ void main() { // wait for other opponent move to be played await tester.pump(const Duration(milliseconds: 200)); }); + + testWidgets('cancel a premove promotion', (WidgetTester tester) async { + await tester.pumpWidget( + buildBoard( + settings: const ChessboardSettings( + autoQueenPromotionOnPremove: false, + animationDuration: Duration.zero, + ), + initialPlayerSide: PlayerSide.white, + initialFen: '8/5P2/2RK2P1/8/4k3/8/8/8 w - - 0 1', + shouldPlayOpponentMove: true, + ), + ); + + await makeMove(tester, Square.g6, Square.g7); + await makeMove(tester, Square.g7, Square.g8); + expect(find.byKey(const Key('g7-premove')), findsOneWidget); + expect(find.byKey(const Key('g8-premove')), findsOneWidget); + + // wait for opponent move to be played + await tester.pump(const Duration(milliseconds: 200)); + + // promotion dialog is shown + expect(find.byType(PromotionSelector), findsOneWidget); + + // premove highlight are not shown anymore + expect(find.byKey(const Key('g7-premove')), findsNothing); + expect(find.byKey(const Key('g8-premove')), findsNothing); + + // cancel promotion dialog + await tester.tapAt(squareOffset(Square.c3)); + await tester.pump(); + + // promotion dialog is closed + expect(find.byType(PromotionSelector), findsNothing); + + expect(find.byKey(const Key('g7-whitepawn')), findsOneWidget); + }); }); group('Drawing shapes', () { @@ -1265,6 +1302,11 @@ Widget buildBoard({ premove = null; }); }); + } else { + setState(() { + promotionMove = premove; + premove = null; + }); } } }); From b0ebc77b6b2c2e825bc53f330783ec6b89e90c87 Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Fri, 30 Aug 2024 12:01:43 +0200 Subject: [PATCH 11/21] Update README --- README.md | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index d40e6f8..6df133e 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,13 @@ This package exports a `Chessboard` widget which can be interactable or not. It is configurable with a `ChessboardSettings` object which defines the board behavior and appearance. -You must provide a `ChessboardState` object to the `Chessboard` widget. This -object is immutable and contains the board state (the position, which side has -to move, etc.). +To interact with the board in order to play a game, you must provide a `GameState` +object to the `Chessboard` widget. This object is immutable and contains the game +state (which side is to move, the current valid moves, etc.), along with the +callback functions to handle user interactions. + All chess logic must be handled outside of this package. Any change in the state -of the game needs to be transferred to the board by creating a new `ChessboardState` object. +of the game needs to be transferred to the board by creating a new `GameState` object. ## Usage @@ -62,13 +64,10 @@ class _MyHomePageState extends State { title: const Text('Chessground demo'), ), body: Center( - child: Chessboard( + child: Chessboard.fixed( size: screenWidth, - state: ChessboardState( - interactableSide: InteractableSide.none, - orientation: Side.white, - fen: fen, - ), + orientation: Side.white, + fen: fen, ), ), ); From 49d45e4ea8c29fb33161367bda6b836b5a739820 Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Fri, 30 Aug 2024 12:07:21 +0200 Subject: [PATCH 12/21] Update CHANGELOG --- CHANGELOG.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51dd719..f7c4774 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,16 @@ -## 4.1.0 - +## 5.0.0 + +- Added another `Chessboard.fixed` constructor that allows to set the board to a + fixed position. +- Premove state is now lifted up to the parent widget, in order to allow + instant play of premoves. +- Promotion state is now lifted up to the parent widget, in order to allow more + control over the promotion dialog. - `ChessboardEditor` now supports highlighting squares. ### Breaking changes: +- `Chessboard` now require a `gameState` parameter of type `GameState` instead + of `BoardData`. - Added required parameters `piece` and `pieceAssets` to `PieceShape`, removed `role`. Added optional `opacity` parameter. From a3019987f771d1a71430a5b56310458af4982b78 Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Fri, 30 Aug 2024 12:07:50 +0200 Subject: [PATCH 13/21] Bump version --- example/pubspec.lock | 2 +- pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/example/pubspec.lock b/example/pubspec.lock index 92e9149..dc40e2e 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -31,7 +31,7 @@ packages: path: ".." relative: true source: path - version: "4.0.0" + version: "5.0.0" clock: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a9e8f11..98353a5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: chessground description: Chess board UI developed for lichess.org. It has no chess logic inside so it can be used for chess variants. -version: 4.0.0 +version: 5.0.0 repository: https://github.com/lichess-org/flutter-chessground funding: - https://lichess.org/patron From a19e2efee5fe2d9d5ae3c1a80d6018c1245da6b1 Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Fri, 30 Aug 2024 12:13:15 +0200 Subject: [PATCH 14/21] Tweak --- lib/src/models.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/models.dart b/lib/src/models.dart index bd7a506..6c4a142 100644 --- a/lib/src/models.dart +++ b/lib/src/models.dart @@ -56,7 +56,7 @@ class GameState { /// /// If the move is a pawn move that should trigger a promotion, `shouldPromote` will be true. /// - /// If the move has been made with drag-n-drop, `isDrop` will be true. + /// If the move has been made with drag and drop, `isDrop` will be true. final void Function(NormalMove, {bool? isDrop, bool? shouldPromote}) onMove; /// Callback called after a piece has been selected for promotion. From 35dd6498503a220fd4b51a78e486bcc049e4e146 Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Fri, 30 Aug 2024 12:20:22 +0200 Subject: [PATCH 15/21] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7c4774..6a72aee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Promotion state is now lifted up to the parent widget, in order to allow more control over the promotion dialog. - `ChessboardEditor` now supports highlighting squares. +- Fix: ensure the board background does not overflow the board. ### Breaking changes: - `Chessboard` now require a `gameState` parameter of type `GameState` instead From 65175fb2df1dfb99da492450e3af21677d2e913f Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Fri, 30 Aug 2024 15:44:19 +0200 Subject: [PATCH 16/21] More api updates --- CHANGELOG.md | 2 +- README.md | 4 +-- example/lib/main.dart | 32 +++++++++++++-------- lib/src/models.dart | 28 +++++++++--------- lib/src/widgets/board.dart | 26 ++++++++--------- test/widgets/board_test.dart | 56 ++++++++++++++++++++---------------- 6 files changed, 81 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a72aee..a323897 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ - Fix: ensure the board background does not overflow the board. ### Breaking changes: -- `Chessboard` now require a `gameState` parameter of type `GameState` instead +- `Chessboard` now require a `game` parameter of type `GameData` instead of `BoardData`. - Added required parameters `piece` and `pieceAssets` to `PieceShape`, removed `role`. Added optional `opacity` parameter. diff --git a/README.md b/README.md index 6df133e..9b440e9 100644 --- a/README.md +++ b/README.md @@ -28,13 +28,13 @@ This package exports a `Chessboard` widget which can be interactable or not. It is configurable with a `ChessboardSettings` object which defines the board behavior and appearance. -To interact with the board in order to play a game, you must provide a `GameState` +To interact with the board in order to play a game, you must provide a `GameData` object to the `Chessboard` widget. This object is immutable and contains the game state (which side is to move, the current valid moves, etc.), along with the callback functions to handle user interactions. All chess logic must be handled outside of this package. Any change in the state -of the game needs to be transferred to the board by creating a new `GameState` object. +of the game needs to be transferred to the board by creating a new `GameData` object. ## Usage diff --git a/example/lib/main.dart b/example/lib/main.dart index 7af979d..b561142 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -312,7 +312,7 @@ class _HomePageState extends State { opponentsPiecesUpsideDown: playMode == Mode.freePlay, fen: fen, lastMove: lastMove, - game: GameState( + game: GameData( playerSide: (playMode == Mode.botPlay || playMode == Mode.inputMove) ? PlayerSide.white @@ -329,6 +329,11 @@ class _HomePageState extends State { onPromotionCancel: _onPromotionCancel, premovable: ( onSetPremove: _onSetPremove, + onUnsetPremove: () { + setState(() { + premove = null; + }); + }, premove: premove, ), ), @@ -352,7 +357,7 @@ class _HomePageState extends State { (premove!.to.rank == Rank.first || premove!.to.rank == Rank.eighth); if (!isPawnPromotion) { Timer.run(() { - _playMove(premove!, isPremove: true); + _playMove(premove!); }); } } @@ -415,7 +420,7 @@ class _HomePageState extends State { super.initState(); } - void _onSetPremove(NormalMove? move, {bool? shouldPromote}) { + void _onSetPremove(NormalMove move) { setState(() { premove = move; }); @@ -437,10 +442,9 @@ class _HomePageState extends State { }); } - void _playMove(NormalMove move, - {bool? isDrop, bool? isPremove, bool? shouldPromote}) { + void _playMove(NormalMove move, {bool? isDrop, Piece? captured}) { lastPos = position; - if (shouldPromote == true) { + if (isPromotionPawnMove(move)) { setState(() { promotionMove = move; }); @@ -450,18 +454,15 @@ class _HomePageState extends State { lastMove = move; fen = position.fen; validMoves = makeLegalMoves(position); - if (isPremove == true) { - premove = null; - } promotionMove = null; + premove = null; }); } } - void _onUserMoveAgainstBot(NormalMove move, - {bool? isDrop, bool? shouldPromote}) async { + void _onUserMoveAgainstBot(NormalMove move, {isDrop, captured}) async { lastPos = position; - if (shouldPromote == true) { + if (isPromotionPawnMove(move)) { setState(() { promotionMove = move; }); @@ -502,4 +503,11 @@ class _HomePageState extends State { } } } + + bool isPromotionPawnMove(NormalMove move) { + return move.promotion == null && + position.board.roleAt(move.from) == Role.pawn && + ((move.to.rank == Rank.first && position.turn == Side.black) || + (move.to.rank == Rank.eighth && position.turn == Side.white)); + } } diff --git a/lib/src/models.dart b/lib/src/models.dart index 6c4a142..bd4a4e6 100644 --- a/lib/src/models.dart +++ b/lib/src/models.dart @@ -19,11 +19,13 @@ enum PlayerSide { black; } -/// State of a game in an interactive chessboard. +/// Game data for an interactive chessboard. +/// +/// This is used to control the state of the chessboard and to provide callbacks for user interactions. @immutable -class GameState { - /// Creates a new game state. - const GameState({ +class GameData { + /// Creates a new [GameData] with the provided values. + const GameData({ required this.playerSide, required this.sideToMove, required this.validMoves, @@ -54,10 +56,10 @@ class GameState { /// Callback called after a move has been made. /// - /// If the move is a pawn move that should trigger a promotion, `shouldPromote` will be true. - /// /// If the move has been made with drag and drop, `isDrop` will be true. - final void Function(NormalMove, {bool? isDrop, bool? shouldPromote}) onMove; + /// + /// If a piece has been captured, `captured` will be the captured piece. + final void Function(NormalMove, {bool? isDrop, Piece? captured}) onMove; /// Callback called after a piece has been selected for promotion. /// @@ -82,13 +84,11 @@ typedef Premovable = ({ /// Chessground will not play the premove automatically, it is up to the library user to play it. NormalMove? premove, - /// Callback called after a premove has been set/unset. - /// - /// If the callback is null, the board will not allow premoves. - /// - /// If the premove is a pawn move that should trigger a promotion, `shouldPromote` - /// will be true. This is useful to show a promotion selector to the user. - void Function(NormalMove?, {bool? shouldPromote}) onSetPremove, + /// Callback called after a premove has been set. + void Function(NormalMove) onSetPremove, + + /// Callback called after a premove has been unset. + void Function() onUnsetPremove, }); /// Describes a set of piece assets. diff --git a/lib/src/widgets/board.dart b/lib/src/widgets/board.dart index 1a57fee..8ac4a29 100644 --- a/lib/src/widgets/board.dart +++ b/lib/src/widgets/board.dart @@ -77,12 +77,12 @@ class Chessboard extends StatefulWidget with ChessboardGeometry { final String fen; /// Last move played, used to highlight corresponding squares. - final NormalMove? lastMove; + final Move? lastMove; /// Game state of the board. /// /// If `null`, the board cannot be interacted with. - final GameState? game; + final GameData? game; /// Optional set of [Shape] to be drawn on the board. final ISet? shapes; @@ -609,7 +609,7 @@ class _BoardState extends State { // - cancel premove // - unselect piece else if (widget.game?.premovable?.premove != null) { - widget.game?.premovable?.onSetPremove.call(null); + widget.game?.premovable?.onUnsetPremove.call(); setState(() { selected = null; _premoveDests = null; @@ -692,12 +692,12 @@ class _BoardState extends State { final couldMove = _tryMoveOrPremoveTo(square, drop: true); // if the premove was not possible, cancel the current premove if (!couldMove && widget.game?.premovable?.premove != null) { - widget.game?.premovable?.onSetPremove.call(null); + widget.game?.premovable?.onUnsetPremove.call(); } } // if the user drags a piece to an empty square, cancel the premove else if (widget.game?.premovable?.premove != null) { - widget.game?.premovable?.onSetPremove.call(null); + widget.game?.premovable?.onUnsetPremove.call(); } _onDragEnd(); setState(() { @@ -720,7 +720,7 @@ class _BoardState extends State { widget.game?.premovable?.premove != null && widget.game?.premovable?.premove!.from == square) { _shouldCancelPremoveOnTapUp = false; - widget.game?.premovable?.onSetPremove.call(null); + widget.game?.premovable?.onUnsetPremove.call(); } _shouldDeselectOnTapUp = false; @@ -873,10 +873,12 @@ class _BoardState extends State { } if (_isPromoMove(selectedPiece, square)) { if (widget.settings.autoQueenPromotion) { - widget.game?.onMove - .call(move.withPromotion(Role.queen), isDrop: drop); + widget.game?.onMove.call( + move.withPromotion(Role.queen), + isDrop: drop, + ); } else { - widget.game?.onMove.call(move, isDrop: drop, shouldPromote: true); + widget.game?.onMove.call(move, isDrop: drop); } } else { widget.game?.onMove.call(move, isDrop: drop); @@ -889,11 +891,7 @@ class _BoardState extends State { widget.settings.autoQueenPromotionOnPremove && isPromoPremove ? NormalMove(from: selected!, to: square, promotion: Role.queen) : NormalMove(from: selected!, to: square); - widget.game?.premovable?.onSetPremove.call( - premove, - shouldPromote: - !widget.settings.autoQueenPromotionOnPremove && isPromoPremove, - ); + widget.game?.premovable?.onSetPremove.call(premove); return true; } return false; diff --git a/test/widgets/board_test.dart b/test/widgets/board_test.dart index c7d213d..507b794 100644 --- a/test/widgets/board_test.dart +++ b/test/widgets/board_test.dart @@ -1220,7 +1220,7 @@ Widget buildBoard({ PlayerSide interactiveSide = initialPlayerSide; Position position = Chess.fromSetup(Setup.parseFen(initialFen)); NormalMove? lastMove; - NormalMove? premove; + NormalMove? premoveData; NormalMove? promotionMove = initialPromotionMove; ISet shapes = initialShapes ?? ISet(); @@ -1232,6 +1232,13 @@ Widget buildBoard({ lastMove = move; } + bool isPromotionPawnMove(NormalMove move) { + return move.promotion == null && + position.board.roleAt(move.from) == Role.pawn && + ((move.to.rank == Rank.first && position.turn == Side.black) || + (move.to.rank == Rank.eighth && position.turn == Side.white)); + } + return MaterialApp( home: StatefulBuilder( builder: (BuildContext context, StateSetter setState) { @@ -1257,15 +1264,15 @@ Widget buildBoard({ orientation: orientation, fen: position.fen, lastMove: lastMove, - game: GameState( + game: GameData( playerSide: interactiveSide, isCheck: position.isCheck, sideToMove: position.turn == Side.white ? Side.white : Side.black, validMoves: makeLegalMoves(position), promotionMove: promotionMove, - onMove: (NormalMove move, {bool? isDrop, bool? shouldPromote}) { + onMove: (NormalMove move, {isDrop, captured}) { setState(() { - if (shouldPromote == true) { + if (isPromotionPawnMove(move)) { promotionMove = move; } else { playMove(move); @@ -1288,25 +1295,21 @@ Widget buildBoard({ lastMove = NormalMove.fromUci(opponentMove.uci); }); - if (premove != null && position.isLegal(premove!)) { - // if premove autoqueen if off, detect pawn promotion - final isPawnPromotion = premove!.promotion == null && - position.board.roleAt(premove!.from) == Role.pawn && - (premove!.to.rank == Rank.first || - premove!.to.rank == Rank.eighth); - - if (!isPawnPromotion) { - Timer.run(() { + if (premoveData != null) { + if (position.isLegal(premoveData!)) { + if (!isPromotionPawnMove(premoveData!)) { + Timer.run(() { + setState(() { + position = position.playUnchecked(premoveData!); + premoveData = null; + }); + }); + } else { setState(() { - position = position.playUnchecked(premove!); - premove = null; + promotionMove = premoveData; + premoveData = null; }); - }); - } else { - setState(() { - promotionMove = premove; - premove = null; - }); + } } } }); @@ -1324,10 +1327,15 @@ Widget buildBoard({ }); }, premovable: ( - premove: premove, - onSetPremove: (NormalMove? move, {bool? shouldPromote}) { + premove: premoveData, + onSetPremove: (NormalMove move) { + setState(() { + premoveData = move; + }); + }, + onUnsetPremove: () { setState(() { - premove = move; + premoveData = null; }); }, ), From 3c0feae8a96d8088dcf27f692241be978d4a112b Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Fri, 30 Aug 2024 16:05:27 +0200 Subject: [PATCH 17/21] Add a comment --- test/widgets/board_test.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/test/widgets/board_test.dart b/test/widgets/board_test.dart index 507b794..9254be3 100644 --- a/test/widgets/board_test.dart +++ b/test/widgets/board_test.dart @@ -1295,6 +1295,7 @@ Widget buildBoard({ lastMove = NormalMove.fromUci(opponentMove.uci); }); + // play premove just after the opponent move if (premoveData != null) { if (position.isLegal(premoveData!)) { if (!isPromotionPawnMove(premoveData!)) { From bbbc2d3fd8312a06614c45530b9e008bbec06967 Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Mon, 2 Sep 2024 09:19:42 +0200 Subject: [PATCH 18/21] More api update --- example/lib/main.dart | 7 +------ lib/src/models.dart | 9 ++++----- lib/src/widgets/board.dart | 8 ++++---- test/widgets/board_test.dart | 7 +------ 4 files changed, 10 insertions(+), 21 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index b561142..999dc88 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -329,11 +329,6 @@ class _HomePageState extends State { onPromotionCancel: _onPromotionCancel, premovable: ( onSetPremove: _onSetPremove, - onUnsetPremove: () { - setState(() { - premove = null; - }); - }, premove: premove, ), ), @@ -420,7 +415,7 @@ class _HomePageState extends State { super.initState(); } - void _onSetPremove(NormalMove move) { + void _onSetPremove(NormalMove? move) { setState(() { premove = move; }); diff --git a/lib/src/models.dart b/lib/src/models.dart index bd4a4e6..1d2afef 100644 --- a/lib/src/models.dart +++ b/lib/src/models.dart @@ -84,11 +84,10 @@ typedef Premovable = ({ /// Chessground will not play the premove automatically, it is up to the library user to play it. NormalMove? premove, - /// Callback called after a premove has been set. - void Function(NormalMove) onSetPremove, - - /// Callback called after a premove has been unset. - void Function() onUnsetPremove, + /// Callback called after a premove has been set/unset. + /// + /// If `null`, the premove will be unset. + void Function(NormalMove?) onSetPremove, }); /// Describes a set of piece assets. diff --git a/lib/src/widgets/board.dart b/lib/src/widgets/board.dart index 8ac4a29..d31859f 100644 --- a/lib/src/widgets/board.dart +++ b/lib/src/widgets/board.dart @@ -609,7 +609,7 @@ class _BoardState extends State { // - cancel premove // - unselect piece else if (widget.game?.premovable?.premove != null) { - widget.game?.premovable?.onUnsetPremove.call(); + widget.game?.premovable?.onSetPremove.call(null); setState(() { selected = null; _premoveDests = null; @@ -692,12 +692,12 @@ class _BoardState extends State { final couldMove = _tryMoveOrPremoveTo(square, drop: true); // if the premove was not possible, cancel the current premove if (!couldMove && widget.game?.premovable?.premove != null) { - widget.game?.premovable?.onUnsetPremove.call(); + widget.game?.premovable?.onSetPremove.call(null); } } // if the user drags a piece to an empty square, cancel the premove else if (widget.game?.premovable?.premove != null) { - widget.game?.premovable?.onUnsetPremove.call(); + widget.game?.premovable?.onSetPremove.call(null); } _onDragEnd(); setState(() { @@ -720,7 +720,7 @@ class _BoardState extends State { widget.game?.premovable?.premove != null && widget.game?.premovable?.premove!.from == square) { _shouldCancelPremoveOnTapUp = false; - widget.game?.premovable?.onUnsetPremove.call(); + widget.game?.premovable?.onSetPremove.call(null); } _shouldDeselectOnTapUp = false; diff --git a/test/widgets/board_test.dart b/test/widgets/board_test.dart index 9254be3..243c934 100644 --- a/test/widgets/board_test.dart +++ b/test/widgets/board_test.dart @@ -1329,16 +1329,11 @@ Widget buildBoard({ }, premovable: ( premove: premoveData, - onSetPremove: (NormalMove move) { + onSetPremove: (NormalMove? move) { setState(() { premoveData = move; }); }, - onUnsetPremove: () { - setState(() { - premoveData = null; - }); - }, ), ), shapes: shapes, From d0f608f9edab9489055f784f9d7f3641bfbe488b Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Mon, 2 Sep 2024 11:12:59 +0200 Subject: [PATCH 19/21] More api tweaks --- example/lib/main.dart | 4 ++-- lib/src/models.dart | 6 +----- test/widgets/board_test.dart | 2 +- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 999dc88..d7dda96 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -437,7 +437,7 @@ class _HomePageState extends State { }); } - void _playMove(NormalMove move, {bool? isDrop, Piece? captured}) { + void _playMove(NormalMove move, {bool? isDrop}) { lastPos = position; if (isPromotionPawnMove(move)) { setState(() { @@ -455,7 +455,7 @@ class _HomePageState extends State { } } - void _onUserMoveAgainstBot(NormalMove move, {isDrop, captured}) async { + void _onUserMoveAgainstBot(NormalMove move, {isDrop}) async { lastPos = position; if (isPromotionPawnMove(move)) { setState(() { diff --git a/lib/src/models.dart b/lib/src/models.dart index 1d2afef..f8bb038 100644 --- a/lib/src/models.dart +++ b/lib/src/models.dart @@ -57,13 +57,9 @@ class GameData { /// Callback called after a move has been made. /// /// If the move has been made with drag and drop, `isDrop` will be true. - /// - /// If a piece has been captured, `captured` will be the captured piece. - final void Function(NormalMove, {bool? isDrop, Piece? captured}) onMove; + final void Function(NormalMove, {bool? isDrop}) onMove; /// Callback called after a piece has been selected for promotion. - /// - /// The move is guaranteed to be a promotion move. final void Function(Role) onPromotionSelect; /// Callback called after a promotion has been canceled. diff --git a/test/widgets/board_test.dart b/test/widgets/board_test.dart index 243c934..1288133 100644 --- a/test/widgets/board_test.dart +++ b/test/widgets/board_test.dart @@ -1270,7 +1270,7 @@ Widget buildBoard({ sideToMove: position.turn == Side.white ? Side.white : Side.black, validMoves: makeLegalMoves(position), promotionMove: promotionMove, - onMove: (NormalMove move, {isDrop, captured}) { + onMove: (NormalMove move, {isDrop}) { setState(() { if (isPromotionPawnMove(move)) { promotionMove = move; From fdc8280b3305105efd66c2db955397bda47e63f1 Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Mon, 2 Sep 2024 14:02:44 +0200 Subject: [PATCH 20/21] Renaming --- example/lib/main.dart | 9 +++++---- lib/src/models.dart | 12 +++++------- lib/src/widgets/board.dart | 8 +++++--- test/widgets/board_test.dart | 11 ++++------- 4 files changed, 19 insertions(+), 21 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index d7dda96..ed5f8ae 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -325,8 +325,7 @@ class _HomePageState extends State { promotionMove: promotionMove, onMove: playMode == Mode.botPlay ? _onUserMoveAgainstBot : _playMove, - onPromotionSelect: _onPromotionSelect, - onPromotionCancel: _onPromotionCancel, + onPromotionSelection: _onPromotionSelection, premovable: ( onSetPremove: _onSetPremove, premove: premove, @@ -421,8 +420,10 @@ class _HomePageState extends State { }); } - void _onPromotionSelect(Role role) { - if (promotionMove != null) { + void _onPromotionSelection(Role? role) { + if (role == null) { + _onPromotionCancel(); + } else if (promotionMove != null) { if (playMode == Mode.botPlay) { _onUserMoveAgainstBot(promotionMove!.withPromotion(role)); } else { diff --git a/lib/src/models.dart b/lib/src/models.dart index f8bb038..41d8c71 100644 --- a/lib/src/models.dart +++ b/lib/src/models.dart @@ -29,10 +29,9 @@ class GameData { required this.playerSide, required this.sideToMove, required this.validMoves, - required this.onMove, - required this.onPromotionSelect, - required this.onPromotionCancel, required this.promotionMove, + required this.onMove, + required this.onPromotionSelection, this.isCheck, this.premovable, }); @@ -60,10 +59,9 @@ class GameData { final void Function(NormalMove, {bool? isDrop}) onMove; /// Callback called after a piece has been selected for promotion. - final void Function(Role) onPromotionSelect; - - /// Callback called after a promotion has been canceled. - final void Function() onPromotionCancel; + /// + /// If the argument is `null`, the promotion should be canceled. + final void Function(Role? role) onPromotionSelection; /// Optional premovable state of the board. /// diff --git a/lib/src/widgets/board.dart b/lib/src/widgets/board.dart index d31859f..04298e2 100644 --- a/lib/src/widgets/board.dart +++ b/lib/src/widgets/board.dart @@ -371,7 +371,7 @@ class _BoardState extends State { else ...highlightedBackground, ...objects, - if (widget.game != null && widget.game?.promotionMove != null) + if (widget.game?.promotionMove != null) PromotionSelector( pieceAssets: widget.settings.pieceAssets, move: widget.game!.promotionMove!, @@ -380,8 +380,10 @@ class _BoardState extends State { orientation: widget.orientation, piecesUpsideDown: widget.opponentsPiecesUpsideDown && widget.game?.sideToMove != widget.orientation, - onSelect: widget.game!.onPromotionSelect, - onCancel: widget.game!.onPromotionCancel, + onSelect: widget.game!.onPromotionSelection, + onCancel: () { + widget.game!.onPromotionSelection(null); + }, ), ], ), diff --git a/test/widgets/board_test.dart b/test/widgets/board_test.dart index 1288133..c26f722 100644 --- a/test/widgets/board_test.dart +++ b/test/widgets/board_test.dart @@ -1316,14 +1316,11 @@ Widget buildBoard({ }); } }, - onPromotionSelect: (Role role) { - setState(() { - playMove(promotionMove!.withPromotion(role)); - promotionMove = null; - }); - }, - onPromotionCancel: () { + onPromotionSelection: (Role? role) { setState(() { + if (role != null) { + playMove(promotionMove!.withPromotion(role)); + } promotionMove = null; }); }, From ab934ac21b197c1be6b2e221231f55d91042a84d Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Mon, 2 Sep 2024 15:04:58 +0200 Subject: [PATCH 21/21] Rename enum member, fix tests --- example/lib/main.dart | 2 +- lib/src/board_settings.dart | 8 ++++---- lib/src/widgets/board.dart | 2 +- test/widgets/board_test.dart | 15 ++++++++++----- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 5ba771c..d3c642d 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -309,7 +309,7 @@ class _HomePageState extends State { autoQueenPromotionOnPremove: false, pieceOrientationBehavior: playMode == Mode.freePlay ? PieceOrientationBehavior.opponentUpsideDown - : PieceOrientationBehavior.default_, + : PieceOrientationBehavior.facingUser, ), orientation: orientation, fen: fen, diff --git a/lib/src/board_settings.dart b/lib/src/board_settings.dart index 1314e76..f7c461e 100644 --- a/lib/src/board_settings.dart +++ b/lib/src/board_settings.dart @@ -19,13 +19,13 @@ enum PieceShiftMethod { /// Describes how pieces on the board are oriented. enum PieceOrientationBehavior { - /// Pieces are always facing user - default_, + /// Pieces are always facing user (the default). + facingUser, /// Opponent's pieces are upside down, for over the board play face to face. opponentUpsideDown, - /// Piece orientation matches side to play, for over the board play where each user grabs the device in turn + /// Piece orientation matches side to play, for over the board play where each user grabs the device in turn. sideToPlay, } @@ -49,7 +49,7 @@ class ChessboardSettings { this.blindfoldMode = false, this.dragFeedbackScale = 2.0, this.dragFeedbackOffset = const Offset(0.0, -1.0), - this.pieceOrientationBehavior = PieceOrientationBehavior.default_, + this.pieceOrientationBehavior = PieceOrientationBehavior.facingUser, // shape drawing this.drawShape = const DrawShapeOptions(), diff --git a/lib/src/widgets/board.dart b/lib/src/widgets/board.dart index 00e0b94..5b50b88 100644 --- a/lib/src/widgets/board.dart +++ b/lib/src/widgets/board.dart @@ -824,7 +824,7 @@ class _BoardState extends State { /// widget settings. bool _isUpsideDown(Side pieceColor) => switch (widget.settings.pieceOrientationBehavior) { - PieceOrientationBehavior.default_ => false, + PieceOrientationBehavior.facingUser => false, PieceOrientationBehavior.opponentUpsideDown => pieceColor == widget.orientation.opposite, PieceOrientationBehavior.sideToPlay => diff --git a/test/widgets/board_test.dart b/test/widgets/board_test.dart index 2023cb6..9decbc7 100644 --- a/test/widgets/board_test.dart +++ b/test/widgets/board_test.dart @@ -1214,11 +1214,14 @@ void main() { } } - testWidgets('default', (WidgetTester tester) async { + testWidgets('facing user', (WidgetTester tester) async { for (final orientation in Side.values) { await tester.pumpWidget( buildBoard( - initialInteractableSide: InteractableSide.both, + settings: const ChessboardSettings( + animationDuration: Duration.zero, + ), + initialPlayerSide: PlayerSide.both, orientation: orientation, ), ); @@ -1243,9 +1246,10 @@ void main() { for (final orientation in Side.values) { await tester.pumpWidget( buildBoard( - initialInteractableSide: InteractableSide.both, + initialPlayerSide: PlayerSide.both, orientation: orientation, settings: const ChessboardSettings( + animationDuration: Duration.zero, pieceOrientationBehavior: PieceOrientationBehavior.opponentUpsideDown, ), @@ -1272,9 +1276,10 @@ void main() { for (final orientation in Side.values) { await tester.pumpWidget( buildBoard( - initialInteractableSide: InteractableSide.both, + initialPlayerSide: PlayerSide.both, orientation: orientation, settings: const ChessboardSettings( + animationDuration: Duration.zero, pieceOrientationBehavior: PieceOrientationBehavior.sideToPlay, ), ), @@ -1300,7 +1305,7 @@ void main() { Future makeMove(WidgetTester tester, Square from, Square to) async { final orientation = - tester.widget(find.byType(Chessboard)).state.orientation; + tester.widget(find.byType(Chessboard)).orientation; await tester.tapAt(squareOffset(from, orientation: orientation)); await tester.pump(); await tester.tapAt(squareOffset(to, orientation: orientation));