-
-
Notifications
You must be signed in to change notification settings - Fork 189
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: offline games ("over the board")
- Loading branch information
1 parent
22503a7
commit 3c70d62
Showing
10 changed files
with
1,390 additions
and
62 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
import 'package:dartchess/dartchess.dart'; | ||
import 'package:fast_immutable_collections/fast_immutable_collections.dart'; | ||
import 'package:freezed_annotation/freezed_annotation.dart'; | ||
import 'package:lichess_mobile/src/model/common/eval.dart'; | ||
import 'package:lichess_mobile/src/model/common/id.dart'; | ||
|
||
import 'game.dart'; | ||
import 'game_status.dart'; | ||
import 'player.dart'; | ||
|
||
part 'over_the_board_game.freezed.dart'; | ||
part 'over_the_board_game.g.dart'; | ||
|
||
/// An offline game played in real life by two human players on the same device. | ||
/// | ||
/// See [PlayableGame] for a game that is played online. | ||
@Freezed(fromJson: true, toJson: true) | ||
abstract class OverTheBoardGame | ||
with _$OverTheBoardGame, BaseGame, IndexableSteps { | ||
const OverTheBoardGame._(); | ||
|
||
@override | ||
Player get white => const Player(); | ||
@override | ||
Player get black => const Player(); | ||
|
||
@override | ||
IList<ExternalEval>? get evals => null; | ||
@override | ||
IList<Duration>? get clocks => null; | ||
|
||
@override | ||
GameId get id => const GameId('--------'); | ||
|
||
@Assert('steps.isNotEmpty') | ||
factory OverTheBoardGame({ | ||
@JsonKey(fromJson: stepsFromJson, toJson: stepsToJson) | ||
required IList<GameStep> steps, | ||
required GameMeta meta, | ||
required String? initialFen, | ||
required GameStatus status, | ||
Side? winner, | ||
bool? isThreefoldRepetition, | ||
}) = _OverTheBoardGame; | ||
|
||
bool get finished => status.value >= GameStatus.mate.value; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,141 @@ | ||
import 'dart:async'; | ||
import 'dart:math'; | ||
|
||
import 'package:dartchess/dartchess.dart'; | ||
import 'package:freezed_annotation/freezed_annotation.dart'; | ||
import 'package:lichess_mobile/src/model/common/time_increment.dart'; | ||
import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||
|
||
part 'over_the_board_clock.freezed.dart'; | ||
part 'over_the_board_clock.g.dart'; | ||
|
||
@riverpod | ||
class OverTheBoardClock extends _$OverTheBoardClock { | ||
final Stopwatch _stopwatch = Stopwatch(); | ||
|
||
late Timer _updateTimer; | ||
|
||
@override | ||
OverTheBoardClockState build() { | ||
_updateTimer = Timer.periodic(const Duration(milliseconds: 100), (_) { | ||
if (_stopwatch.isRunning) { | ||
final newTime = | ||
state.timeLeft(state.activeClock!)! - _stopwatch.elapsed; | ||
|
||
if (state.activeClock == Side.white) { | ||
state = state.copyWith(whiteTimeLeft: newTime); | ||
} else { | ||
state = state.copyWith(blackTimeLeft: newTime); | ||
} | ||
|
||
if (newTime <= Duration.zero) { | ||
state = state.copyWith( | ||
flagSide: state.activeClock, | ||
); | ||
} | ||
|
||
_stopwatch.reset(); | ||
} | ||
}); | ||
|
||
ref.onDispose(() { | ||
_updateTimer.cancel(); | ||
}); | ||
|
||
return OverTheBoardClockState.fromTimeIncrement( | ||
TimeIncrement( | ||
const Duration(minutes: 5).inSeconds, | ||
const Duration(seconds: 3).inSeconds, | ||
), | ||
); | ||
} | ||
|
||
void setupClock(TimeIncrement timeIncrement) { | ||
_stopwatch.stop(); | ||
_stopwatch.reset(); | ||
|
||
state = OverTheBoardClockState.fromTimeIncrement(timeIncrement); | ||
} | ||
|
||
void restart() { | ||
setupClock(state.timeIncrement); | ||
} | ||
|
||
void switchSide({required Side newSideToMove, required bool addIncrement}) { | ||
if (state.timeIncrement.isInfinite || state.flagSide != null) return; | ||
|
||
final increment = | ||
Duration(seconds: addIncrement ? state.timeIncrement.increment : 0); | ||
if (newSideToMove == Side.black) { | ||
state = state.copyWith( | ||
whiteTimeLeft: state.whiteTimeLeft! + increment, | ||
activeClock: Side.black, | ||
); | ||
} else { | ||
state = state.copyWith( | ||
blackTimeLeft: state.blackTimeLeft! + increment, | ||
activeClock: Side.white, | ||
); | ||
} | ||
|
||
_stopwatch.reset(); | ||
_stopwatch.start(); | ||
} | ||
|
||
void onMove({required Side newSideToMove}) { | ||
switchSide(newSideToMove: newSideToMove, addIncrement: state.active); | ||
} | ||
|
||
void pause() { | ||
if (_stopwatch.isRunning) { | ||
state = state.copyWith( | ||
activeClock: null, | ||
); | ||
_stopwatch.reset(); | ||
_stopwatch.stop(); | ||
} | ||
} | ||
|
||
void resume(Side newSideToMove) { | ||
_stopwatch.reset(); | ||
_stopwatch.start(); | ||
|
||
state = state.copyWith( | ||
activeClock: newSideToMove, | ||
); | ||
} | ||
} | ||
|
||
@freezed | ||
class OverTheBoardClockState with _$OverTheBoardClockState { | ||
const OverTheBoardClockState._(); | ||
|
||
const factory OverTheBoardClockState({ | ||
required TimeIncrement timeIncrement, | ||
required Duration? whiteTimeLeft, | ||
required Duration? blackTimeLeft, | ||
required Side? activeClock, | ||
required Side? flagSide, | ||
}) = _OverTheBoardClockState; | ||
|
||
factory OverTheBoardClockState.fromTimeIncrement( | ||
TimeIncrement timeIncrement, | ||
) { | ||
final initialTime = timeIncrement.isInfinite | ||
? null | ||
: Duration(seconds: max(timeIncrement.time, timeIncrement.increment)); | ||
|
||
return OverTheBoardClockState( | ||
timeIncrement: timeIncrement, | ||
whiteTimeLeft: initialTime, | ||
blackTimeLeft: initialTime, | ||
activeClock: null, | ||
flagSide: null, | ||
); | ||
} | ||
|
||
bool get active => activeClock != null || flagSide != null; | ||
|
||
Duration? timeLeft(Side side) => | ||
side == Side.white ? whiteTimeLeft : blackTimeLeft; | ||
} |
166 changes: 166 additions & 0 deletions
166
lib/src/model/over_the_board/over_the_board_game_controller.dart
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,166 @@ | ||
import 'package:dartchess/dartchess.dart'; | ||
import 'package:fast_immutable_collections/fast_immutable_collections.dart'; | ||
import 'package:freezed_annotation/freezed_annotation.dart'; | ||
import 'package:lichess_mobile/src/model/common/chess.dart'; | ||
import 'package:lichess_mobile/src/model/common/chess960.dart'; | ||
import 'package:lichess_mobile/src/model/common/perf.dart'; | ||
import 'package:lichess_mobile/src/model/common/service/move_feedback.dart'; | ||
import 'package:lichess_mobile/src/model/common/speed.dart'; | ||
import 'package:lichess_mobile/src/model/common/time_increment.dart'; | ||
import 'package:lichess_mobile/src/model/game/game.dart'; | ||
import 'package:lichess_mobile/src/model/game/game_status.dart'; | ||
import 'package:lichess_mobile/src/model/game/material_diff.dart'; | ||
import 'package:lichess_mobile/src/model/game/over_the_board_game.dart'; | ||
import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||
|
||
part 'over_the_board_game_controller.freezed.dart'; | ||
part 'over_the_board_game_controller.g.dart'; | ||
|
||
@riverpod | ||
class OverTheBoardGameController extends _$OverTheBoardGameController { | ||
@override | ||
OverTheBoardGameState build() => OverTheBoardGameState.fromVariant( | ||
Variant.standard, | ||
Speed.fromTimeIncrement(const TimeIncrement(0, 0)), | ||
); | ||
|
||
void startNewGame(Variant variant, TimeIncrement timeIncrement) { | ||
state = OverTheBoardGameState.fromVariant( | ||
variant, | ||
Speed.fromTimeIncrement(timeIncrement), | ||
); | ||
} | ||
|
||
void rematch() { | ||
state = OverTheBoardGameState.fromVariant( | ||
state.game.meta.variant, | ||
state.game.meta.speed, | ||
); | ||
} | ||
|
||
void makeMove(Move move) { | ||
final (newPos, newSan) = | ||
state.currentPosition.makeSan(Move.parse(move.uci)!); | ||
final sanMove = SanMove(newSan, move); | ||
final newStep = GameStep( | ||
position: newPos, | ||
sanMove: sanMove, | ||
diff: MaterialDiff.fromBoard(newPos.board), | ||
); | ||
|
||
// In an over-the-board game, we support "implicit takebacks": | ||
// When going back one or more steps (i.e. stepCursor < game.steps.length - 1), | ||
// a new move can be made, removing all steps after the current stepCursor. | ||
state = state.copyWith( | ||
game: state.game.copyWith( | ||
steps: state.game.steps | ||
.removeRange(state.stepCursor + 1, state.game.steps.length) | ||
.add(newStep), | ||
), | ||
stepCursor: state.stepCursor + 1, | ||
); | ||
|
||
if (state.currentPosition.isCheckmate) { | ||
state = state.copyWith( | ||
game: state.game.copyWith( | ||
status: GameStatus.mate, | ||
winner: state.turn.opposite, | ||
), | ||
); | ||
} else if (state.currentPosition.isStalemate) { | ||
state = state.copyWith( | ||
game: state.game.copyWith( | ||
status: GameStatus.stalemate, | ||
), | ||
); | ||
} | ||
|
||
_moveFeedback(sanMove); | ||
} | ||
|
||
void onFlag(Side side) { | ||
state = state.copyWith( | ||
game: state.game.copyWith( | ||
status: GameStatus.outoftime, | ||
winner: side.opposite, | ||
), | ||
); | ||
} | ||
|
||
void goForward() { | ||
if (state.canGoForward) { | ||
state = state.copyWith(stepCursor: state.stepCursor + 1); | ||
} | ||
} | ||
|
||
void goBack() { | ||
if (state.canGoBack) { | ||
state = state.copyWith( | ||
stepCursor: state.stepCursor - 1, | ||
); | ||
} | ||
} | ||
|
||
void _moveFeedback(SanMove sanMove) { | ||
final isCheck = sanMove.san.contains('+'); | ||
if (sanMove.san.contains('x')) { | ||
ref.read(moveFeedbackServiceProvider).captureFeedback(check: isCheck); | ||
} else { | ||
ref.read(moveFeedbackServiceProvider).moveFeedback(check: isCheck); | ||
} | ||
} | ||
} | ||
|
||
@freezed | ||
class OverTheBoardGameState with _$OverTheBoardGameState { | ||
const OverTheBoardGameState._(); | ||
|
||
const factory OverTheBoardGameState({ | ||
required OverTheBoardGame game, | ||
@Default(0) int stepCursor, | ||
}) = _OverTheBoardGameState; | ||
|
||
factory OverTheBoardGameState.fromVariant( | ||
Variant variant, | ||
Speed speed, | ||
) { | ||
final position = variant == Variant.chess960 | ||
? randomChess960Position() | ||
: variant.initialPosition; | ||
return OverTheBoardGameState( | ||
game: OverTheBoardGame( | ||
steps: [ | ||
GameStep( | ||
position: position, | ||
), | ||
].lock, | ||
status: GameStatus.started, | ||
initialFen: position.fen, | ||
meta: GameMeta( | ||
createdAt: DateTime.now(), | ||
rated: false, | ||
variant: variant, | ||
speed: speed, | ||
perf: Perf.fromVariantAndSpeed(variant, speed), | ||
), | ||
), | ||
); | ||
} | ||
|
||
Position get currentPosition => game.stepAt(stepCursor).position; | ||
Side get turn => currentPosition.turn; | ||
bool get finished => game.finished; | ||
NormalMove? get lastMove => stepCursor > 0 | ||
? NormalMove.fromUci(game.steps[stepCursor].sanMove!.move.uci) | ||
: null; | ||
|
||
MaterialDiffSide? currentMaterialDiff(Side side) { | ||
return game.steps[stepCursor].diff?.bySide(side); | ||
} | ||
|
||
List<String> get moves => | ||
game.steps.skip(1).map((e) => e.sanMove!.san).toList(growable: false); | ||
|
||
bool get canGoForward => stepCursor < game.steps.length - 1; | ||
bool get canGoBack => stepCursor > 0; | ||
} |
Oops, something went wrong.