Files
flutter_lua_runtime/lib/runtime/rendering/runtime_component.dart
2026-06-09 12:06:58 +08:00

1124 lines
31 KiB
Dart

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));
}
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 get renderAlpha => _runtimeAlpha ?? _node.alpha;
void setRuntimeAlpha(double value) {
final next = value.clamp(0, 1).toDouble();
if (_runtimeAlpha == next) {
return;
}
_runtimeAlpha = next;
_syncTextStyle(_node);
}
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);
}
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)) {
canvas.drawImageRect(
image,
Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()),
rect,
Paint()..color = composeRuntimeColorAlpha(Colors.white, renderAlpha),
);
return;
}
final radius =
_node.radius ?? (_node.type == RuntimeNodeType.button ? 12.0 : 4.0);
canvas.drawRRect(
RRect.fromRectAndRadius(rect, Radius.circular(radius)),
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;
}
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;
_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<Shadow>? _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;
}