From ce6406101e290c86743c800de7e5da6d492bac9c Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Thu, 15 Oct 2026 18:06:14 -0400 Subject: [PATCH 1/3] Watch one player in the replay viewer Reviewing a round usually means asking what one player was doing and what they could see. Now a player can be watched: click their agent on the map or their row in the roster, or press 1-0 (allies 1-5, enemies 6-0, as the roster lists them). Picking them again or Esc stops watching. While watching, only that player's view cone is drawn and cut (the frame leaves everyone else coneless, so a capture saves exactly the one cone on screen), everyone else is drawn at 55% opacity, and the camera glides in to at least 2x and keeps the player centred left of the roster. Panning by hand stops following without ending the watch; picking the player again follows them again. The watched roster row is raised and ringed in violet, and every row's tooltip says how to watch its player. Co-Authored-By: Claude Opus 5.5 --- lib/replay/replay_frame.dart | 14 +- lib/replay/replay_playback.dart | 78 +++- lib/replay_view.dart | 68 ++- lib/widgets/replay/replay_canvas.dart | 249 +++++++++- lib/widgets/replay/replay_match_card.dart | 5 +- lib/widgets/replay/replay_roster.dart | 122 ++++- test/replay_capture_test.dart | 39 ++ test/replay_watch_test.dart | 537 ++++++++++++++++++++++ 8 files changed, 1057 insertions(+), 55 deletions(-) create mode 100644 test/replay_watch_test.dart diff --git a/lib/replay/replay_frame.dart b/lib/replay/replay_frame.dart index 4945c20e9..a6dfdeacd 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 6407d00a0..aaed610a4 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 35b978289..4e9b2b5fd 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 64b4b4119..3e05fe063 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,145 @@ 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 _approach = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 220), + )..addListener(_follow); + 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(); + 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 +307,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 +333,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 +366,17 @@ 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); + double opacityOf(PlacedWidget placed) => + watchedId == null || placed.id == watchedId + ? 1 + : _othersOpacity; // The map as cones reveal it. final revealedMap = Stack( children: [ @@ -292,26 +506,31 @@ class _ReplayCanvasState extends ConsumerState { ? 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 +542,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 83639e693..2ee30f6cc 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 5d034adff..76e5a8753 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,43 @@ 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( 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 +142,96 @@ 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({ + 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 b99d6001c..21450778a 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 000000000..da29a6082 --- /dev/null +++ b/test/replay_watch_test.dart @@ -0,0 +1,537 @@ +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('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('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')); + }); + }); +} From 1a8ded8e9cf49499fe300cddac097ece257e1020 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Thu, 15 Oct 2026 18:24:54 -0400 Subject: [PATCH 2/3] Make the camera's animation with the canvas, not on first use A canvas that never watched anyone made it in dispose, after its ticker mode was gone, and Flutter threw. Co-Authored-By: Claude Opus 5.5 --- lib/widgets/replay/replay_canvas.dart | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/widgets/replay/replay_canvas.dart b/lib/widgets/replay/replay_canvas.dart index 3e05fe063..9e23745f6 100644 --- a/lib/widgets/replay/replay_canvas.dart +++ b/lib/widgets/replay/replay_canvas.dart @@ -141,10 +141,7 @@ class _ReplayCanvasState extends ConsumerState /// Carries the camera to a player picked to watch; once there, it keeps /// them centred each frame. - late final _approach = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 220), - )..addListener(_follow); + late final AnimationController _approach; var _approaching = false; /// Where the camera looked, and how close, as the approach began, and how @@ -159,6 +156,12 @@ class _ReplayCanvasState extends ConsumerState @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); } From 033325ae1f6e2d7eeb3ed5cfeda311e1dcb8e897 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Thu, 15 Oct 2026 19:11:08 -0400 Subject: [PATCH 3/3] Keep each roster row with its player, and draw the watched one on top Swapping sides moves every player to the other block. Unkeyed, the rows stayed put and took on other players, and stopping a watch after a swap then closed the app on Windows (in the accessibility bridge, with a tool reading the window). Keyed by player, it doesn't. The watched agent is now drawn after everyone else, so a dimmed agent standing on them no longer hides them. Co-Authored-By: Claude Opus 5.5 --- lib/widgets/replay/replay_canvas.dart | 13 ++++++-- lib/widgets/replay/replay_roster.dart | 4 +++ test/replay_watch_test.dart | 43 +++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/lib/widgets/replay/replay_canvas.dart b/lib/widgets/replay/replay_canvas.dart index 9e23745f6..6bf91cba8 100644 --- a/lib/widgets/replay/replay_canvas.dart +++ b/lib/widgets/replay/replay_canvas.dart @@ -376,6 +376,16 @@ class _ReplayCanvasState extends ConsumerState 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 @@ -503,8 +513,7 @@ 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), diff --git a/lib/widgets/replay/replay_roster.dart b/lib/widgets/replay/replay_roster.dart index 76e5a8753..d56e64abe 100644 --- a/lib/widgets/replay/replay_roster.dart +++ b/lib/widgets/replay/replay_roster.dart @@ -129,6 +129,9 @@ class _TeamBlock extends StatelessWidget { 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, @@ -146,6 +149,7 @@ class _TeamBlock extends StatelessWidget { /// 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, diff --git a/test/replay_watch_test.dart b/test/replay_watch_test.dart index da29a6082..4a7c06135 100644 --- a/test/replay_watch_test.dart +++ b/test/replay_watch_test.dart @@ -320,6 +320,31 @@ void main() { 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); @@ -521,6 +546,24 @@ void main() { 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');