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 _components = {}; final Map _epochs = {}; final Map _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 = []; 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 = { 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 nodes, String id) { final descendants = _descendantIdsOfSnapshot(nodes, id); for (final childId in descendants) { nodes.remove(childId); } nodes.remove(id); } List _descendantIdsOfSnapshot( Map nodes, String parentId, ) { final descendants = []; 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 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 = {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)); 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 _descendantIdsOf(String parentId) { final descendants = []; 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; } } }