import 'dart:math' as math; import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:flame/particles.dart'; import 'package:flame_spine/flame_spine.dart' as spine; import 'package:flutter/material.dart'; import '../models/runtime_node.dart'; import '../protocol/runtime_protocol.dart'; import '../resources/game_resource_manager.dart'; @visibleForTesting Color composeRuntimeColorAlpha(Color color, double alpha) { return color.withValues(alpha: color.a * alpha.clamp(0.0, 1.0)); } @visibleForTesting Rect runtimeImageSourceRect({ required double imageWidth, required double imageHeight, double? sourceX, double? sourceY, double? sourceWidth, double? sourceHeight, }) { final x = (sourceX ?? 0).clamp(0.0, imageWidth).toDouble(); final y = (sourceY ?? 0).clamp(0.0, imageHeight).toDouble(); final maxWidth = imageWidth - x; final maxHeight = imageHeight - y; final width = (sourceWidth ?? maxWidth).clamp(0.0, maxWidth).toDouble(); final height = (sourceHeight ?? maxHeight).clamp(0.0, maxHeight).toDouble(); return Rect.fromLTWH(x, y, width, height); } @visibleForTesting List<({Rect source, Rect destination})> runtimeNineSliceRects({ required Rect source, required Rect destination, double sliceLeft = 0, double sliceTop = 0, double sliceRight = 0, double sliceBottom = 0, double destinationOverlap = 0, double sourceInset = 0, }) { if (source.width <= 0 || source.height <= 0 || destination.width <= 0 || destination.height <= 0) { return const []; } final left = sliceLeft.clamp(0.0, source.width).toDouble(); final top = sliceTop.clamp(0.0, source.height).toDouble(); final right = sliceRight.clamp(0.0, source.width - left).toDouble(); final bottom = sliceBottom.clamp(0.0, source.height - top).toDouble(); final destLeft = left.clamp(0.0, destination.width).toDouble(); final destTop = top.clamp(0.0, destination.height).toDouble(); final destRight = right.clamp(0.0, destination.width - destLeft).toDouble(); final destBottom = bottom.clamp(0.0, destination.height - destTop).toDouble(); final sourceXs = [ source.left, source.left + left, source.right - right, source.right, ]; final sourceYs = [ source.top, source.top + top, source.bottom - bottom, source.bottom, ]; final destXs = [ destination.left, destination.left + destLeft, destination.right - destRight, destination.right, ]; final destYs = [ destination.top, destination.top + destTop, destination.bottom - destBottom, destination.bottom, ]; final parts = <({Rect source, Rect destination})>[]; for (var y = 0; y < 3; y++) { for (var x = 0; x < 3; x++) { final rawSourcePart = Rect.fromLTRB( sourceXs[x], sourceYs[y], sourceXs[x + 1], sourceYs[y + 1], ); final rawDestPart = Rect.fromLTRB( destXs[x], destYs[y], destXs[x + 1], destYs[y + 1], ); if (rawSourcePart.width <= 0 || rawSourcePart.height <= 0 || rawDestPart.width <= 0 || rawDestPart.height <= 0) { continue; } final sourcePart = _insetNineSliceSourceRect( rawSourcePart, bounds: source, inset: sourceInset, ); final destPart = _overlapNineSliceDestinationRect( rawDestPart, x: x, y: y, bounds: destination, overlap: destinationOverlap, ); parts.add((source: sourcePart, destination: destPart)); } } return parts; } Rect _insetNineSliceSourceRect( Rect rect, { required Rect bounds, required double inset, }) { if (inset <= 0 || rect.width <= inset * 2 || rect.height <= inset * 2) { return rect; } return Rect.fromLTRB( math.min(rect.right, math.max(bounds.left, rect.left + inset)), math.min(rect.bottom, math.max(bounds.top, rect.top + inset)), math.max(rect.left, math.min(bounds.right, rect.right - inset)), math.max(rect.top, math.min(bounds.bottom, rect.bottom - inset)), ); } Rect _overlapNineSliceDestinationRect( Rect rect, { required int x, required int y, required Rect bounds, required double overlap, }) { if (overlap <= 0) { return rect; } return Rect.fromLTRB( x == 0 ? rect.left : math.max(bounds.left, rect.left - overlap), y == 0 ? rect.top : math.max(bounds.top, rect.top - overlap), x == 2 ? rect.right : math.min(bounds.right, rect.right + overlap), y == 2 ? rect.bottom : math.min(bounds.bottom, rect.bottom + overlap), ); } class RuntimeComponent extends PositionComponent with HasVisibility, TapCallbacks { RuntimeComponent({ required RuntimeNode node, required GameResourceManager resources, required this.onNodeTap, }) : _node = node, _resources = resources { priority = node.layer; _applyBase(node); _syncImage(node); _syncSpine(node); _syncParticle(node); } RuntimeNode get node => _node; final GameResourceManager _resources; final void Function(RuntimeNode node, Vector2 localPosition) onNodeTap; RuntimeNode _node; TextComponent? _textComponent; ui.Image? _image; String? _loadedAsset; int? _loadedGeneration; int _imageLoadToken = 0; spine.SpineComponent? _spineComponent; ParticleSystemComponent? _particleComponent; String? _particleSignature; String? _loadedSpineAsset; int? _loadedSpineGeneration; int _spineLoadToken = 0; String? _appliedSpineAnimation; bool? _appliedSpineLoop; String? _pendingSpineAnimation; int _pendingSpineTrack = 0; bool _pendingSpineLoop = true; bool _pendingSpineQueue = false; double _pendingSpineDelay = 0; String? _appliedSpineSkin; double? _runtimeAlpha; double _parentScrollX = 0; double _parentScrollY = 0; double _parentContentOffsetX = 0; double _parentContentOffsetY = 0; bool _viewportCulled = false; bool _pressed = false; double _inheritedAlpha = 1; double get localAlpha => _runtimeAlpha ?? _node.alpha; double get renderAlpha => (_inheritedAlpha * localAlpha).clamp(0.0, 1.0); void setRuntimeAlpha(double value) { final next = value.clamp(0, 1).toDouble(); if (_runtimeAlpha == next) { return; } _runtimeAlpha = next; _refreshInheritedAlphaSubtree(); } void setInheritedAlpha(double value) { final next = value.clamp(0, 1).toDouble(); if (_inheritedAlpha == next) { return; } _inheritedAlpha = next; _refreshInheritedAlphaSubtree(); } void _refreshInheritedAlphaSubtree() { _syncTextStyle(_node); for (final child in children.whereType()) { child.setInheritedAlpha(renderAlpha); } } void setParentScroll({ double x = 0, double y = 0, double contentOffsetX = 0, double contentOffsetY = 0, }) { _parentScrollX = x < 0 ? 0 : x; _parentScrollY = y < 0 ? 0 : y; _parentContentOffsetX = contentOffsetX < 0 ? 0 : contentOffsetX; _parentContentOffsetY = contentOffsetY < 0 ? 0 : contentOffsetY; _syncPosition(); } void setViewportCulled(bool value) { _viewportCulled = value; _syncVisibility(); } void updateNode(RuntimeNode node) { _node = node; priority = node.layer; _applyBase(node); _syncImage(node); _syncSpine(node); _syncParticle(node); _refreshInheritedAlphaSubtree(); } bool containsVisualPoint(Vector2 point) { if (!_node.visible) { return false; } final local = absoluteToLocal(point); return local.x >= 0 && local.y >= 0 && local.x <= size.x && local.y <= size.y; } @override bool containsLocalPoint(Vector2 point) { if (!_node.visible || !_node.interactive) { return false; } return point.x >= 0 && point.y >= 0 && point.x <= size.x && point.y <= size.y; } @override void onTapDown(TapDownEvent event) { if (!_node.visible || !_node.interactive) { return; } if (_node.type == RuntimeNodeType.button) { _pressed = true; _syncImage(_node); } onNodeTap(_node, event.localPosition); } @override void onTapUp(TapUpEvent event) { _releasePressedState(); } @override void onTapCancel(TapCancelEvent event) { _releasePressedState(); } void _releasePressedState() { if (!_pressed) { return; } _pressed = false; _syncImage(_node); } @override void renderTree(Canvas canvas) { if (_node.type != RuntimeNodeType.listView) { super.renderTree(canvas); return; } if (!isVisible) { return; } final paint = Paint() ..color = composeRuntimeColorAlpha( _node.color ?? _defaultColor(), renderAlpha, ); canvas.save(); canvas.transform(Float64List.fromList(transform.transformMatrix.storage)); _renderBoxOrImage(canvas, paint); canvas.save(); canvas.clipRRect(_listViewClipRRect()); for (final child in children) { child.renderTree(canvas); } canvas.restore(); _renderListViewScrollbar(canvas); if (debugMode) { renderDebugMode(canvas); } canvas.restore(); } @override void render(Canvas canvas) { final paint = Paint() ..color = composeRuntimeColorAlpha( _node.color ?? _defaultColor(), renderAlpha, ); switch (_node.type) { case RuntimeNodeType.circle: canvas.drawCircle(Offset(size.x / 2, size.y / 2), size.x / 2, paint); break; case RuntimeNodeType.line: _renderLine(canvas, paint); break; case RuntimeNodeType.progress: _renderProgress(canvas, paint); break; case RuntimeNodeType.listView: _renderListView(canvas, paint); break; case RuntimeNodeType.panel: case RuntimeNodeType.button: case RuntimeNodeType.rect: case RuntimeNodeType.sprite: case RuntimeNodeType.image: _renderBoxOrImage(canvas, paint); break; case RuntimeNodeType.spine: _renderSpinePlaceholder(canvas, paint); break; case RuntimeNodeType.particle: break; default: break; } } void _renderLine(Canvas canvas, Paint paint) { final stroke = _node.strokeWidth ?? 2; canvas.drawLine( Offset.zero, Offset(size.x, size.y), paint ..style = PaintingStyle.stroke ..strokeWidth = stroke, ); } void _renderProgress(Canvas canvas, Paint paint) { final rect = Rect.fromLTWH(0, 0, size.x, size.y); final radius = Radius.circular(_node.radius ?? 4); final backgroundPaint = Paint() ..color = composeRuntimeColorAlpha(const Color(0x33475569), renderAlpha); canvas.drawRRect(RRect.fromRectAndRadius(rect, radius), backgroundPaint); final value = _node.value ?? 0; if (value <= 0) { return; } final fillRect = Rect.fromLTWH(0, 0, size.x * value, size.y); canvas.drawRRect(RRect.fromRectAndRadius(fillRect, radius), paint); } void _renderSpinePlaceholder(Canvas canvas, Paint paint) { if (_spineComponent != null) { return; } final rect = Rect.fromLTWH(0, 0, size.x, size.y); canvas.drawRRect( RRect.fromRectAndRadius(rect, const Radius.circular(4)), paint, ); } void _renderListView(Canvas canvas, Paint paint) { _renderBoxOrImage(canvas, paint); } double get _scrollbarThickness => (_node.scrollbarThickness ?? 5).clamp(1.0, 16.0).toDouble(); double get _scrollbarGutter => _scrollbarThickness + 8; ({bool horizontal, bool vertical}) _listViewScrollbarVisibility() { if (!_node.scrollbarVisible) { return (horizontal: false, vertical: false); } final left = _node.paddingLeft.clamp(0.0, size.x).toDouble(); final top = _node.paddingTop.clamp(0.0, size.y).toDouble(); final right = _node.paddingRight.clamp(0.0, size.x).toDouble(); final bottom = _node.paddingBottom.clamp(0.0, size.y).toDouble(); var viewportWidth = size.x - left - right; var viewportHeight = size.y - top - bottom; var vertical = (_node.contentHeight ?? 0) > viewportHeight && viewportHeight > 0; var horizontal = (_node.contentWidth ?? 0) > viewportWidth && viewportWidth > 0; for (var i = 0; i < 2; i += 1) { viewportWidth = size.x - left - right - (vertical ? _scrollbarGutter : 0); viewportHeight = size.y - top - bottom - (horizontal ? _scrollbarGutter : 0); vertical = (_node.contentHeight ?? 0) > viewportHeight && viewportHeight > 0; horizontal = (_node.contentWidth ?? 0) > viewportWidth && viewportWidth > 0; } return (horizontal: horizontal, vertical: vertical); } Rect _listViewContentRect() { final scrollbars = _listViewScrollbarVisibility(); final left = _node.paddingLeft.clamp(0.0, size.x).toDouble(); final top = _node.paddingTop.clamp(0.0, size.y).toDouble(); final rightInset = (_node.paddingRight + (scrollbars.vertical ? _scrollbarGutter : 0)) .clamp(0.0, size.x) .toDouble(); final bottomInset = (_node.paddingBottom + (scrollbars.horizontal ? _scrollbarGutter : 0)) .clamp(0.0, size.y) .toDouble(); final width = (size.x - left - rightInset).clamp(0.0, size.x).toDouble(); final height = (size.y - top - bottomInset).clamp(0.0, size.y).toDouble(); return Rect.fromLTWH(left, top, width, height); } RRect _listViewClipRRect() { final radius = Radius.circular((_node.radius ?? 0).clamp(0.0, 10000.0)); return RRect.fromRectAndRadius(_listViewContentRect(), radius); } Vector2 listViewContentOffset() { if (_node.type != RuntimeNodeType.listView) { return Vector2.zero(); } final rect = _listViewContentRect(); return Vector2(rect.left, rect.top); } Vector2 listViewContentViewport() { if (_node.type != RuntimeNodeType.listView) { return Vector2.zero(); } final rect = _listViewContentRect(); return Vector2(rect.width, rect.height); } void _renderListViewScrollbar(Canvas canvas) { if (!_node.scrollbarVisible) { return; } final thickness = _scrollbarThickness; final contentRect = _listViewContentRect(); final scrollbars = _listViewScrollbarVisibility(); final trackPaint = Paint() ..color = composeRuntimeColorAlpha( _node.scrollbarTrackColor ?? const Color(0x33475569), renderAlpha, ); final thumbPaint = Paint() ..color = composeRuntimeColorAlpha( _node.scrollbarThumbColor ?? const Color(0xaa94a3b8), renderAlpha, ); final contentHeight = _node.contentHeight; if (scrollbars.vertical && contentHeight != null && contentRect.height > 0) { final trackHeight = (contentRect.height - 12).clamp( 4.0, contentRect.height, ); final maxScroll = contentHeight - contentRect.height; final thumbHeight = (contentRect.height * contentRect.height / contentHeight).clamp( 18.0, contentRect.height, ); final thumbY = maxScroll <= 0 ? 0.0 : (_node.scrollY / maxScroll) * (contentRect.height - thumbHeight); canvas.drawRRect( RRect.fromRectAndRadius( Rect.fromLTWH(size.x - thickness - 2, 6, thickness, trackHeight), Radius.circular(thickness / 2), ), trackPaint, ); canvas.drawRRect( RRect.fromRectAndRadius( Rect.fromLTWH( size.x - thickness - 2, thumbY + 6, thickness, (thumbHeight - 12).clamp(4.0, contentRect.height), ), Radius.circular(thickness / 2), ), thumbPaint, ); } final contentWidth = _node.contentWidth; if (scrollbars.horizontal && contentWidth != null && contentRect.width > 0) { final trackWidth = (contentRect.width - 12).clamp(4.0, contentRect.width); final maxScroll = contentWidth - contentRect.width; final thumbWidth = (contentRect.width * contentRect.width / contentWidth) .clamp(18.0, contentRect.width); final thumbX = maxScroll <= 0 ? 0.0 : (_node.scrollX / maxScroll) * (contentRect.width - thumbWidth); canvas.drawRRect( RRect.fromRectAndRadius( Rect.fromLTWH(6, size.y - thickness - 2, trackWidth, thickness), Radius.circular(thickness / 2), ), trackPaint, ); canvas.drawRRect( RRect.fromRectAndRadius( Rect.fromLTWH( thumbX + 6, size.y - thickness - 2, (thumbWidth - 12).clamp(4.0, contentRect.width), thickness, ), Radius.circular(thickness / 2), ), thumbPaint, ); } } void _renderBoxOrImage(Canvas canvas, Paint paint) { final image = _image; final rect = Rect.fromLTWH(0, 0, size.x, size.y); if (image != null && (_node.type == RuntimeNodeType.sprite || _node.type == RuntimeNodeType.image || _node.type == RuntimeNodeType.button)) { final imagePaint = Paint() ..color = composeRuntimeColorAlpha(Colors.white, renderAlpha); final source = _imageSourceRect(image, _currentImageFrame(_node)); if (_usesNineSlice(source, rect)) { _drawNineSliceImage( canvas, image, source, rect, imagePaint..filterQuality = FilterQuality.none, ); } else { canvas.drawImageRect(image, source, rect, imagePaint); } return; } final radius = _node.radius ?? (_node.type == RuntimeNodeType.button ? 12.0 : 4.0); canvas.drawRRect( RRect.fromRectAndRadius(rect, Radius.circular(radius)), paint, ); } Rect _imageSourceRect(ui.Image image, String? frameName) { final frame = _loadedAsset == null ? null : _resources.textureFrame(_loadedAsset!, frameName); if (frame != null) { return frame.rect; } return runtimeImageSourceRect( imageWidth: image.width.toDouble(), imageHeight: image.height.toDouble(), sourceX: _node.sourceX, sourceY: _node.sourceY, sourceWidth: _node.sourceWidth, sourceHeight: _node.sourceHeight, ); } bool _usesNineSlice(Rect source, Rect destination) { if (source.width <= 0 || source.height <= 0 || destination.width <= 0 || destination.height <= 0) { return false; } return (_node.sliceLeft ?? 0) > 0 || (_node.sliceTop ?? 0) > 0 || (_node.sliceRight ?? 0) > 0 || (_node.sliceBottom ?? 0) > 0; } void _drawNineSliceImage( Canvas canvas, ui.Image image, Rect source, Rect destination, Paint paint, ) { final parts = runtimeNineSliceRects( source: source, destination: destination, destinationOverlap: 1, sourceInset: 0.5, sliceLeft: _node.sliceLeft ?? 0, sliceTop: _node.sliceTop ?? 0, sliceRight: _node.sliceRight ?? 0, sliceBottom: _node.sliceBottom ?? 0, ); for (final part in parts) { canvas.drawImageRect(image, part.source, part.destination, paint); } } void _applyBase(RuntimeNode node) { _syncVisibility(); size = Vector2(node.width ?? 40, node.height ?? 40); _syncPosition(); scale = Vector2.all(node.scale); angle = node.rotation; anchor = _anchorFromString(node.anchor); _syncText(node); _syncSpineLayout(); } void _syncVisibility() { isVisible = _node.visible && !_viewportCulled; } void _syncPosition() { position = Vector2( _parentContentOffsetX + _node.x - _parentScrollX, _parentContentOffsetY + _node.y - _parentScrollY, ); } void _syncImage(RuntimeNode node) { if (node.type != RuntimeNodeType.sprite && node.type != RuntimeNodeType.image && node.type != RuntimeNodeType.button) { _imageLoadToken++; _releaseLoadedImage(); return; } final asset = _currentImageAsset(node); if (asset == null || asset.isEmpty) { _imageLoadToken++; _releaseLoadedImage(); return; } final requestedGeneration = _resources.generation; if (asset == _loadedAsset && requestedGeneration == _loadedGeneration) { return; } final requestToken = ++_imageLoadToken; _releaseLoadedImage(); _loadedAsset = asset; _loadedGeneration = requestedGeneration; _resources.loadImage(asset, retain: true).then((image) { if (_imageLoadToken != requestToken) { _releaseRetainedImage(asset, requestedGeneration, image); return; } if (_loadedAsset != asset || _loadedGeneration != requestedGeneration) { _releaseRetainedImage(asset, requestedGeneration, image); return; } if (_resources.generation != requestedGeneration) { _releaseRetainedImage(asset, requestedGeneration, image); return; } _image = image; }); } String? _currentImageAsset(RuntimeNode node) { if (node.type != RuntimeNodeType.button) { return node.asset; } if (!node.interactive && node.disabledAsset != null) { return node.disabledAsset; } if (_pressed && node.pressedAsset != null) { return node.pressedAsset; } return node.asset; } String? _currentImageFrame(RuntimeNode node) { if (node.type != RuntimeNodeType.button) { return node.frame; } if (!node.interactive && node.disabledFrame != null) { return node.disabledFrame; } if (_pressed && node.pressedFrame != null) { return node.pressedFrame; } return node.frame; } void _releaseRetainedImage(String asset, int generation, ui.Image? image) { if (image == null) { return; } _resources.releaseImage(asset, generation: generation); } void _syncParticle(RuntimeNode node) { if (node.type != RuntimeNodeType.particle) { _releaseParticle(); return; } final signature = _particleNodeSignature(node); if (signature == _particleSignature && _particleComponent != null) { _syncParticleLayout(); return; } _releaseParticle(); _particleSignature = signature; final component = ParticleSystemComponent( particle: _buildParticle(node), position: Vector2.zero(), size: size, anchor: Anchor.topLeft, priority: priority + 1, ); _particleComponent = component; add(component); } String _particleNodeSignature(RuntimeNode node) { return [ node.preset, node.count, node.duration, node.color, node.colorTo, node.radius, node.radiusTo, node.speedMin, node.speedMax, node.gravityX, node.gravityY, node.spread, node.autoRemove, node.fadeOut, node.width, node.height, ].join('|'); } Particle _buildParticle(RuntimeNode node) { final preset = node.preset ?? RuntimeParticlePresetValue.burst; final count = (node.count ?? _defaultParticleCount(preset)).clamp(1, 300); final duration = node.duration ?? _defaultParticleDuration(preset); return Particle.generate( count: count, lifespan: node.autoRemove ? duration : 86400, generator: (index) => ComputedParticle( lifespan: node.autoRemove ? duration : 86400, renderer: (canvas, particle) => _renderComputedParticle( canvas, particle, node, preset, index, count, duration, ), ), ); } int _defaultParticleCount(String preset) { return switch (preset) { RuntimeParticlePresetValue.snow => 80, RuntimeParticlePresetValue.confetti => 72, RuntimeParticlePresetValue.trail => 16, _ => 36, }; } double _defaultParticleDuration(String preset) { return switch (preset) { RuntimeParticlePresetValue.snow => 8, RuntimeParticlePresetValue.confetti => 1.2, RuntimeParticlePresetValue.trail => 0.35, _ => 0.55, }; } void _renderComputedParticle( Canvas canvas, Particle particle, RuntimeNode node, String preset, int index, int count, double duration, ) { final config = _particleConfig(node, preset, index, count); final progress = node.autoRemove ? particle.progress : ((particle.progress * 86400 / duration) % 1.0); final x = config.start.x + config.velocity.x * progress * duration + 0.5 * config.gravity.x * progress * progress * duration * duration; final y = config.start.y + config.velocity.y * progress * duration + 0.5 * config.gravity.y * progress * progress * duration * duration; final radius = config.radius + (config.radiusTo - config.radius) * progress; if (radius <= 0) { return; } final color = Color.lerp(config.color, config.colorTo, progress) ?? config.color; final alpha = node.fadeOut ? (1 - progress).clamp(0.0, 1.0) : 1.0; final paint = Paint() ..color = color.withValues(alpha: color.a * renderAlpha * alpha); canvas.drawCircle(Offset(x, y), radius, paint); } _ParticleConfig _particleConfig( RuntimeNode node, String preset, int index, int count, ) { final width = node.width ?? size.x; final height = node.height ?? size.y; final center = Vector2(width / 2, height / 2); final baseColor = node.color ?? _defaultParticleColor(preset); final colorTo = node.colorTo ?? baseColor.withValues(alpha: 0); final radius = node.radius ?? _defaultParticleRadius(preset); final radiusTo = node.radiusTo ?? 0; final speedMin = node.speedMin ?? _defaultParticleSpeedMin(preset); final speedMax = node.speedMax ?? _defaultParticleSpeedMax(preset); final gravity = Vector2( node.gravityX ?? 0, node.gravityY ?? _defaultParticleGravityY(preset), ); if (preset == RuntimeParticlePresetValue.snow) { final x = width * _stableUnit(index, 3); final y = height * _stableUnit(index, 7); final drift = -12 + 24 * _stableUnit(index, 11); final fall = speedMin + (speedMax - speedMin) * _stableUnit(index, 13); return _ParticleConfig( start: Vector2(x, y), velocity: Vector2(drift, fall), gravity: gravity, color: baseColor, colorTo: colorTo, radius: radius * (0.6 + _stableUnit(index, 17)), radiusTo: radiusTo, ); } if (preset == RuntimeParticlePresetValue.trail) { final angle = -math.pi / 2 + (_stableUnit(index, 23) - 0.5) * math.pi; final speed = speedMin + _stableUnit(index, 29) * (speedMax - speedMin); return _ParticleConfig( start: center, velocity: Vector2(math.cos(angle), math.sin(angle)) * speed, gravity: gravity, color: baseColor, colorTo: colorTo, radius: radius, radiusTo: radiusTo, ); } final spread = (node.spread ?? 360) * math.pi / 180; final baseAngle = preset == RuntimeParticlePresetValue.confetti ? -math.pi / 2 : 0.0; final angle = baseAngle - spread / 2 + spread * (count <= 1 ? 0 : index / (count - 1)); final speed = speedMin + _stableUnit(index, 31) * (speedMax - speedMin); return _ParticleConfig( start: center, velocity: Vector2(math.cos(angle), math.sin(angle)) * speed, gravity: gravity, color: baseColor, colorTo: node.colorTo ?? _defaultParticleColorTo(preset, baseColor), radius: radius, radiusTo: radiusTo, ); } double _stableUnit(int index, int salt) { final value = math.sin((index + 1) * (salt + 17) * 12.9898) * 43758.5453; return value - value.floorToDouble(); } Color _defaultParticleColor(String preset) { return switch (preset) { RuntimeParticlePresetValue.snow => const Color(0xccffffff), RuntimeParticlePresetValue.confetti => const Color(0xffff4d6d), RuntimeParticlePresetValue.trail => const Color(0xff38bdf8), _ => const Color(0xffffcc33), }; } Color _defaultParticleColorTo(String preset, Color base) { return switch (preset) { RuntimeParticlePresetValue.confetti => const Color(0xfffacc15), _ => base.withValues(alpha: 0), }; } double _defaultParticleRadius(String preset) { return switch (preset) { RuntimeParticlePresetValue.snow => 1.6, RuntimeParticlePresetValue.confetti => 2.6, RuntimeParticlePresetValue.trail => 2.0, _ => 2.4, }; } double _defaultParticleSpeedMin(String preset) { return switch (preset) { RuntimeParticlePresetValue.snow => 12, RuntimeParticlePresetValue.confetti => 120, RuntimeParticlePresetValue.trail => 20, _ => 80, }; } double _defaultParticleSpeedMax(String preset) { return switch (preset) { RuntimeParticlePresetValue.snow => 28, RuntimeParticlePresetValue.confetti => 260, RuntimeParticlePresetValue.trail => 70, _ => 220, }; } double _defaultParticleGravityY(String preset) { return switch (preset) { RuntimeParticlePresetValue.snow => 4, RuntimeParticlePresetValue.confetti => 240, RuntimeParticlePresetValue.trail => 0, _ => 80, }; } void _syncParticleLayout() { final particle = _particleComponent; if (particle == null) { return; } particle ..position = Vector2.zero() ..size = size ..anchor = Anchor.topLeft ..priority = priority + 1; } void _releaseParticle() { _particleComponent?.removeFromParent(); _particleComponent = null; _particleSignature = null; } void _syncSpine(RuntimeNode node) { if (node.type != RuntimeNodeType.spine) { _spineLoadToken++; _releaseLoadedSpine(); return; } final asset = node.asset; if (asset == null || asset.isEmpty) { _spineLoadToken++; _releaseLoadedSpine(); return; } final requestedGeneration = _resources.generation; if (asset == _loadedSpineAsset && requestedGeneration == _loadedSpineGeneration) { _syncSpineLayout(); _syncSpineSkin(node); _syncSpineAnimation(node); return; } final requestToken = ++_spineLoadToken; _releaseLoadedSpine(); _loadedSpineAsset = asset; _loadedSpineGeneration = requestedGeneration; _resources.createSpineComponent(asset).then((spine) { if (_spineLoadToken != requestToken) { spine?.dispose(); return; } if (_loadedSpineAsset != asset || _loadedSpineGeneration != requestedGeneration) { spine?.dispose(); return; } if (_resources.generation != requestedGeneration) { spine?.dispose(); return; } if (spine == null) { return; } _spineComponent = spine; add(spine); _syncSpineLayout(); _syncSpineSkin(_node); _syncSpineAnimation(_node); }); } bool playSpineAnimation( String animation, { int track = 0, bool loop = true, bool queue = false, double delay = 0, }) { final spine = _spineComponent; if (spine == null) { if (_node.type != RuntimeNodeType.spine) { return false; } _pendingSpineAnimation = animation; _pendingSpineTrack = track; _pendingSpineLoop = loop; _pendingSpineQueue = queue; _pendingSpineDelay = delay; return true; } if (queue) { spine.animationState.addAnimation(track, animation, loop, delay); } else { spine.animationState.setAnimation(track, animation, loop); } _appliedSpineAnimation = animation; _appliedSpineLoop = loop; _pendingSpineAnimation = null; return true; } void _syncSpineLayout() { final spine = _spineComponent; if (spine == null) { return; } spine ..position = Vector2.zero() ..anchor = Anchor.topLeft ..priority = priority; final width = _node.width; final height = _node.height; if (width != null && height != null && spine.size.x > 0 && spine.size.y > 0) { spine.scale = Vector2(width / spine.size.x, height / spine.size.y); } } void _syncSpineSkin(RuntimeNode node) { final skin = node.skin; if (skin == null || skin == _appliedSpineSkin) { return; } _spineComponent?.skeleton.setSkin(skin); _appliedSpineSkin = skin; } void _syncSpineAnimation(RuntimeNode node) { final pendingAnimation = _pendingSpineAnimation; if (pendingAnimation != null) { playSpineAnimation( pendingAnimation, track: _pendingSpineTrack, loop: _pendingSpineLoop, queue: _pendingSpineQueue, delay: _pendingSpineDelay, ); return; } final animation = node.animation; if (animation == null) { return; } if (animation == _appliedSpineAnimation && node.loop == _appliedSpineLoop) { return; } playSpineAnimation(animation, loop: node.loop); } void _releaseLoadedSpine() { final spine = _spineComponent; if (spine != null) { spine.removeFromParent(); spine.dispose(); } _spineComponent = null; _loadedSpineAsset = null; _loadedSpineGeneration = null; _appliedSpineAnimation = null; _appliedSpineLoop = null; _pendingSpineAnimation = null; _pendingSpineTrack = 0; _pendingSpineLoop = true; _pendingSpineQueue = false; _pendingSpineDelay = 0; _appliedSpineSkin = null; } void _releaseLoadedImage() { final asset = _loadedAsset; final generation = _loadedGeneration; if (_image != null && asset != null && generation != null) { _resources.releaseImage(asset, generation: generation); } _image = null; _loadedAsset = null; _loadedGeneration = null; } void _syncText(RuntimeNode node) { final label = node.text; if (label == null && node.type != RuntimeNodeType.text) { _textComponent?.removeFromParent(); _textComponent = null; return; } final text = label ?? ''; final component = _textComponent; if (component == null) { _textComponent = TextComponent( text: text, textRenderer: TextPaint(style: _textStyle(node)), anchor: _textAnchor(node), position: _textPosition(node), priority: priority + 1, ); add(_textComponent!); return; } component ..text = text ..anchor = _textAnchor(node) ..position = _textPosition(node) ..priority = priority + 1; _syncTextStyle(node); } void _syncTextStyle(RuntimeNode node) { final component = _textComponent; if (component == null) { return; } component.textRenderer = TextPaint(style: _textStyle(node)); } TextStyle _textStyle(RuntimeNode node) { final color = _textColor(node); return TextStyle( color: composeRuntimeColorAlpha(color, renderAlpha), fontSize: node.fontSize ?? 18, fontWeight: node.type == RuntimeNodeType.button ? FontWeight.w600 : FontWeight.normal, shadows: _textShadows(node), ); } @override void onRemove() { _imageLoadToken++; _spineLoadToken++; _releaseLoadedImage(); _releaseLoadedSpine(); _releaseParticle(); _runtimeAlpha = null; _inheritedAlpha = 1; _pressed = false; _textComponent = null; super.onRemove(); } Anchor _textAnchor(RuntimeNode node) { final topAligned = _usesTopAlignedText(node); return switch (node.textAlign) { RuntimeTextAlignValue.left => topAligned ? Anchor.topLeft : Anchor.centerLeft, RuntimeTextAlignValue.right => topAligned ? Anchor.topRight : Anchor.centerRight, _ => topAligned ? Anchor.topCenter : Anchor.center, }; } Vector2 _textPosition(RuntimeNode node) { final topAligned = _usesTopAlignedText(node); return switch (node.textAlign) { RuntimeTextAlignValue.left => topAligned ? Vector2.zero() : Vector2(0, size.y / 2), RuntimeTextAlignValue.right => topAligned ? Vector2(size.x, 0) : Vector2(size.x, size.y / 2), _ => topAligned ? Vector2(size.x / 2, 0) : size / 2, }; } bool _usesTopAlignedText(RuntimeNode node) { if (node.type == RuntimeNodeType.button) { return false; } return (node.text ?? '').contains('\n'); } List? _textShadows(RuntimeNode node) { final color = node.textShadowColor; if (color == null) { return null; } return [ Shadow( color: composeRuntimeColorAlpha(color, renderAlpha), offset: Offset( node.textShadowOffsetX ?? 0, node.textShadowOffsetY ?? 0, ), blurRadius: node.textShadowBlur ?? 0, ), ]; } Color _textColor(RuntimeNode node) { if (node.type == RuntimeNodeType.button) { return Colors.white; } return node.color ?? Colors.white; } Color _defaultColor() { return switch (_node.type) { RuntimeNodeType.button => const Color(0xff3662d8), RuntimeNodeType.panel => const Color(0xaa111827), RuntimeNodeType.text => Colors.transparent, RuntimeNodeType.circle => const Color(0xffef4444), RuntimeNodeType.rect => const Color(0xff334155), RuntimeNodeType.listView => const Color(0xff111827), RuntimeNodeType.line => const Color(0xffffffff), RuntimeNodeType.progress => const Color(0xff22c55e), RuntimeNodeType.sprite || RuntimeNodeType.image || RuntimeNodeType.spine => const Color(0xff64748b), _ => const Color(0xff94a3b8), }; } Anchor _anchorFromString(String value) { return switch (value) { RuntimeAnchorValue.center => Anchor.center, RuntimeAnchorValue.topLeft => Anchor.topLeft, RuntimeAnchorValue.topRight => Anchor.topRight, RuntimeAnchorValue.bottomLeft => Anchor.bottomLeft, RuntimeAnchorValue.bottomRight => Anchor.bottomRight, _ => Anchor.topLeft, }; } } class _ParticleConfig { const _ParticleConfig({ required this.start, required this.velocity, required this.gravity, required this.color, required this.colorTo, required this.radius, required this.radiusTo, }); final Vector2 start; final Vector2 velocity; final Vector2 gravity; final Color color; final Color colorTo; final double radius; final double radiusTo; }