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
18 changes: 18 additions & 0 deletions lib/interactive_map.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:icarus/widgets/draggable_widgets/utilities/svg_height_view_cone.dart';
import 'package:icarus/providers/svg_height_runtime_provider.dart';
import 'package:icarus/widgets/editor_operation_scope.dart';
import 'package:icarus/const/coordinate_system.dart';
import 'package:icarus/const/maps.dart';
Expand Down Expand Up @@ -130,6 +132,21 @@ class _InteractiveMapState extends ConsumerState<InteractiveMap> {
super.dispose();
}

/// The painted floor, which the revealed map is clipped to once; null
/// until the map's height model loads (no height cones show before it
/// does).
Path? _revealedFloor(bool isAttack) {
final map = ref.watch(mapProvider).currentMap;
final model = ref
.watch(svgHeightRuntimeProvider(map))
.asData
?.value
?.model(isAttack);
if (model == null) return null;
return SvgHeightMapTransform.forMap(map)
.floorOnScreen(model, isAttack: isAttack);
}

@override
Widget build(BuildContext context) {
bool isAttack = ref.watch(mapProvider).isAttack;
Expand Down Expand Up @@ -308,6 +325,7 @@ class _InteractiveMapState extends ConsumerState<InteractiveMap> {
child: IgnorePointer(
child: ConeCoverageLayer(
coverage: _cones,
floor: _revealedFloor(isAttack),
opacity: ref
.watch(interactionStateProvider)
.isLineUpMode
Expand Down
132 changes: 100 additions & 32 deletions lib/view_cone/cone_coverage.dart
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,16 @@ double _contrast(Color a, Color b) {
return (math.max(la, lb) + 0.05) / (math.min(la, lb) + 0.05);
}

/// Something that paints one cone's lit area, clipped to the floor, with a
/// given [fill], in its own coordinates.
/// Something that paints one cone's lit area with a given [fill], in its
/// own coordinates.
abstract interface class ConeAreaPainter {
/// How strongly this cone is drawn: below 1 for a drag preview.
double get opacity;

void paintArea(Canvas canvas, Size size, Paint fill);
/// Paints the lit area, also clipped to the painted floor when
/// [clipToFloor] (when the layer it joins does not clip its map itself).
void paintArea(Canvas canvas, Size size, Paint fill,
{required bool clipToFloor});
}

/// Gives the cones below it a shared [ConeCoverage].
Expand Down Expand Up @@ -240,49 +243,64 @@ class RenderConeArea extends RenderBox {
bool hitTestSelf(Offset position) => false;
}

/// Paints [mask] (what cones see, opaque where they reach) and shows
/// [child] only there. Everything happens inside its own layer, so nothing
/// under it is read; [child] must draw straight to the canvas (a picture, no
/// repaint boundary of its own).
void _paintRevealed(
/// Shows [child] only where [mask] (what cones see, opaque where they reach)
/// paints, and returns the layer that does it.
///
/// [child] is a repaint boundary, so the revealed map keeps one picture
/// while cones move. The mask is drawn over it with dstIn, both inside a
/// layer of their own (a clip to the whole box that saves a layer), so dstIn
/// reaches only the revealed map, never the map under it. A colour filter
/// would group them too, but the engine caches a filter's children as one
/// image, which would redraw the map into a new image with every mask.
ClipRectLayer? _paintRevealed(
PaintingContext context,
bool needsCompositing,
Offset offset,
Size size,
RenderBox child,
ClipRectLayer? oldLayer,
void Function(Canvas canvas) mask,
) {
final canvas = context.canvas;
final bounds = offset & size;
canvas.saveLayer(bounds, Paint());
canvas.save();
canvas.translate(offset.dx, offset.dy);
mask(canvas);
canvas.restore();
canvas.saveLayer(bounds, Paint()..blendMode = BlendMode.srcIn);
context.paintChild(child, offset);
canvas.restore();
canvas.restore();
return context.pushClipRect(needsCompositing, offset, Offset.zero & size,
(context, offset) {
context.paintChild(child, offset);
final canvas = context.canvas;
canvas.saveLayer(offset & size, Paint()..blendMode = BlendMode.dstIn);
canvas.translate(offset.dx, offset.dy);
mask(canvas);
canvas.restore();
}, clipBehavior: Clip.antiAliasWithSaveLayer, oldLayer: oldLayer);
}

/// Shows [child], the revealed copy of the map, wherever a cone registered
/// with [coverage] reaches.
class ConeCoverageLayer extends SingleChildRenderObjectWidget {
const ConeCoverageLayer({
ConeCoverageLayer({
super.key,
required this.coverage,
this.opacity = 1,
required super.child,
});
Path? floor,
required Widget child,
}) : clipsToFloor = floor != null,
super(child: _cachedMap(floor, child));

final ConeCoverage coverage;

/// Whether the revealed map is clipped to the floor here, once, so cones
/// need not each clip themselves.
final bool clipsToFloor;

/// How strongly the whole layer shows, as placed widgets are dimmed in
/// lineup mode.
final double opacity;

@override
RenderConeCoverageLayer createRenderObject(BuildContext context) =>
RenderConeCoverageLayer(coverage: coverage, opacity: opacity);
RenderConeCoverageLayer(
coverage: coverage,
opacity: opacity,
clipsToFloor: clipsToFloor,
);

@override
void updateRenderObject(
Expand All @@ -291,16 +309,26 @@ class ConeCoverageLayer extends SingleChildRenderObjectWidget {
) {
renderObject
..coverage = coverage
..opacity = opacity;
..opacity = opacity
..clipsToFloor = clipsToFloor;
}
}

class RenderConeCoverageLayer extends RenderProxyBox {
RenderConeCoverageLayer({
required ConeCoverage coverage,
required double opacity,
required bool clipsToFloor,
}) : _coverage = coverage,
_opacity = opacity;
_opacity = opacity,
_clipsToFloor = clipsToFloor;

bool _clipsToFloor;
set clipsToFloor(bool value) {
if (value == _clipsToFloor) return;
_clipsToFloor = value;
markNeedsPaint();
}

ConeCoverage _coverage;
set coverage(ConeCoverage value) {
Expand Down Expand Up @@ -340,13 +368,17 @@ class RenderConeCoverageLayer extends RenderProxyBox {
@override
void paint(PaintingContext context, Offset offset) {
final child = this.child;
if (child == null || _opacity <= 0) return;
final areas = [
for (final area in _coverage.areas)
if (area.attached && area.hasSize) area,
];
if (areas.isEmpty) return;
_paintRevealed(context, offset, size, child, (canvas) {
if (child == null || _opacity <= 0 || areas.isEmpty) {
layer = null;
return;
}
layer = _paintRevealed(
context, needsCompositing, offset, size, child, layer as ClipRectLayer?,
(canvas) {
// Each cone's strength as an opaque grey on black (an edge pixel it
// half covers is half as grey), lightened together: wherever cones
// overlap, the strongest one counts, in any order. The grey then
Expand All @@ -373,6 +405,7 @@ class RenderConeCoverageLayer extends RenderProxyBox {
Paint()
..color = Color.from(
alpha: 1, red: strength, green: strength, blue: strength),
clipToFloor: !_clipsToFloor,
);
canvas.restore();
canvas.restore();
Expand All @@ -393,7 +426,14 @@ class RenderConeCoverageLayer extends RenderProxyBox {
/// Shows [child], the revealed copy of the map, wherever [cones] (a painter
/// that draws every cone itself, opaque where each reaches) paints.
class RevealedFloor extends SingleChildRenderObjectWidget {
const RevealedFloor({super.key, required this.cones, required super.child});
/// [floor], when given, is where [child] shows at all: it is clipped to it
/// once, in the map's own cached layer, so [cones] need not clip to it.
RevealedFloor({
super.key,
required this.cones,
Path? floor,
required Widget child,
}) : super(child: _cachedMap(floor, child));

final CustomPainter cones;

Expand Down Expand Up @@ -433,9 +473,12 @@ class RenderRevealedFloor extends RenderProxyBox {
@override
void paint(PaintingContext context, Offset offset) {
final child = this.child;
if (child == null) return;
_paintRevealed(
context, offset, size, child, (canvas) => _cones.paint(canvas, size));
if (child == null) {
layer = null;
return;
}
layer = _paintRevealed(context, needsCompositing, offset, size, child,
layer as ClipRectLayer?, (canvas) => _cones.paint(canvas, size));
}
}

Expand All @@ -462,3 +505,28 @@ class _ConeCoverageHostState extends State<ConeCoverageHost> {
@override
Widget build(BuildContext context) => widget.builder(context, _coverage);
}

/// The revealed map in a layer of its own, clipped to [floor] if given.
///
/// Clipping the map once here, rather than every cone to the floor, is most
/// of what makes cones cheap on Windows: the engine draws there without a
/// stencil buffer, so a clip to a shape as complex as the floor is a mask
/// drawn on the CPU, and cones moving every frame need new ones.
Widget _cachedMap(Path? floor, Widget map) => RepaintBoundary(
child: floor == null
? map
: ClipPath(clipper: _FloorClipper(floor), child: map),
);

class _FloorClipper extends CustomClipper<Path> {
const _FloorClipper(this.floor);

final Path floor;

@override
Path getClip(Size size) => floor;

@override
bool shouldReclip(_FloorClipper oldClipper) =>
!identical(oldClipper.floor, floor);
}
98 changes: 87 additions & 11 deletions lib/widgets/draggable_widgets/utilities/svg_height_view_cone.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,48 @@ class SvgHeightMapTransform {
Offset sideWorldFromSource(Offset point, {required bool isAttack}) =>
offset +
(point - (isAttack ? Offset.zero : defenseArtworkOffset)) * scale;

/// Height-model space to the screen, seen from one side: an even scale
/// and an offset.
Float64List sourceToScreen({required bool isAttack}) {
final coordinates = CoordinateSystem.instance;
final screenScale = coordinates.worldHeightToScreen(scale);
final shift = coordinates.coordinateToScreen(
sideWorldFromSource(Offset.zero, isAttack: isAttack));
return Float64List(16)
..[0] = screenScale
..[5] = screenScale
..[10] = 1
..[12] = shift.dx
..[13] = shift.dy
..[15] = 1;
}

/// The painted floor of [model] on screen: where a cone may reveal
/// anything. Off it, and in its holes, cones reveal nothing. Kept while
/// the screen stays put, so a clip to it can be drawn once and reused.
Path floorOnScreen(SvgHeightVisibility model, {required bool isAttack}) {
final toScreen = sourceToScreen(isAttack: isAttack);
final cached = _floors[model];
if (cached != null &&
cached.$1[0] == toScreen[0] &&
cached.$1[12] == toScreen[12] &&
cached.$1[13] == toScreen[13]) {
return cached.$2;
}
Path? floor;
for (final receiver in model.receivers) {
final next = SvgHeightViewConePainter.ringsPath(receiver.rings,
evenOdd: receiver.evenOdd);
floor =
floor == null ? next : Path.combine(PathOperation.union, floor, next);
}
final screen = (floor ?? Path()).transform(toScreen);
_floors[model] = (toScreen, screen);
return screen;
}

static final _floors = Expando<(Float64List, Path)>();
}

/// Compatibility accessors for Split-specific tests and audit tools.
Expand Down Expand Up @@ -285,30 +327,64 @@ class SvgHeightViewConePainter extends CustomPainter

/// Painting by itself: a grey fading toward the cone's reach.
@override
void paint(Canvas canvas, Size size) => _paintArea(canvas, _fill);
void paint(Canvas canvas, Size size) =>
_paintArea(canvas, _fill, clipToFloor: true);

/// Painting into a [ConeCoverageLayer]: flat [fill].
@override
void paintArea(Canvas canvas, Size size, Paint fill) =>
_paintArea(canvas, (_, __) => fill);
void paintArea(Canvas canvas, Size size, Paint fill,
{required bool clipToFloor}) =>
_paintArea(canvas, (_, __) => fill, clipToFloor: clipToFloor);

/// What a cone without height floors lights: what it sees, out to its
/// reach. Made once per painter, so painting it needs no clip. On Windows
/// the engine draws without a stencil buffer, so a clip to anything but a
/// rectangle, a rounded rectangle or a small convex polygon (unturned) is
/// a mask drawn on the CPU.
static final _lit = Expando<Path>();

/// Marks a painter whose lit area could not be made (path operations
/// can fail on awkward outlines); it is clipped instead, as it used to be.
static final _noLit = Path();

static Path _litArea(Path visibility, Offset apex, double radius) {
try {
return Path.combine(
PathOperation.intersect,
visibility,
Path()..addOval(Rect.fromCircle(center: apex, radius: radius)),
);
} on StateError {
return _noLit;
}
}

void _paintArea(Canvas canvas, Paint Function(Offset, double) fill) {
void _paintArea(Canvas canvas, Paint Function(Offset, double) fill,
{required bool clipToFloor}) {
if ((_visibility == null && visibilityPolygon.length < 3) ||
(_receiver == null && receiverRings.isEmpty)) return;
final visibility =
_visibility ?? _path([visibilityPolygon], evenOdd: false);
final receiver =
_receiver ?? _path(receiverRings, evenOdd: receiverEvenOdd);
canvas.save();
// Receiver fill is the last geometric clip. No cone pixels can appear in
// the SVG's blank exterior or in authored holes in the playable fill.
canvas.translate(receiverOffset.dx, receiverOffset.dy);
canvas.clipPath(receiver);
canvas.translate(-receiverOffset.dx, -receiverOffset.dy);
if (clipToFloor) {
// Receiver fill is the last geometric clip. No cone pixels can appear
// in the SVG's blank exterior or in authored holes in the playable
// fill.
canvas.translate(receiverOffset.dx, receiverOffset.dy);
canvas.clipPath(receiver);
canvas.translate(-receiverOffset.dx, -receiverOffset.dy);
}
final floors = this.floors;
if (floors == null) {
canvas.clipPath(visibility);
canvas.drawCircle(apex, radius, fill(apex, radius));
final lit = _lit[this] ??= _litArea(visibility, apex, radius);
if (identical(lit, _noLit)) {
canvas.clipPath(visibility);
canvas.drawCircle(apex, radius, fill(apex, radius));
} else {
canvas.drawPath(lit, fill(apex, radius));
}
} else {
final (cone, sourceToCanvas, range) = floors;
final origin = cone.sector!.first;
Expand Down
Loading
Loading