Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions lib/replay/replay_capture.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:icarus/collab/collab_models.dart';
import 'package:icarus/collab/replay_capture_record.dart';
import 'package:icarus/const/drawing_element.dart';
import 'package:icarus/const/hive_boxes.dart';
import 'package:icarus/const/maps.dart';
import 'package:icarus/const/placed_classes.dart';
Expand Down Expand Up @@ -52,22 +53,24 @@ class ReplayCapture {
/// The pages captured into [strategy], in order.
List<String> get pageIds => List.unmodifiable(_pageIds);

/// Saves [frame] as a page named [pageName] and returns the strategy.
/// Refuses, without writing, a frame holding a non-finite number. If the
/// user deleted the strategy or moved it to another map, this starts a
/// new one rather than writing this map's positions into it.
/// Saves [frame] as a page named [pageName], with [drawings] as its
/// drawings, and returns the strategy. Refuses, without writing, a frame
/// holding a non-finite number. If the user deleted the strategy or moved
/// it to another map, this starts a new one rather than writing this
/// map's positions into it.
///
/// Captures are written one after another, in the order asked for.
Future<CapturedStrategy> capture(
ReplayFrame frame, {
required String pageName,
List<DrawingElement> drawings = const [],
}) {
try {
_checkFinite(frame);
} catch (error, stackTrace) {
return Future.error(error, stackTrace);
}
final page = (name: pageName, frame: frame);
final page = (name: pageName, frame: frame, drawings: drawings);
final written = _tail.then((_) => _write(page));
_tail = written.then((_) {}, onError: (_) {});
return written;
Expand Down Expand Up @@ -133,8 +136,12 @@ class ReplayCapture {
}
}

/// One replay moment to save as a page.
typedef CapturedPage = ({String name, ReplayFrame frame});
/// One replay moment to save as a page, with what the user drew over it.
typedef CapturedPage = ({
String name,
ReplayFrame frame,
List<DrawingElement> drawings,
});

/// The strategy a replay's captures fill, as it stood when last read.
typedef CapturedStrategy = ({
Expand Down Expand Up @@ -273,7 +280,7 @@ class LocalReplayCaptureStore implements ReplayCaptureStore {
id: const Uuid().v4(),
name: captured.name,
isAutoNamed: false,
drawingData: const [],
drawingData: captured.drawings,
agentData: frame.agents,
abilityData: frame.abilities,
textData: const [],
Expand Down Expand Up @@ -389,7 +396,7 @@ class CloudReplayCaptureStore implements ReplayCaptureStore {
id: pageId,
name: page.name,
isAutoNamed: false,
drawingData: const [],
drawingData: page.drawings,
agentData: frame.agents,
abilityData: frame.abilities,
textData: const [],
Expand Down
215 changes: 215 additions & 0 deletions lib/replay/replay_drawing.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import 'dart:math' as math;

import 'package:flutter/material.dart';
import 'package:icarus/const/bounding_box.dart';
import 'package:icarus/const/coordinate_system.dart';
import 'package:icarus/const/drawing_element.dart';
import 'package:icarus/const/settings.dart';
import 'package:icarus/providers/action_history_models.dart';
import 'package:icarus/providers/drawing_provider.dart';
import 'package:icarus/replay/replay_playback.dart';
import 'package:uuid/uuid.dart';

/// The two ways to draw over a replay.
enum ReplayDrawingTool { freehand, arrow }

/// What the user draws over the paused replay. Strokes are the editor's own
/// drawing elements, in the same world coordinates as a page's, so they look
/// like editor drawings and a capture saves them as page drawings.
///
/// They mark up the moment on screen, so they go when it does: playing
/// clears them and puts the pen away, and moving the clock while paused
/// clears them. Nothing is saved by drawing.
class ReplayDrawing extends ChangeNotifier {
ReplayDrawing(this.playback) : _timeMs = playback.timeMs {
playback.addListener(_playbackChanged);
}

final ReplayPlayback playback;

bool _active = false;
ReplayDrawingTool _tool = ReplayDrawingTool.freehand;
final List<DrawingElement> _strokes = [];
DrawingElement? _current;
int _revision = 0;
int _timeMs;

/// Whether a drag on the map draws rather than pans.
bool get active => _active;
ReplayDrawingTool get tool => _tool;

/// The finished strokes, oldest first.
List<DrawingElement> get strokes => List.unmodifiable(_strokes);

/// The stroke under the pointer, until the drag ends.
DrawingElement? get current => _current;

/// Changes whenever what is drawn changes, for the painter.
int get revision => _revision;

/// Takes the pen out, pausing playback, or puts it away. Strokes stay
/// either way.
void toggle() => _active ? stop() : start();

void start() {
playback.pause();
if (_active) return;
_active = true;
notifyListeners();
}

void stop() {
if (!_active) return;
_active = false;
_current = null;
_revision++;
notifyListeners();
}

set tool(ReplayDrawingTool value) {
if (_tool == value) return;
_tool = value;
notifyListeners();
}

/// Starts a stroke at [screenPoint], a point on the canvas in the
/// attack-side world [coordinates] describe, as the editor's painter does.
void begin(
Offset screenPoint,
CoordinateSystem coordinates, {
required Color color,
required double thickness,
}) {
if (!_active) return;
final point = coordinates.screenToCoordinate(screenPoint);
final id = const Uuid().v4();
final box = BoundingBox(min: point, max: point);
_current = switch (_tool) {
ReplayDrawingTool.freehand => FreeDrawing(
colorValue: color.toARGB32(),
thickness: thickness,
boundingBox: box,
isDotted: false,
hasArrow: false,
id: id,
)..appendPoint(point),
ReplayDrawingTool.arrow => Line(
lineStart: point,
lineEnd: point,
colorValue: color.toARGB32(),
thickness: thickness,
boundingBox: box,
isDotted: false,
hasArrow: true,
id: id,
),
};
_changed();
}

/// Carries the stroke under the pointer on to [screenPoint].
void extend(Offset screenPoint, CoordinateSystem coordinates) {
final point = coordinates.screenToCoordinate(screenPoint);
switch (_current) {
case final FreeDrawing stroke:
// The editor's spacing, so a stroke is as smooth here as there.
final minDistance = coordinates.normalize(Settings.freeDrawMinDistance);
if ((stroke.listOfPoints.last - point).distance < minDistance) return;
stroke
..appendPoint(point)
..boundingBox = _grown(stroke.boundingBox!, point)
..rebuildPath(coordinates);
case final Line line:
line
..updateEndPoint(point)
..boundingBox = _grown(
BoundingBox(min: line.lineStart, max: line.lineStart),
point,
);
default:
return;
}
_changed();
}

/// Ends the stroke under the pointer. A click that never moved draws
/// nothing.
void end(CoordinateSystem coordinates) {
final stroke = _current;
if (stroke == null) return;
_current = null;
switch (stroke) {
case final FreeDrawing drawn when drawn.listOfPoints.length > 1:
var finished = drawn;
if (Settings.enableStrokeSimplification) {
finished =
douglasPeucker(finished, Settings.strokeSimplificationEpsilon);
}
_strokes.add(finished
..recomputeCachedPolylineLength()
..rebuildPath(coordinates));
case final Line line when line.lineEnd != line.lineStart:
_strokes.add(line);
default:
break;
}
_changed();
}

void clear() {
if (_strokes.isEmpty && _current == null) return;
_strokes.clear();
_current = null;
_changed();
}

/// Builds every freehand path again for a canvas of a new size. Doesn't
/// notify: it runs while the layer that paints them builds.
void rebuildPaths(CoordinateSystem coordinates) {
for (final stroke in [..._strokes, if (_current != null) _current!]) {
if (stroke is FreeDrawing) stroke.rebuildPath(coordinates);
}
_revision++;
}

/// Copies of the strokes for a captured page, sharing nothing with the
/// ones on screen.
List<DrawingElement> forCapture() =>
[for (final stroke in _strokes) cloneDrawingElement(stroke)];

void _playbackChanged() {
final moved = playback.timeMs != _timeMs;
_timeMs = playback.timeMs;
if (playback.playing) {
if (!_active && _strokes.isEmpty && _current == null) return;
_active = false;
_strokes.clear();
_current = null;
_changed();
} else if (moved) {
clear();
}
}

void _changed() {
_revision++;
notifyListeners();
}

static BoundingBox _grown(BoundingBox box, Offset point) => BoundingBox(
min: Offset(
math.min(box.min.dx, point.dx),
math.min(box.min.dy, point.dy),
),
max: Offset(
math.max(box.max.dx, point.dx),
math.max(box.max.dy, point.dy),
),
);

@override
void dispose() {
playback.removeListener(_playbackChanged);
super.dispose();
}
}
37 changes: 35 additions & 2 deletions lib/replay_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:icarus/const/maps.dart';
import 'package:icarus/const/settings.dart';
import 'package:icarus/const/shortcut_info.dart';
import 'package:icarus/providers/map_provider.dart';
import 'package:icarus/providers/replay_library_provider.dart';
import 'package:icarus/providers/strategy_settings_provider.dart';
Expand All @@ -16,6 +17,7 @@ import 'package:icarus/const/utilities.dart';
import 'package:icarus/replay/replay_cone_cuts.dart';
import 'package:icarus/replay/replay_cone_worker.dart';
import 'package:icarus/replay/replay_decoder.dart';
import 'package:icarus/replay/replay_drawing.dart';
import 'package:icarus/replay/replay_frame.dart';
import 'package:icarus/replay/replay_document.dart';
import 'package:icarus/replay/replay_files.dart';
Expand Down Expand Up @@ -62,6 +64,10 @@ class _ReplayViewState extends ConsumerState<ReplayView>
Timer? _progressTimer;

ReplayPlayback? _playback;

/// What the user draws over the paused replay. The screen's own, never
/// the editor's drawings.
ReplayDrawing? _drawing;
ProviderSubscription<AsyncValue<SvgHeightRuntime?>>? _heightRuntime;
SvgHeightVisibility? _heightModel;
SvgHeightRuntime? _heightRuntimeValue;
Expand Down Expand Up @@ -117,7 +123,10 @@ class _ReplayViewState extends ConsumerState<ReplayView>
perspective: perspective,
)..addListener(_syncMapSide);
if (_heightModel != null) playback.heightModel = _heightModel;
setState(() => _playback = playback);
setState(() {
_playback = playback;
_drawing = ReplayDrawing(playback);
});
unawaited(_startConeCuts());
claimEditorState();
_ticker.start();
Expand Down Expand Up @@ -248,6 +257,7 @@ class _ReplayViewState extends ConsumerState<ReplayView>
_loader.cancel();
_ticker.dispose();
_coneCuts?.dispose();
_drawing?.dispose();
_playback
?..removeListener(_syncMapSide)
..dispose();
Expand All @@ -256,7 +266,25 @@ class _ReplayViewState extends ConsumerState<ReplayView>

KeyEventResult _onKey(FocusNode node, KeyEvent event) {
final playback = _playback;
if (playback == null) return KeyEventResult.ignored;
final drawing = _drawing;
if (playback == null || drawing == null || event is KeyUpEvent) {
return KeyEventResult.ignored;
}
// The pen answers to the editor's Draw key, whatever the user bound it
// to, so the habit carries over.
final draw = ShortcutInfo.effectiveBindingFor(
IcarusShortcutAction.draw.name,
ref.read(appPreferencesProvider).customShortcutBindings,
).toActivator();
if (draw.accepts(event, HardwareKeyboard.instance)) {
if (event is! KeyRepeatEvent) drawing.toggle();
return KeyEventResult.handled;
}
// Esc puts the pen away before it stops watching a player.
if (event.logicalKey == LogicalKeyboardKey.escape && drawing.active) {
drawing.stop();
return KeyEventResult.handled;
}
return replayKey(playback, event);
}

Expand All @@ -275,6 +303,7 @@ class _ReplayViewState extends ConsumerState<ReplayView>
child: playback != null
? _ReplayBench(
playback: playback,
drawing: _drawing!,
map: map!,
onReturnFromEditor: claimEditorState,
)
Expand Down Expand Up @@ -405,11 +434,13 @@ String replayMapName(MapValue map) {
class _ReplayBench extends StatelessWidget {
const _ReplayBench({
required this.playback,
required this.drawing,
required this.map,
required this.onReturnFromEditor,
});

final ReplayPlayback playback;
final ReplayDrawing drawing;
final MapValue map;
final VoidCallback onReturnFromEditor;

Expand All @@ -421,6 +452,7 @@ class _ReplayBench extends StatelessWidget {
child: RepaintBoundary(
child: ReplayCanvas(
playback: playback,
drawing: drawing,
map: map,
reservedRight: ReplayRoster.width + 16,
),
Expand All @@ -432,6 +464,7 @@ class _ReplayBench extends StatelessWidget {
padding: const EdgeInsets.all(8),
child: ReplayMatchCard(
playback: playback,
drawing: drawing,
map: map,
onReturnFromEditor: onReturnFromEditor,
),
Expand Down
Loading
Loading