diff --git a/lib/replay/replay_frame.dart b/lib/replay/replay_frame.dart index 4945c20e..a6dfdeac 100644 --- a/lib/replay/replay_frame.dart +++ b/lib/replay/replay_frame.dart @@ -316,13 +316,18 @@ class ReplayFrameBuilder { /// How much of a thrown utility's path trails behind it. static const trailMs = 350; + /// The placed id of [subject]'s agent on every frame. + static String agentIdOf(String subject) => 'replay-player-$subject'; + /// The moment at [timeMs]. With [cuts], each living player's cone is /// asked for where they are now and drawn from its latest cut; see - /// [ReplayFrame.cones]. + /// [ReplayFrame.cones]. While [watching] a player, only theirs is: the + /// rest are drawn without a cone, and none is cut for them. ReplayFrame frameAt( int timeMs, { required ReplayTeam perspective, ReplayConeCuts? cuts, + String? watching, }) { final round = document.roundAt(timeMs); final isAttack = (round?.attackingTeam ?? ReplayTeam.red) == perspective; @@ -345,7 +350,7 @@ class ReplayFrameBuilder { if (!_onCanvas(standing)) continue; final position = standing - agentAnchor; final isAlly = team == perspective; - final id = 'replay-player-${player.subject}'; + final id = agentIdOf(player.subject); final visionElevation = _visionElevation(pose); final rotation = projection.rotationForYaw(pose.yaw); // A flash shortens the cone; one shorter than the editor allows is @@ -354,7 +359,10 @@ class ReplayFrameBuilder { state.alive ? document.blinds[player.subject]?.flashAt(timeMs) : null; final blindness = flash?.blindness ?? 0.0; final sightLength = coneLength * (1 - blindness); - final sees = state.alive && sightLength >= ViewConeUtility.minLength; + final watched = watching == null || watching == player.subject; + final sees = watched && + state.alive && + sightLength >= ViewConeUtility.minLength; if (flash != null) { flashed.add(ReplayFlashed( agentId: id, diff --git a/lib/replay/replay_playback.dart b/lib/replay/replay_playback.dart index 6407d00a..aaed610a 100644 --- a/lib/replay/replay_playback.dart +++ b/lib/replay/replay_playback.dart @@ -49,19 +49,95 @@ class ReplayPlayback extends ChangeNotifier { final cached = _frame; if (cached != null && cached.timeMs == _timeMs && - _framePerspective == _perspective) { + _framePerspective == _perspective && + _frameWatching == _watching) { return cached; } _framePerspective = _perspective; + _frameWatching = _watching; return _frame = frames.frameAt( _timeMs, perspective: _perspective, cuts: _coneCuts, + watching: _watching, ); } ReplayFrame? _frame; ReplayTeam? _framePerspective; + String? _frameWatching; + + /// The moment on screen built afresh, sharing no objects with [frame]: + /// what a capture saves, cones and all, exactly as drawn. + ReplayFrame freshFrame() => frames.frameAt( + _timeMs, + perspective: _perspective, + watching: _watching, + ); + + /// The player being watched, by subject: only their view cone is drawn. + /// Null while everyone's is. + String? get watching => _watching; + String? _watching; + + /// Whether the camera keeps the watched player centred. Moving the map + /// by hand stops it; picking the player again starts it again. + bool get following => _following; + var _following = false; + + /// Picks [subject] to watch, and follows them. Picking the player already + /// watched follows them again if the camera had stopped, and otherwise + /// stops watching. + void watch(String subject) { + if (_watching == subject && _following) { + stopWatching(); + return; + } + _watching = subject; + _following = true; + notifyListeners(); + } + + void stopWatching() { + if (_watching == null) return; + _watching = null; + _following = false; + notifyListeners(); + } + + /// The user moved the map: the watched player stays watched, but the + /// camera stops following them. + void stopFollowing() { + if (!_following) return; + _following = false; + notifyListeners(); + } + + /// [team]'s players in the order the roster lists them. + List playersOf(ReplayTeam team) => [ + for (final player in document.players) + if (player.team == team) player, + ]; + + /// The player watched with number key [slot] (0 for the 1 key, 9 for the + /// 0 key): the allies top to bottom on 1–5, the enemies on 6–0. + ReplayPlayer? playerInSlot(int slot) { + final team = slot < 5 ? _perspective : _perspective.other; + final players = playersOf(team); + final index = slot % 5; + return index < players.length ? players[index] : null; + } + + /// The number key that watches [player], as it is labelled; null when + /// they have none. + String? watchKeyOf(ReplayPlayer player) { + final team = player.team; + if (team == null) return null; + final index = playersOf(team).indexOf(player); + if (index < 0 || index >= 5) return null; + final slot = index + (team == _perspective ? 0 : 5); + return '${(slot + 1) % 10}'; + } /// Where view cones are cut, once the worker is up. ReplayConeCuts? get coneCuts => _coneCuts; diff --git a/lib/replay_view.dart b/lib/replay_view.dart index 35b97828..4e9b2b5f 100644 --- a/lib/replay_view.dart +++ b/lib/replay_view.dart @@ -256,23 +256,8 @@ class _ReplayViewState extends ConsumerState KeyEventResult _onKey(FocusNode node, KeyEvent event) { final playback = _playback; - if (playback == null || event is KeyUpEvent) return KeyEventResult.ignored; - switch (event.logicalKey) { - case LogicalKeyboardKey.space: - if (event is KeyRepeatEvent) return KeyEventResult.handled; - playback.togglePlaying(); - case LogicalKeyboardKey.arrowLeft: - playback.seek(playback.timeMs - 5000); - case LogicalKeyboardKey.arrowRight: - playback.seek(playback.timeMs + 5000); - case LogicalKeyboardKey.arrowUp: - playback.previousRound(); - case LogicalKeyboardKey.arrowDown: - playback.nextRound(); - default: - return KeyEventResult.ignored; - } - return KeyEventResult.handled; + if (playback == null) return KeyEventResult.ignored; + return replayKey(playback, event); } @override @@ -306,6 +291,55 @@ class _ReplayViewState extends ConsumerState } } +/// What a key does to [playback]: Space plays and pauses, the arrows move +/// through time and rounds, 1–0 watch a player (the allies top to bottom on +/// 1–5, the enemies on 6–0, as the roster lists them) and Esc stops +/// watching. +KeyEventResult replayKey(ReplayPlayback playback, KeyEvent event) { + if (event is KeyUpEvent) return KeyEventResult.ignored; + final slot = _watchSlots.indexOf(event.logicalKey); + if (slot >= 0) { + // Held down, the key would flick between watching and not. + if (event is KeyRepeatEvent) return KeyEventResult.handled; + final player = playback.playerInSlot(slot); + if (player == null) return KeyEventResult.ignored; + playback.watch(player.subject); + return KeyEventResult.handled; + } + switch (event.logicalKey) { + case LogicalKeyboardKey.space: + if (event is KeyRepeatEvent) return KeyEventResult.handled; + playback.togglePlaying(); + case LogicalKeyboardKey.arrowLeft: + playback.seek(playback.timeMs - 5000); + case LogicalKeyboardKey.arrowRight: + playback.seek(playback.timeMs + 5000); + case LogicalKeyboardKey.arrowUp: + playback.previousRound(); + case LogicalKeyboardKey.arrowDown: + playback.nextRound(); + case LogicalKeyboardKey.escape when playback.watching != null: + playback.stopWatching(); + default: + return KeyEventResult.ignored; + } + return KeyEventResult.handled; +} + +/// The number row, 1 to 0, as the game's replay numbers its players. +const _watchSlots = [ + LogicalKeyboardKey.digit1, + LogicalKeyboardKey.digit2, + LogicalKeyboardKey.digit3, + LogicalKeyboardKey.digit4, + LogicalKeyboardKey.digit5, + LogicalKeyboardKey.digit6, + LogicalKeyboardKey.digit7, + LogicalKeyboardKey.digit8, + LogicalKeyboardKey.digit9, + LogicalKeyboardKey.digit0, +]; + class _ReplayStrip extends StatelessWidget { const _ReplayStrip({required this.map, required this.probe}); diff --git a/lib/widgets/replay/replay_canvas.dart b/lib/widgets/replay/replay_canvas.dart index 64b4b411..6bf91cba 100644 --- a/lib/widgets/replay/replay_canvas.dart +++ b/lib/widgets/replay/replay_canvas.dart @@ -1,3 +1,6 @@ +import 'dart:math' as math; +import 'dart:ui' show lerpDouble; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -63,11 +66,72 @@ Widget _inside(Widget view, Widget Function(Widget) wrap) { Widget _faded(Widget view, double opacity) => _inside(view, (child) => Opacity(opacity: opacity, child: child)); +/// [view] at [opacity]. At full strength it is left unwrapped: an +/// [Opacity] is a layer of its own, which no agent should cost while no one +/// is watched. +Widget _steppedBack(Widget view, double opacity) => + opacity == 1 ? view : _faded(view, opacity); + /// [view] with its range drawn faint and its icon solid. Widget _faintRange(Widget view) => _inside( view, (child) => AbilityRangeOpacity(opacity: _rangeOpacity, child: child)); -class _ReplayCanvasState extends ConsumerState { +/// How strongly everyone but the watched player is drawn, so the one being +/// watched stands out. +const _othersOpacity = 0.55; + +/// How close the camera comes, at least, to a player picked to watch. +const _watchScale = 2.0; + +/// How far past its icon's edge a click still picks an agent, as a share of +/// the icon's radius. +const _agentHitSlop = 0.25; + +/// Where [agent]'s icon is centred on the canvas, in canvas pixels. +Offset _agentCentre(PlacedAgentNode agent, {required bool isAttack}) { + final coordinates = CoordinateSystem.instance; + final anchor = CoordinateSystem.virtualToWorld(storedAgentAnchor); + return coordinates.coordinateToScreen(coordinates.positionForSide( + canonicalPosition: agent.position + anchor, + reflectionOffset: Offset.zero, + isAttack: isAttack, + )); +} + +/// The agent drawn under [point], in canvas pixels: of those whose icon +/// (drawn [agentSize] across, grown a little) holds it, the nearest. +@visibleForTesting +PlacedAgentNode? replayAgentAt( + Iterable agents, + Offset point, { + required bool isAttack, + required double agentSize, +}) { + var reach = + CoordinateSystem.instance.scale(agentSize) / 2 * (1 + _agentHitSlop); + PlacedAgentNode? nearest; + for (final agent in agents) { + final distance = (_agentCentre(agent, isAttack: isAttack) - point).distance; + if (distance > reach) continue; + reach = distance; + nearest = agent; + } + return nearest; +} + +/// The camera looking at [focus] (canvas pixels) from the centre of +/// [viewport], at [scale]. +@visibleForTesting +Matrix4 replayCameraOn(Offset focus, double scale, Size viewport) => + Matrix4.diagonal3Values(scale, scale, 1) + ..setTranslationRaw( + viewport.width / 2 - focus.dx * scale, + viewport.height / 2 - focus.dy * scale, + 0, + ); + +class _ReplayCanvasState extends ConsumerState + with SingleTickerProviderStateMixin { final _controller = TransformationController(); Size? _lastViewport; @@ -75,13 +139,148 @@ class _ReplayCanvasState extends ConsumerState { /// it failed), painted together as in the editor. final _cones = ConeCoverage(); + /// Carries the camera to a player picked to watch; once there, it keeps + /// them centred each frame. + late final AnimationController _approach; + var _approaching = false; + + /// Where the camera looked, and how close, as the approach began, and how + /// close it ends. + var _approachFrom = Offset.zero; + var _approachFromScale = 1.0; + var _approachToScale = 1.0; + + /// The player the camera follows, by subject. + String? _followed; + + @override + void initState() { + super.initState(); + // Made here, not on first use: a canvas that never watched anyone + // would otherwise make it in dispose, after its ticker mode is gone. + _approach = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 220), + )..addListener(_follow); + widget.playback.addListener(_playbackChanged); + } + + @override + void didUpdateWidget(ReplayCanvas oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.playback != widget.playback) { + oldWidget.playback.removeListener(_playbackChanged); + widget.playback.addListener(_playbackChanged); + } + } + @override void dispose() { + widget.playback.removeListener(_playbackChanged); + _approach.dispose(); _controller.dispose(); _cones.dispose(); super.dispose(); } + /// Starts following a player as they are picked, and keeps them centred + /// as they move. + void _playbackChanged() { + final playback = widget.playback; + final followed = playback.following ? playback.watching : null; + if (followed != _followed) { + _followed = followed; + if (followed != null) { + _approachWatched(); + } else { + _approach.stop(); + _approaching = false; + } + return; + } + if (followed != null && !_approach.isAnimating) _follow(); + } + + /// Glides to the watched player, coming in to [_watchScale] when further + /// out than that. + void _approachWatched() { + final viewport = _lastViewport; + if (viewport == null) return; + final scale = _controller.value.getMaxScaleOnAxis(); + _approachFrom = _controller.toScene(viewport.center(Offset.zero)); + _approachFromScale = scale; + _approachToScale = math.max(scale, _watchScale); + _approaching = true; + _approach.forward(from: 0); + } + + /// Centres the watched player in the part of the canvas the roster leaves + /// clear; part way there while approaching. + void _follow() { + final viewport = _lastViewport; + final at = _watchedCentre(); + if (viewport == null || at == null) return; + var focus = at; + var scale = _controller.value.getMaxScaleOnAxis(); + if (_approaching) { + final t = Curves.easeOutCubic.transform(_approach.value); + focus = Offset.lerp(_approachFrom, at, t)!; + scale = lerpDouble(_approachFromScale, _approachToScale, t)!; + if (_approach.value == 1) _approaching = false; + } + _controller.value = replayCameraOn(focus, scale, viewport); + } + + /// Where the watched player's icon is, when they are on the map. + Offset? _watchedCentre() { + final subject = widget.playback.watching; + if (subject == null) return null; + final frame = widget.playback.frame; + final id = ReplayFrameBuilder.agentIdOf(subject); + for (final agent in frame.agents) { + if (agent.id == id) return _agentCentre(agent, isAttack: frame.isAttack); + } + return null; + } + + /// A click on an agent watches them; one on empty map does nothing, so + /// the watch is never lost by accident. [point] is in canvas pixels. + void _tapped(Offset point) { + final playback = widget.playback; + final frame = playback.frame; + final agent = replayAgentAt( + frame.agents, + point, + isAttack: frame.isAttack, + agentSize: + ref.read(appPreferencesProvider).defaultAgentSizeForNewStrategies, + ); + if (agent == null) return; + for (final player in playback.document.players) { + if (ReplayFrameBuilder.agentIdOf(player.subject) == agent.id) { + playback.watch(player.subject); + return; + } + } + } + + /// Zooming keeps the followed player centred; moving the map by hand + /// stops following them. + void _interactionStarted(ScaleStartDetails details) { + _approach.stop(); + _approaching = false; + } + + void _interactionUpdated(ScaleUpdateDetails details) { + final playback = widget.playback; + if (!playback.following) return; + if (details.scale == 1 && details.focalPointDelta != Offset.zero) { + playback.stopFollowing(); + } else { + _follow(); + } + } + /// The theme a captured strategy starts with, so the map looks the same /// here and in the editor afterwards. MapThemePalette _palette() { @@ -111,11 +310,16 @@ class _ReplayCanvasState extends ConsumerState { _lastViewport = viewport; _controller.value = Matrix4.identity() ..translateByDouble((viewportWidth - worldWidth) / 2, 0, 0, 1); + if (widget.playback.following) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _follow(); + }); + } } final mapWidth = height * coordinates.mapAspectRatio; final mapLeft = (worldWidth - mapWidth) / 2; - return DecoratedBox( + final canvas = DecoratedBox( decoration: BoxDecoration( gradient: RadialGradient( radius: 1.5, @@ -132,6 +336,8 @@ class _ReplayCanvasState extends ConsumerState { minScale: 1, maxScale: 8, boundaryMargin: const EdgeInsets.all(double.infinity), + onInteractionStart: _interactionStarted, + onInteractionUpdate: _interactionUpdated, child: SizedBox( width: worldWidth, height: height, @@ -163,6 +369,27 @@ class _ReplayCanvasState extends ConsumerState { for (final flash in frame.effects.whereType()) flash.agentId: flash, }; + // While one player is watched, everyone else steps back. + // The watched agent is never wrapped, so its cone keeps + // its state; the rest are drawn without cones. + final watching = widget.playback.watching; + final watchedId = watching == null + ? null + : ReplayFrameBuilder.agentIdOf(watching); + // The watched agent is drawn last, so no one stands on + // them. + final agents = [ + for (final placed in widgets) + if (placed is PlacedAgentNode && placed.id != watchedId) + placed, + for (final placed in widgets) + if (placed is PlacedAgentNode && placed.id == watchedId) + placed, + ]; + double opacityOf(PlacedWidget placed) => + watchedId == null || placed.id == watchedId + ? 1 + : _othersOpacity; // The map as cones reveal it. final revealedMap = Stack( children: [ @@ -286,32 +513,36 @@ class _ReplayCanvasState extends ConsumerState { isAttack: isAttack, abilitySize: abilitySize, ), - for (final placed in widgets) - if (placed is PlacedAgentNode) ...[ + for (final placed in agents) ...[ cuts == null ? ConeCoverageScope( key: ValueKey(placed.id), coverage: _cones, - child: view(placed), + child: _steppedBack( + view(placed), opacityOf(placed)), ) : ViewConesDrawnElsewhere( key: ValueKey(placed.id), - child: view(placed), + child: _steppedBack( + view(placed), opacityOf(placed)), ), // Washed with its agent, under any stacked above. if (flashed[placed.id] case final flash?) Positioned.fill( key: ValueKey('flash-${placed.id}'), child: IgnorePointer( - child: CustomPaint( - painter: ReplayFlashPainter( - flash: flash, - isAttack: isAttack, - agentSize: agentSize, - neutralTeamColors: ref - .watch(strategySettingsProvider) - .useNeutralTeamColors, + child: _steppedBack( + CustomPaint( + painter: ReplayFlashPainter( + flash: flash, + isAttack: isAttack, + agentSize: agentSize, + neutralTeamColors: ref + .watch(strategySettingsProvider) + .useNeutralTeamColors, + ), ), + opacityOf(placed), ), ), ), @@ -323,6 +554,12 @@ class _ReplayCanvasState extends ConsumerState { ), ), ); + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapUp: (details) => + _tapped(_controller.toScene(details.localPosition)), + child: canvas, + ); }, ); } diff --git a/lib/widgets/replay/replay_match_card.dart b/lib/widgets/replay/replay_match_card.dart index 83639e69..2ee30f6c 100644 --- a/lib/widgets/replay/replay_match_card.dart +++ b/lib/widgets/replay/replay_match_card.dart @@ -63,10 +63,7 @@ class _ReplayMatchCardState extends ConsumerState { try { // A fresh frame: the saved page must not share objects with the one // on screen. - final frame = playback.frames.frameAt( - playback.timeMs, - perspective: playback.perspective, - ); + final frame = playback.freshFrame(); final strategy = await _capture.capture(frame, pageName: replayMomentLabel(playback)); _strategyIds.add(strategy.id); diff --git a/lib/widgets/replay/replay_roster.dart b/lib/widgets/replay/replay_roster.dart index 5d034adf..d56e64ab 100644 --- a/lib/widgets/replay/replay_roster.dart +++ b/lib/widgets/replay/replay_roster.dart @@ -66,6 +66,8 @@ extension on ReplayRoster { final round = playback.round; return [ playback.perspective, + playback.watching, + playback.following, round?.index, for (final player in playback.document.players) if (playback.frames.playerState(player, now) case final state) ...[ @@ -96,35 +98,46 @@ class _TeamBlock extends StatelessWidget { Widget build(BuildContext context) { final theme = ShadTheme.of(context); final states = [ - for (final player in playback.document.players) - if (player.team == team) - playback.frames.playerState(player, playback.timeMs), + for (final player in playback.playersOf(team)) + playback.frames.playerState(player, playback.timeMs), ]; final alive = states.where((state) => state.alive).length; + // Rows reach 6px further out than the header, so a watched row's + // surface frames its content and the content lines up with the header. return Padding( - padding: const EdgeInsets.fromLTRB(12, 10, 12, 8), + padding: const EdgeInsets.fromLTRB(6, 10, 6, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Row( - children: [ - Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: team.colorFor(playback.perspective), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Row( + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: team.colorFor(playback.perspective), + ), ), - ), - const Spacer(), - Text('$alive alive', style: theme.textTheme.muted), - ], + const Spacer(), + Text('$alive alive', style: theme.textTheme.muted), + ], + ), ), const SizedBox(height: 6), for (final state in states) _PlayerRow( + // Keyed: a swap moves each player to the other block, and + // a row (its hover, its watched state) must go with them. + key: ValueKey(state.player.subject), state: state, economy: playback.round?.economyFor(state.player.subject), + watched: playback.watching == state.player.subject, + following: playback.following, + watchKey: playback.watchKeyOf(state.player), + onWatch: () => playback.watch(state.player.subject), ), ], ), @@ -132,20 +145,97 @@ class _TeamBlock extends StatelessWidget { } } -class _PlayerRow extends StatelessWidget { - const _PlayerRow({required this.state, required this.economy}); +/// One player as they stand. Clicking the row watches them; the watched +/// player's row is raised and ringed in violet. +class _PlayerRow extends StatefulWidget { + const _PlayerRow({ + super.key, + required this.state, + required this.economy, + required this.watched, + required this.following, + required this.watchKey, + required this.onWatch, + }); final ReplayPlayerState state; final ReplayEconomy? economy; + final bool watched; + + /// Whether the camera follows the watched player. + final bool following; + + /// The number key that watches this player, when they have one. + final String? watchKey; + final VoidCallback onWatch; + + @override + State<_PlayerRow> createState() => _PlayerRowState(); +} + +class _PlayerRowState extends State<_PlayerRow> { + var _hovered = false; + + /// What clicking the row (or pressing its key) does. + String _tooltip(String name) { + final action = !widget.watched + ? 'Watch' + : widget.following + ? 'Stop watching' + : 'Follow'; + final key = widget.watchKey; + return key == null ? '$action $name' : '$action $name ($key)'; + } @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); + final state = widget.state; + final economy = widget.economy; final agent = replayAgentType(state.player.agentId); final agentData = agent == null ? null : AgentData.agents[agent]; final weapon = replayWeaponType(economy?.weapon); final name = state.player.name ?? agentData?.name ?? 'Unknown agent'; final credits = economy?.credits; + const radius = 8.0; + return ShadTooltip( + builder: (context) => Text(_tooltip(name)), + // A Shad detector, so the tooltip sees the hover. + child: ShadGestureDetector( + cursor: SystemMouseCursors.click, + behavior: HitTestBehavior.opaque, + onHoverChange: (hovered) => setState(() => _hovered = hovered), + onTap: widget.onWatch, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: 36, + padding: const EdgeInsets.symmetric(horizontal: 6), + decoration: widget.watched + ? Settings.raisedSurface(radius) + : BoxDecoration( + color: _hovered ? theme.colorScheme.accent : null, + borderRadius: BorderRadius.circular(radius), + ), + foregroundDecoration: widget.watched + ? BoxDecoration( + borderRadius: BorderRadius.circular(radius), + border: Border.all(color: Settings.accentInk), + ) + : null, + child: _content(theme, agentData, name, weapon, credits), + ), + ), + ); + } + + Widget _content( + ShadThemeData theme, + AgentData? agentData, + String name, + WeaponType weapon, + int? credits, + ) { + final state = widget.state; return Opacity( opacity: state.alive ? 1 : 0.4, child: SizedBox( diff --git a/test/replay_capture_test.dart b/test/replay_capture_test.dart index b99d6001..21450778 100644 --- a/test/replay_capture_test.dart +++ b/test/replay_capture_test.dart @@ -24,10 +24,15 @@ import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/replay/replay_capture.dart'; +import 'package:icarus/replay/replay_document.dart'; import 'package:icarus/replay/replay_frame.dart'; +import 'package:icarus/replay/replay_map_projection.dart'; +import 'package:icarus/replay/replay_playback.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:uuid/uuid.dart'; +import 'replay_test_support.dart'; + /// What the library's New Strategy makes: one blank page. Future createBlankStrategy(MapValue map, String name) async { final strategy = StrategyData( @@ -177,6 +182,40 @@ void main() { ); expect(box.isEmpty, isTrue); }); + + test('watching one player, a capture saves only their cone', () async { + // Jett (Red) and Omen (Blue) standing apart, Omen watched. + final document = ReplayDocument.fromBytes(icrp( + { + ...sampleJson(), + 'movement': { + 'a': {'offset': 0, 'count': 3}, + 'b': {'offset': 3 * 24, 'count': 3}, + }, + }, + records: [ + for (var t = 0; t <= 2000; t += 1000) [t, 0, 0, 100, 90, 0], + for (var t = 0; t <= 2000; t += 1000) [t, 600, 0, 100, 90, 0], + ], + )); + final playback = ReplayPlayback( + document: document, + projection: ReplayMapProjection.forMapPath(document.match.mapPath)!, + perspective: ReplayTeam.red, + ) + ..seek(1500) + ..watch('b'); + addTearDown(playback.dispose); + + await capture().capture(playback.freshFrame(), pageName: 'watching'); + + final agents = box.values.single.pages.single.agentData; + expect(agents, hasLength(2)); + expect( + agents.whereType().map((agent) => agent.id), + [ReplayFrameBuilder.agentIdOf('b')], + ); + }); }); group('destination', () { diff --git a/test/replay_watch_test.dart b/test/replay_watch_test.dart new file mode 100644 index 00000000..4a7c0613 --- /dev/null +++ b/test/replay_watch_test.dart @@ -0,0 +1,580 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/const/transition_data.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/replay/replay_cone_cuts.dart'; +import 'package:icarus/replay/replay_document.dart'; +import 'package:icarus/replay/replay_frame.dart'; +import 'package:icarus/replay/replay_map_projection.dart'; +import 'package:icarus/replay/replay_playback.dart'; +import 'package:icarus/replay_view.dart'; +import 'package:icarus/widgets/replay/replay_canvas.dart'; +import 'package:icarus/widgets/inset_shadow_decoration.dart'; +import 'package:icarus/widgets/replay/replay_roster.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'replay_test_support.dart'; + +const jett = 'add6443a-41bd-e414-f6ad-e58d267f4e95'; +const omen = '8e253930-4c05-31dd-1b6c-968525494517'; + +const reds = ['r0', 'r1', 'r2', 'r3', 'r4']; +const blues = ['b0', 'b1', 'b2', 'b3', 'b4']; + +/// Ten players, five Jetts on Red (attacking both rounds) and five Omens +/// on Blue, each standing still at their own spot, one sample a second. +/// [walker] walks along x instead, 1 cm a millisecond. +ReplayDocument tenPlayers({ + List> kills = const [], + String? walker, +}) { + final records = >[]; + final movement = {}; + void stream(String subject, List> samples) { + movement[subject] = { + 'offset': records.length * 24, + 'count': samples.length + }; + records.addAll(samples); + } + + for (final (team, subjects) in [(0, reds), (1, blues)]) { + for (var i = 0; i < subjects.length; i++) { + final subject = subjects[i]; + final x = -1200.0 + i * 600; + final y = team * 1200.0; + stream(subject, [ + for (var t = 0; t <= 120000; t += 1000) + [t, subject == walker ? x + t / 10 : x, y, 100, 90, 0], + ]); + } + } + return ReplayDocument.fromBytes(icrp( + { + ...sampleJson(), + 'players': [ + for (final subject in reds) + {'subject': subject, 'agentId': jett, 'team': 'Red'}, + for (final subject in blues) + {'subject': subject, 'agentId': omen, 'team': 'Blue'}, + ], + 'rounds': [ + {'index': 0, 'startMs': 0, 'endMs': 60000, 'attackingTeam': 'Red'}, + {'index': 1, 'startMs': 60000, 'endMs': 120000, 'attackingTeam': 'Red'}, + ], + 'kills': kills, + 'vitals': {}, + 'movement': movement, + }, + records: records, + )); +} + +/// Records which players' cones a frame asks to be cut, cutting none. +class _RecordingCuts implements ReplayConeCuts { + final wanted = []; + + @override + void want(String subject, ReplayConeAim aim) => wanted.add(subject); + + @override + ReplayConeCut? latest(String subject) => null; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Preferences extends AppPreferencesNotifier { + @override + AppPreferences build() => AppPreferences( + defaultThemeProfileIdForNewStrategies: + MapThemeProfilesProvider.immutableDefaultProfileId, + ); +} + +class _Profiles extends MapThemeProfilesProvider { + @override + MapThemeProfilesState build() => MapThemeProfilesState( + profiles: [MapThemeProfilesProvider.immutableDefaultProfile], + defaultProfileIdForNewStrategies: + MapThemeProfilesProvider.immutableDefaultProfileId, + ); +} + +void main() { + final projection = ReplayMapProjection.forMapPath( + '/Game/Maps/Juliett/Juliett', + )!; + + ReplayPlayback playback(ReplayDocument document) => ReplayPlayback( + document: document, + projection: projection, + perspective: ReplayTeam.red, + ); + + List coneIds(ReplayFrame frame) => [ + for (final agent in frame.agents) + if (agent is PlacedViewConeAgent) agent.id, + ]; + + group('frame', () { + final frames = ReplayFrameBuilder(tenPlayers(), projection); + + test('everyone is on the map with a cone while no one is watched', () { + final frame = frames.frameAt(1000, perspective: ReplayTeam.red); + expect(coneIds(frame), hasLength(10)); + }); + + test('watching one player draws only their cone', () { + final frame = + frames.frameAt(1000, perspective: ReplayTeam.red, watching: 'r2'); + expect(frame.agents, hasLength(10)); + expect(coneIds(frame), [ReplayFrameBuilder.agentIdOf('r2')]); + for (final agent in frame.agents) { + if (agent is PlacedViewConeAgent) continue; + expect((agent as PlacedAgent).state, AgentState.none); + } + }); + + test('no one else has a cone cut', () { + final cuts = _RecordingCuts(); + final frame = frames.frameAt( + 1000, + perspective: ReplayTeam.red, + cuts: cuts, + watching: 'b3', + ); + expect(cuts.wanted, ['b3']); + expect(frame.cones.keys, [ReplayFrameBuilder.agentIdOf('b3')]); + + final everyone = _RecordingCuts(); + frames.frameAt(1000, perspective: ReplayTeam.red, cuts: everyone); + expect(everyone.wanted, [...reds, ...blues]); + }); + + test('a dead watched player has no cone, and their next round does', () { + final frames = ReplayFrameBuilder( + tenPlayers(kills: [ + {'timeMs': 5000, 'victim': 'r2', 'killer': 'b0'}, + ]), + projection, + ); + final dead = + frames.frameAt(6000, perspective: ReplayTeam.red, watching: 'r2'); + expect(coneIds(dead), isEmpty); + final body = dead.agents.singleWhere( + (agent) => agent.id == ReplayFrameBuilder.agentIdOf('r2')); + expect((body as PlacedAgent).state, AgentState.dead); + + final respawned = + frames.frameAt(61000, perspective: ReplayTeam.red, watching: 'r2'); + expect(coneIds(respawned), [ReplayFrameBuilder.agentIdOf('r2')]); + }); + }); + + group('playback', () { + test('watching redraws the moment; swapping perspective keeps it', () { + final p = playback(tenPlayers())..seek(1000); + expect(coneIds(p.frame), hasLength(10)); + p.watch('r2'); + expect(coneIds(p.frame), [ReplayFrameBuilder.agentIdOf('r2')]); + p.perspective = ReplayTeam.blue; + expect(p.watching, 'r2'); + expect(coneIds(p.frame), [ReplayFrameBuilder.agentIdOf('r2')]); + expect(p.frame.agents.firstWhere((a) => a.id.endsWith('r2')).isAlly, + isFalse); + p.stopWatching(); + expect(coneIds(p.frame), hasLength(10)); + }); + + test('picking the watched player follows them again, then stops', () { + final p = playback(tenPlayers()); + p.watch('r2'); + expect((p.watching, p.following), ('r2', true)); + p.stopFollowing(); + expect((p.watching, p.following), ('r2', false)); + p.watch('r2'); + expect((p.watching, p.following), ('r2', true)); + p.watch('r2'); + expect((p.watching, p.following), (null, false)); + p.watch('r2'); + p.watch('b1'); + expect((p.watching, p.following), ('b1', true)); + }); + }); + + group('keys', () { + Future keyboard(WidgetTester tester) async { + final p = playback(tenPlayers()); + addTearDown(p.dispose); + await tester.pumpWidget(Focus( + autofocus: true, + onKeyEvent: (_, event) => replayKey(p, event), + child: const SizedBox(), + )); + await tester.pump(); + return p; + } + + testWidgets('1–5 watch the allies, 6–0 the enemies, as listed', + (tester) async { + final p = await keyboard(tester); + final expected = { + LogicalKeyboardKey.digit1: 'r0', + LogicalKeyboardKey.digit3: 'r2', + LogicalKeyboardKey.digit5: 'r4', + LogicalKeyboardKey.digit6: 'b0', + LogicalKeyboardKey.digit9: 'b3', + LogicalKeyboardKey.digit0: 'b4', + }; + for (final MapEntry(:key, :value) in expected.entries) { + await tester.sendKeyEvent(key); + expect(p.watching, value, reason: '$key'); + } + // Again: stops watching. + await tester.sendKeyEvent(LogicalKeyboardKey.digit0); + expect(p.watching, isNull); + }); + + testWidgets('Esc stops watching', (tester) async { + final p = await keyboard(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.digit2); + expect(p.watching, 'r1'); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + expect(p.watching, isNull); + // With no one watched, Esc is left for whatever else wants it. + expect( + replayKey( + p, + const KeyDownEvent( + physicalKey: PhysicalKeyboardKey.escape, + logicalKey: LogicalKeyboardKey.escape, + timeStamp: Duration.zero, + ), + ), + KeyEventResult.ignored, + ); + }); + + testWidgets('the keys follow a perspective swap', (tester) async { + final p = await keyboard(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.digit2); + expect(p.watching, 'r1'); + p.perspective = ReplayTeam.blue; + // Still watching the same player; the keys now count from Blue. + expect(p.watching, 'r1'); + await tester.sendKeyEvent(LogicalKeyboardKey.digit1); + expect(p.watching, 'b0'); + await tester.sendKeyEvent(LogicalKeyboardKey.digit7); + expect(p.watching, 'r1'); + expect(p.watchKeyOf(p.document.playerBySubject('r1')!), '7'); + expect(p.watchKeyOf(p.document.playerBySubject('b4')!), '5'); + }); + }); + + group('roster', () { + Future roster(WidgetTester tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(600, 900); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + final p = playback(tenPlayers())..seek(1000); + addTearDown(p.dispose); + await tester.pumpWidget(ShadApp( + home: Scaffold( + body: Align( + alignment: Alignment.topRight, + child: SizedBox(height: 880, child: ReplayRoster(playback: p)), + ), + ), + )); + await tester.pump(); + return p; + } + + /// The tooltip shown while the mouse rests on [row]. + Future tooltipOf(WidgetTester tester, Finder row) async { + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(row)); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } + final shown = find + .textContaining(RegExp(r'^(Watch|Stop watching|Follow) ')) + .evaluate() + .map((element) => (element.widget as Text).data!) + .toList(); + await mouse.moveTo(Offset.zero); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } + await mouse.removePointer(); + return shown.single; + } + + testWidgets("a swap never hands one player's row to another", + (tester) async { + final p = await roster(tester); + final subjects = [...reds, ...blues]; + Map rows() => { + for (final subject in subjects) + subject: tester.state(find.byKey(ValueKey(subject))), + }; + p.watch('r2'); + await tester.pump(); + final before = rows(); + p.perspective = p.perspective.other; + await tester.pump(); + final after = rows(); + for (final subject in subjects) { + // Either the player's own row, or a new one: never another's. + final kept = identical(after[subject], before[subject]); + expect(kept || !before.values.contains(after[subject]), isTrue, + reason: subject); + } + p.stopWatching(); + await tester.pump(); + expect(tester.takeException(), isNull); + }); + + testWidgets('clicking a row watches that player, and again stops', + (tester) async { + final p = await roster(tester); + await tester.tap(find.text('Jett').at(2)); + await tester.pump(); + expect((p.watching, p.following), ('r2', true)); + await tester.pump(const Duration(milliseconds: 200)); + await tester.tap(find.text('Omen').at(4)); + await tester.pump(); + expect(p.watching, 'b4'); + await tester.tap(find.text('Omen').at(4)); + await tester.pump(); + expect(p.watching, isNull); + }); + + testWidgets('the watched row is raised and ringed in violet', + (tester) async { + final p = await roster(tester); + List rows() => tester + .widgetList(find.byType(AnimatedContainer)) + .toList(); + expect(rows(), hasLength(10)); + expect(rows().where((row) => row.foregroundDecoration != null), isEmpty); + p.watch('r3'); + await tester.pump(); + final watched = rows()[3]; + expect(watched.decoration, isA()); + expect( + (watched.foregroundDecoration! as BoxDecoration).border, + Border.all(color: Settings.accentInk), + ); + expect(rows().where((row) => row.foregroundDecoration != null), + hasLength(1)); + }); + + testWidgets('each row says how to watch its player', (tester) async { + final p = await roster(tester); + expect( + await tooltipOf(tester, find.text('Jett').first), 'Watch Jett (1)'); + expect( + await tooltipOf(tester, find.text('Omen').at(1)), 'Watch Omen (7)'); + p.watch('r0'); + await tester.pump(); + expect(await tooltipOf(tester, find.text('Jett').first), + 'Stop watching Jett (1)'); + p.stopFollowing(); + await tester.pump(); + expect( + await tooltipOf(tester, find.text('Jett').first), 'Follow Jett (1)'); + }); + }); + + group('map', () { + setUp(() => CoordinateSystem(playAreaSize: const Size(1600, 900))); + + test('a click picks the agent whose icon it lands on', () { + final frame = ReplayFrameBuilder(tenPlayers(), projection) + .frameAt(1000, perspective: ReplayTeam.red); + final coordinates = CoordinateSystem.instance; + final anchor = CoordinateSystem.virtualToWorld(storedAgentAnchor); + final r2 = frame.agents.singleWhere( + (agent) => agent.id == ReplayFrameBuilder.agentIdOf('r2')); + final centre = coordinates.coordinateToScreen(r2.position + anchor); + final radius = coordinates.scale(Settings.agentSize) / 2; + PlacedAgentNode? at(Offset point, {bool isAttack = true}) => + replayAgentAt(frame.agents, point, + isAttack: isAttack, agentSize: Settings.agentSize); + + expect(at(centre)?.id, r2.id); + expect(at(centre + Offset(radius, 0))?.id, r2.id); + expect(at(centre + Offset(radius * 2, 0)), isNull); + // On defense the map is turned round, and the agents with it. + final turned = Offset(1600 - centre.dx, 900 - centre.dy); + expect(at(turned, isAttack: false)?.id, r2.id); + expect(at(centre, isAttack: false)?.id, isNot(r2.id)); + }); + + test('the camera puts its focus in the middle of the viewport', () { + const viewport = Size(1000, 900); + final camera = replayCameraOn(const Offset(300, 200), 2, viewport); + expect(MatrixUtils.transformPoint(camera, const Offset(300, 200)), + const Offset(500, 450)); + expect(camera.getMaxScaleOnAxis(), 2); + }); + }); + + group('canvas', () { + const reservedRight = 296.0; + + Future<(ReplayPlayback, TransformationController)> canvas( + WidgetTester tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1600, 900); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + final p = playback(tenPlayers(walker: 'r2'))..seek(1000); + addTearDown(p.dispose); + await tester.pumpWidget(ProviderScope( + overrides: [ + appPreferencesProvider.overrideWith(_Preferences.new), + mapThemeProfilesProvider.overrideWith(_Profiles.new), + ], + child: ShadApp( + home: Scaffold( + body: ReplayCanvas( + playback: p, + map: projection.map, + reservedRight: reservedRight, + ), + ), + ), + )); + await tester.pump(); + final controller = tester + .widget(find.byType(InteractiveViewer)) + .transformationController!; + return (p, controller); + } + + /// Where [subject]'s icon is on screen now. + Offset onScreen( + ReplayPlayback p, + TransformationController controller, + String subject, + ) { + final agent = p.frame.agents.singleWhere( + (agent) => agent.id == ReplayFrameBuilder.agentIdOf(subject)); + final anchor = CoordinateSystem.virtualToWorld(storedAgentAnchor); + final scene = + CoordinateSystem.instance.coordinateToScreen(agent.position + anchor); + return MatrixUtils.transformPoint(controller.value, scene); + } + + const middle = Offset((1600 - reservedRight) / 2, 450); + + Future settle(WidgetTester tester) async { + for (var i = 0; i < 20; i++) { + await tester.pump(const Duration(milliseconds: 20)); + } + } + + void expectCentred(Offset point) { + expect(point.dx, closeTo(middle.dx, 0.5)); + expect(point.dy, closeTo(middle.dy, 0.5)); + } + + testWidgets('clicking an agent watches and follows them', (tester) async { + final (p, controller) = await canvas(tester); + await tester.tapAt(onScreen(p, controller, 'r2')); + await tester.pump(); + expect((p.watching, p.following), ('r2', true)); + await settle(tester); + expect(controller.value.getMaxScaleOnAxis(), closeTo(2, 1e-9)); + expectCentred(onScreen(p, controller, 'r2')); + + // They walk on; the camera keeps them centred. + p.seek(4000); + await tester.pump(); + expectCentred(onScreen(p, controller, 'r2')); + + // The rest are drawn without cones, dimmed. + final faded = tester + .widgetList(find.byType(Opacity)) + .where((opacity) => opacity.opacity < 1 && opacity.opacity > 0.5); + expect(faded, hasLength(9)); + }); + + testWidgets('a click on empty map keeps the watch', (tester) async { + final (p, controller) = await canvas(tester); + p.watch('b1'); + await settle(tester); + await tester.tapAt(const Offset(40, 40)); + await tester.pump(); + expect((p.watching, p.following), ('b1', true)); + }); + + testWidgets('panning stops following; picking them again resumes', + (tester) async { + final (p, controller) = await canvas(tester); + p.watch('r2'); + await settle(tester); + await tester.dragFrom(const Offset(300, 300), const Offset(150, 60)); + await settle(tester); + expect((p.watching, p.following), ('r2', false)); + expect( + (onScreen(p, controller, 'r2') - middle).distance, greaterThan(100)); + // Let the fling run out. + await tester.pump(const Duration(seconds: 3)); + final panned = controller.value.clone(); + p.seek(5000); + await tester.pump(); + // Left where the user put the map. + expect(controller.value, panned); + + await tester.tapAt(onScreen(p, controller, 'r2')); + await settle(tester); + expect((p.watching, p.following), ('r2', true)); + expectCentred(onScreen(p, controller, 'r2')); + }); + + testWidgets('the watched agent is drawn above the rest', (tester) async { + final (p, _) = await canvas(tester); + List drawn() => [ + for (final element in find + .byWidgetPredicate((widget) => + widget.key is ValueKey && + (widget.key! as ValueKey) + .value + .startsWith('replay-player-')) + .evaluate()) + (element.widget.key! as ValueKey).value, + ]; + final first = drawn().first; + p.watch(first.substring('replay-player-'.length)); + await tester.pump(); + expect(drawn().last, first); + }); + + testWidgets('zooming with the wheel keeps following', (tester) async { + final (p, controller) = await canvas(tester); + p.watch('r2'); + await settle(tester); + final mouse = TestPointer(1, PointerDeviceKind.mouse); + await tester.sendEventToBinding(mouse.hover(const Offset(200, 200))); + await tester.sendEventToBinding(mouse.scroll(const Offset(0, -200))); + await tester.pump(); + expect(controller.value.getMaxScaleOnAxis(), greaterThan(2)); + expect(p.following, isTrue); + expectCentred(onScreen(p, controller, 'r2')); + }); + }); +}