Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move lag compensation and clock improvements #1135

Draft
wants to merge 21 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions lib/src/model/account/account_preferences.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lichess_mobile/src/model/auth/auth_session.dart';
import 'package:lichess_mobile/src/network/http.dart';
import 'package:lichess_mobile/src/utils/l10n_context.dart';
Expand Down Expand Up @@ -29,28 +30,31 @@ typedef AccountPrefState = ({
});

/// A provider that tells if the user wants to see ratings in the app.
final showRatingsPrefProvider = FutureProvider<bool>((ref) async {
@Riverpod(keepAlive: true)
Future<bool> showRatingsPref(Ref ref) async {
return ref.watch(
accountPreferencesProvider
.selectAsync((state) => state?.showRatings.value ?? true),
);
});
}

final clockSoundProvider = FutureProvider<bool>((ref) async {
@Riverpod(keepAlive: true)
Future<bool> clockSound(Ref ref) async {
return ref.watch(
accountPreferencesProvider
.selectAsync((state) => state?.clockSound.value ?? true),
);
});
}

final pieceNotationProvider = FutureProvider<PieceNotation>((ref) async {
@Riverpod(keepAlive: true)
Future<PieceNotation> pieceNotation(Ref ref) async {
return ref.watch(
accountPreferencesProvider.selectAsync(
(state) =>
state?.pieceNotation ?? defaultAccountPreferences.pieceNotation,
),
);
});
}

final defaultAccountPreferences = (
zenMode: Zen.no,
Expand Down
184 changes: 184 additions & 0 deletions lib/src/model/clock/chess_clock.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import 'dart:async';

import 'package:clock/clock.dart';
import 'package:dartchess/dartchess.dart';
import 'package:flutter/foundation.dart';

const _emergencyDelay = Duration(seconds: 20);
const _tickDelay = Duration(milliseconds: 100);

/// A chess clock.
class ChessClock {
ChessClock({
required Duration whiteTime,
required Duration blackTime,
this.emergencyThreshold,
this.onFlag,
this.onEmergency,
}) : _whiteTime = ValueNotifier(whiteTime),
_blackTime = ValueNotifier(blackTime),
_activeSide = Side.white;

/// The threshold at which the clock will call [onEmergency] if provided.
final Duration? emergencyThreshold;

/// Callback when the clock reaches zero.
VoidCallback? onFlag;

/// Called when one clock timers reaches the emergency threshold.
final void Function(Side activeSide)? onEmergency;

Timer? _timer;
Timer? _startDelayTimer;
DateTime? _lastStarted;
final _stopwatch = clock.stopwatch();
bool _shouldPlayEmergencyFeedback = true;
DateTime? _nextEmergency;

final ValueNotifier<Duration> _whiteTime;
final ValueNotifier<Duration> _blackTime;
Side _activeSide;

bool get isRunning {
return _lastStarted != null;
}

/// Returns the current white time.
ValueListenable<Duration> get whiteTime => _whiteTime;

/// Returns the current black time.
ValueListenable<Duration> get blackTime => _blackTime;

/// Returns the current active time.
ValueListenable<Duration> get activeTime => _activeTime;

/// Returns the current active side.
Side get activeSide => _activeSide;

/// Sets the time for either side.
void setTimes({Duration? whiteTime, Duration? blackTime}) {
if (whiteTime != null) {
_whiteTime.value = whiteTime;
}
if (blackTime != null) {
_blackTime.value = blackTime;
}
}

/// Sets the time for the given side.
void setTime(Side side, Duration time) {
if (side == Side.white) {
_whiteTime.value = time;
} else {
_blackTime.value = time;
}
}

/// Increments the time for either side.
void incTimes({Duration? whiteInc, Duration? blackInc}) {
if (whiteInc != null) {
_whiteTime.value += whiteInc;
}
if (blackInc != null) {
_blackTime.value += blackInc;
}
}

/// Increments the time for the given side.
void incTime(Side side, Duration increment) {
if (side == Side.white) {
_whiteTime.value += increment;
} else {
_blackTime.value += increment;
}
}

/// Starts the clock and switch to the given side.
///
/// Trying to start an already running clock on the same side is a no-op.
///
/// The [delay] parameter can be used to add a delay before the clock starts counting down. This is useful for lag compensation.
///
/// Returns the think time of the active side before switching or `null` if the clock is not running.
Duration? startSide(Side side, {Duration? delay}) {
if (isRunning && _activeSide == side) {
return _thinkTime;
}
_activeSide = side;
final thinkTime = _thinkTime;
start(delay: delay);
return thinkTime;
}

/// Starts the clock.
///
/// The [delay] parameter can be used to add a delay before the clock starts counting down. This is useful for lag compensation.
void start({Duration? delay}) {
_lastStarted = clock.now().add(delay ?? Duration.zero);
_startDelayTimer?.cancel();
_startDelayTimer = Timer(delay ?? Duration.zero, _scheduleTick);
}

/// Pauses the clock.
///
/// Returns the current think time for the active side.
Duration stop() {
_stopwatch.stop();
_startDelayTimer?.cancel();
_timer?.cancel();
final thinkTime = _thinkTime ?? Duration.zero;
_lastStarted = null;
return thinkTime;
}

void dispose() {
_timer?.cancel();
_startDelayTimer?.cancel();
_whiteTime.dispose();
_blackTime.dispose();
}

/// Returns the current think time for the active side.
Duration? get _thinkTime {
if (_lastStarted == null) {
return null;
}
return clock.now().difference(_lastStarted!);
}

ValueNotifier<Duration> get _activeTime {
return activeSide == Side.white ? _whiteTime : _blackTime;
}

void _scheduleTick() {
_stopwatch.reset();
_stopwatch.start();
_timer?.cancel();
_timer = Timer(_tickDelay, _tick);
}

void _tick() {
final newTime = _activeTime.value - _stopwatch.elapsed;
_activeTime.value = newTime < Duration.zero ? Duration.zero : newTime;
_checkEmergency();
if (_activeTime.value == Duration.zero) {
onFlag?.call();
}
_scheduleTick();
}

void _checkEmergency() {
final timeLeft = _activeTime.value;
if (emergencyThreshold != null &&
timeLeft <= emergencyThreshold! &&
_shouldPlayEmergencyFeedback &&
(_nextEmergency == null || _nextEmergency!.isBefore(clock.now()))) {
_shouldPlayEmergencyFeedback = false;
_nextEmergency = clock.now().add(_emergencyDelay);
onEmergency?.call(_activeSide);
} else if (emergencyThreshold != null &&
timeLeft > emergencyThreshold! * 1.5) {
_shouldPlayEmergencyFeedback = true;
}
}
}
Loading