491 lines
14 KiB
Dart
491 lines
14 KiB
Dart
import 'package:flame/components.dart';
|
|
|
|
import '../models/game_diff.dart';
|
|
import '../models/runtime_event.dart';
|
|
import '../models/runtime_node.dart';
|
|
import '../protocol/runtime_protocol.dart';
|
|
import '../resources/game_resource_manager.dart';
|
|
import 'runtime_component.dart';
|
|
|
|
class RenderTreeController {
|
|
RenderTreeController({
|
|
required Component root,
|
|
required GameResourceManager resources,
|
|
required void Function(RuntimeEvent event) eventSink,
|
|
this.onScopeRemoved,
|
|
}) : _root = root,
|
|
_resources = resources,
|
|
_eventSink = eventSink;
|
|
|
|
final Component _root;
|
|
final GameResourceManager _resources;
|
|
final void Function(RuntimeEvent event) _eventSink;
|
|
final Map<String, RuntimeComponent> _components = {};
|
|
final Map<String, int> _epochs = {};
|
|
final Map<String, Vector2> _scrollVelocities = {};
|
|
void Function(String id)? onScopeRemoved;
|
|
|
|
RuntimeComponent? componentById(String id) => _components[id];
|
|
|
|
bool contains(String id) => _components.containsKey(id);
|
|
|
|
String? listViewAt(Vector2 canvasPosition) {
|
|
final hits =
|
|
_components.values
|
|
.where(
|
|
(component) =>
|
|
component.node.type == RuntimeNodeType.listView &&
|
|
component.containsVisualPoint(canvasPosition),
|
|
)
|
|
.toList(growable: false)
|
|
..sort((a, b) => b.priority.compareTo(a.priority));
|
|
return hits.isEmpty ? null : hits.first.node.id;
|
|
}
|
|
|
|
bool scrollListViewAt(
|
|
Vector2 canvasPosition, {
|
|
double deltaX = 0,
|
|
double deltaY = 0,
|
|
String source = 'wheel',
|
|
}) {
|
|
final id = listViewAt(canvasPosition);
|
|
if (id == null) {
|
|
return false;
|
|
}
|
|
return scrollListView(id, deltaX: deltaX, deltaY: deltaY, source: source);
|
|
}
|
|
|
|
bool scrollListView(
|
|
String id, {
|
|
double deltaX = 0,
|
|
double deltaY = 0,
|
|
String source = 'program',
|
|
}) {
|
|
final component = _components[id];
|
|
if (component == null || component.node.type != RuntimeNodeType.listView) {
|
|
return false;
|
|
}
|
|
final node = component.node;
|
|
final viewport = _listViewContentViewport(node);
|
|
final nextScrollX = _clampScroll(
|
|
node.scrollX + deltaX,
|
|
viewportExtent: viewport.x,
|
|
contentExtent: node.contentWidth,
|
|
);
|
|
final nextScrollY = _clampScroll(
|
|
node.scrollY + deltaY,
|
|
viewportExtent: viewport.y,
|
|
contentExtent: node.contentHeight,
|
|
);
|
|
if (nextScrollX == node.scrollX && nextScrollY == node.scrollY) {
|
|
return false;
|
|
}
|
|
component.updateNode(
|
|
node.copyWithProps({'scrollX': nextScrollX, 'scrollY': nextScrollY}),
|
|
);
|
|
_reattachChildrenOf(id);
|
|
_emitScrollEvent(component, source: source);
|
|
return true;
|
|
}
|
|
|
|
void setListViewVelocity(String id, Vector2 velocity) {
|
|
final component = _components[id];
|
|
if (component == null || component.node.type != RuntimeNodeType.listView) {
|
|
return;
|
|
}
|
|
if (!component.node.inertia) {
|
|
_scrollVelocities.remove(id);
|
|
return;
|
|
}
|
|
if (velocity.length2 < 1) {
|
|
_scrollVelocities.remove(id);
|
|
return;
|
|
}
|
|
_scrollVelocities[id] = velocity;
|
|
}
|
|
|
|
void stopListViewVelocity(String id) {
|
|
_scrollVelocities.remove(id);
|
|
}
|
|
|
|
void updateListViewInertia(double dt) {
|
|
if (_scrollVelocities.isEmpty || dt <= 0) {
|
|
return;
|
|
}
|
|
for (final entry in _scrollVelocities.entries.toList(growable: false)) {
|
|
final id = entry.key;
|
|
final velocity = entry.value;
|
|
final consumed = scrollListView(
|
|
id,
|
|
deltaX: velocity.x * dt,
|
|
deltaY: velocity.y * dt,
|
|
source: 'inertia',
|
|
);
|
|
final next = velocity * 0.88;
|
|
if (!consumed || next.length2 < 25) {
|
|
_scrollVelocities.remove(id);
|
|
} else {
|
|
_scrollVelocities[id] = next;
|
|
}
|
|
}
|
|
}
|
|
|
|
int epochOf(String id) => _epochs[id] ?? 0;
|
|
|
|
bool isNodeEpochAlive(String id, int epoch) {
|
|
return _components.containsKey(id) && epochOf(id) == epoch;
|
|
}
|
|
|
|
void clear() {
|
|
final ids = _components.keys.toList(growable: false);
|
|
for (final component in _components.values) {
|
|
component.removeFromParent();
|
|
}
|
|
_components.clear();
|
|
for (final id in ids) {
|
|
_bumpEpoch(id);
|
|
onScopeRemoved?.call(id);
|
|
}
|
|
}
|
|
|
|
void removeById(String id) {
|
|
final removedIds = <String>[];
|
|
for (final childId in _descendantIdsOf(id)) {
|
|
final child = _components.remove(childId);
|
|
if (child != null) {
|
|
removedIds.add(childId);
|
|
_bumpEpoch(childId);
|
|
child.removeFromParent();
|
|
}
|
|
}
|
|
|
|
final component = _components.remove(id);
|
|
if (component != null) {
|
|
removedIds.add(id);
|
|
_bumpEpoch(id);
|
|
component.removeFromParent();
|
|
}
|
|
|
|
for (final removedId in removedIds) {
|
|
onScopeRemoved?.call(removedId);
|
|
}
|
|
}
|
|
|
|
void apply(NodeDiff diff) {
|
|
_validateDiff(diff);
|
|
|
|
for (final remove in diff.removes) {
|
|
removeById(remove.id);
|
|
}
|
|
|
|
for (final create in diff.creates) {
|
|
_createOrReplace(create);
|
|
}
|
|
|
|
for (final update in diff.updates) {
|
|
final component = _components[update.id];
|
|
if (component == null) {
|
|
continue;
|
|
}
|
|
final nextNode = component.node.copyWithProps(update.props);
|
|
component.updateNode(nextNode);
|
|
_attachToParent(component);
|
|
_reattachChildrenOf(component.node.id);
|
|
}
|
|
}
|
|
|
|
void _validateDiff(NodeDiff diff) {
|
|
final nodes = <String, RuntimeNode>{
|
|
for (final entry in _components.entries) entry.key: entry.value.node,
|
|
};
|
|
|
|
for (final remove in diff.removes) {
|
|
_removeNodeSnapshot(nodes, remove.id);
|
|
}
|
|
|
|
for (final create in diff.creates) {
|
|
nodes[create.id] = create;
|
|
}
|
|
|
|
for (final update in diff.updates) {
|
|
final current = nodes[update.id];
|
|
if (current == null) {
|
|
continue;
|
|
}
|
|
nodes[update.id] = current.copyWithProps(update.props);
|
|
}
|
|
|
|
_validateParentGraph(nodes);
|
|
}
|
|
|
|
void _removeNodeSnapshot(Map<String, RuntimeNode> nodes, String id) {
|
|
final descendants = _descendantIdsOfSnapshot(nodes, id);
|
|
for (final childId in descendants) {
|
|
nodes.remove(childId);
|
|
}
|
|
nodes.remove(id);
|
|
}
|
|
|
|
List<String> _descendantIdsOfSnapshot(
|
|
Map<String, RuntimeNode> nodes,
|
|
String parentId,
|
|
) {
|
|
final descendants = <String>[];
|
|
for (final node in nodes.values) {
|
|
if (node.parent == parentId) {
|
|
descendants.add(node.id);
|
|
descendants.addAll(_descendantIdsOfSnapshot(nodes, node.id));
|
|
}
|
|
}
|
|
return descendants;
|
|
}
|
|
|
|
void _validateParentGraph(Map<String, RuntimeNode> nodes) {
|
|
for (final node in nodes.values) {
|
|
final parentId = node.parent;
|
|
if (parentId == null) {
|
|
continue;
|
|
}
|
|
if (parentId == node.id) {
|
|
throw const FormatException(
|
|
'RuntimeNode.parent cannot reference itself',
|
|
);
|
|
}
|
|
|
|
final seen = <String>{node.id};
|
|
var currentId = parentId;
|
|
while (true) {
|
|
if (!seen.add(currentId)) {
|
|
throw FormatException(
|
|
'RuntimeNode.parent would create a cycle: ${node.id} -> $parentId',
|
|
);
|
|
}
|
|
final parent = nodes[currentId];
|
|
final nextId = parent?.parent;
|
|
if (nextId == null) {
|
|
break;
|
|
}
|
|
currentId = nextId;
|
|
}
|
|
}
|
|
}
|
|
|
|
void _createOrReplace(RuntimeNode node) {
|
|
_validateParent(node);
|
|
|
|
final existing = _components.remove(node.id);
|
|
existing?.removeFromParent();
|
|
final epoch = _bumpEpoch(node.id);
|
|
|
|
late final RuntimeComponent component;
|
|
component = RuntimeComponent(
|
|
node: node,
|
|
resources: _resources,
|
|
onNodeTap: (tappedNode, localPosition) {
|
|
if (_components[tappedNode.id] != component) {
|
|
return;
|
|
}
|
|
_eventSink(
|
|
RuntimeEvent(
|
|
type: RuntimeEventType.tap,
|
|
target: tappedNode.id,
|
|
handler: tappedNode.onTap,
|
|
x: localPosition.x,
|
|
y: localPosition.y,
|
|
scope: tappedNode.id,
|
|
targetEpoch: epoch,
|
|
scopeEpoch: epoch,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
_components[node.id] = component;
|
|
_attachToParent(component);
|
|
_reattachChildrenOf(node.id);
|
|
}
|
|
|
|
int _bumpEpoch(String id) {
|
|
final next = (_epochs[id] ?? 0) + 1;
|
|
_epochs[id] = next;
|
|
return next;
|
|
}
|
|
|
|
void _attachToParent(RuntimeComponent component) {
|
|
final parentId = component.node.parent;
|
|
final parent = parentId == null ? null : _components[parentId];
|
|
final target = parent ?? _root;
|
|
final parentIsListView = parent?.node.type == RuntimeNodeType.listView;
|
|
final parentScrollX = parentIsListView ? parent!.node.scrollX : 0.0;
|
|
final parentScrollY = parentIsListView ? parent!.node.scrollY : 0.0;
|
|
final parentContentOffset = parentIsListView
|
|
? parent!.listViewContentOffset()
|
|
: Vector2.zero();
|
|
component.setParentScroll(
|
|
x: parentScrollX,
|
|
y: parentScrollY,
|
|
contentOffsetX: parentContentOffset.x,
|
|
contentOffsetY: parentContentOffset.y,
|
|
);
|
|
component.setViewportCulled(_isCulledByParentListView(component, parent));
|
|
component.setInheritedAlpha(parent?.renderAlpha ?? 1);
|
|
if (component.parent == target) {
|
|
return;
|
|
}
|
|
|
|
component.removeFromParent();
|
|
target.add(component);
|
|
}
|
|
|
|
void _validateParent(RuntimeNode node) {
|
|
final parentId = node.parent;
|
|
if (parentId != null && _wouldCreateCycle(node.id, parentId)) {
|
|
throw FormatException(
|
|
'RuntimeNode.parent would create a cycle: ${node.id} -> $parentId',
|
|
);
|
|
}
|
|
}
|
|
|
|
void _reattachChildrenOf(String parentId) {
|
|
for (final component in _components.values.toList(growable: false)) {
|
|
if (component.node.parent == parentId) {
|
|
_attachToParent(component);
|
|
}
|
|
}
|
|
}
|
|
|
|
List<String> _descendantIdsOf(String parentId) {
|
|
final descendants = <String>[];
|
|
for (final component in _components.values) {
|
|
if (component.node.parent == parentId) {
|
|
descendants.add(component.node.id);
|
|
descendants.addAll(_descendantIdsOf(component.node.id));
|
|
}
|
|
}
|
|
return descendants;
|
|
}
|
|
|
|
bool _isCulledByParentListView(
|
|
RuntimeComponent component,
|
|
RuntimeComponent? parent,
|
|
) {
|
|
final parentNode = parent?.node;
|
|
if (parentNode == null ||
|
|
parentNode.type != RuntimeNodeType.listView ||
|
|
!parentNode.virtualized) {
|
|
return false;
|
|
}
|
|
final cache = parentNode.cacheExtent;
|
|
final viewportLeft = parentNode.scrollX - cache;
|
|
final viewportTop = parentNode.scrollY - cache;
|
|
final viewport = parent?.listViewContentViewport() ?? Vector2.zero();
|
|
final viewportRight = parentNode.scrollX + viewport.x + cache;
|
|
final viewportBottom = parentNode.scrollY + viewport.y + cache;
|
|
final node = component.node;
|
|
final childLeft = node.x;
|
|
final childTop = node.y;
|
|
final childRight = node.x + (node.width ?? 0);
|
|
final childBottom = node.y + (node.height ?? 0);
|
|
return childRight < viewportLeft ||
|
|
childLeft > viewportRight ||
|
|
childBottom < viewportTop ||
|
|
childTop > viewportBottom;
|
|
}
|
|
|
|
Vector2 _listViewContentViewport(RuntimeNode node) {
|
|
final width = node.width ?? 0;
|
|
final height = node.height ?? 0;
|
|
final left = node.paddingLeft.clamp(0.0, width).toDouble();
|
|
final top = node.paddingTop.clamp(0.0, height).toDouble();
|
|
if (!node.scrollbarVisible) {
|
|
return Vector2(
|
|
(width - left - node.paddingRight).clamp(0.0, width).toDouble(),
|
|
(height - top - node.paddingBottom).clamp(0.0, height).toDouble(),
|
|
);
|
|
}
|
|
final thickness = (node.scrollbarThickness ?? 5).clamp(1.0, 16.0);
|
|
final gutter = thickness + 8;
|
|
var vertical = (node.contentHeight ?? 0) > height && height > 0;
|
|
var horizontal = (node.contentWidth ?? 0) > width && width > 0;
|
|
for (var i = 0; i < 2; i += 1) {
|
|
final viewportWidth =
|
|
width - left - node.paddingRight - (vertical ? gutter : 0);
|
|
final viewportHeight =
|
|
height - top - node.paddingBottom - (horizontal ? gutter : 0);
|
|
vertical =
|
|
(node.contentHeight ?? 0) > viewportHeight && viewportHeight > 0;
|
|
horizontal =
|
|
(node.contentWidth ?? 0) > viewportWidth && viewportWidth > 0;
|
|
}
|
|
return Vector2(
|
|
(width - left - node.paddingRight - (vertical ? gutter : 0))
|
|
.clamp(0.0, width)
|
|
.toDouble(),
|
|
(height - top - node.paddingBottom - (horizontal ? gutter : 0))
|
|
.clamp(0.0, height)
|
|
.toDouble(),
|
|
);
|
|
}
|
|
|
|
double _clampScroll(
|
|
double value, {
|
|
required double? viewportExtent,
|
|
required double? contentExtent,
|
|
}) {
|
|
final maxScroll = (contentExtent ?? 0) - (viewportExtent ?? 0);
|
|
if (maxScroll <= 0) {
|
|
return 0;
|
|
}
|
|
return value.clamp(0, maxScroll).toDouble();
|
|
}
|
|
|
|
void _emitScrollEvent(RuntimeComponent component, {required String source}) {
|
|
final node = component.node;
|
|
final handler = node.onScroll;
|
|
if (handler == null || handler.isEmpty) {
|
|
return;
|
|
}
|
|
final id = node.id;
|
|
_eventSink(
|
|
RuntimeEvent(
|
|
type: RuntimeEventType.scroll,
|
|
target: id,
|
|
handler: handler,
|
|
scope: id,
|
|
targetEpoch: epochOf(id),
|
|
scopeEpoch: epochOf(id),
|
|
data: {
|
|
'scrollX': node.scrollX,
|
|
'scrollY': node.scrollY,
|
|
'maxScrollX': _clampScroll(
|
|
double.infinity,
|
|
viewportExtent: node.width,
|
|
contentExtent: node.contentWidth,
|
|
),
|
|
'maxScrollY': _clampScroll(
|
|
double.infinity,
|
|
viewportExtent: node.height,
|
|
contentExtent: node.contentHeight,
|
|
),
|
|
'source': source,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
bool _wouldCreateCycle(String nodeId, String parentId) {
|
|
var currentId = parentId;
|
|
while (true) {
|
|
if (currentId == nodeId) {
|
|
return true;
|
|
}
|
|
|
|
final parent = _components[currentId];
|
|
final nextId = parent?.node.parent;
|
|
if (nextId == null) {
|
|
return false;
|
|
}
|
|
currentId = nextId;
|
|
}
|
|
}
|
|
}
|