411 lines
12 KiB
Dart
411 lines
12 KiB
Dart
import 'package:flame/components.dart';
|
|
import 'package:flame_lua_runtime/runtime/models/runtime_node.dart';
|
|
import 'package:flame_lua_runtime/runtime/protocol/runtime_protocol.dart';
|
|
import 'package:flame_lua_runtime/runtime/rendering/runtime_component.dart';
|
|
import 'package:flame_lua_runtime/runtime/resources/game_resource_manager.dart';
|
|
import 'package:flutter/material.dart' show Color;
|
|
import 'package:flutter/rendering.dart' show Rect;
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
void main() {
|
|
group('RuntimeComponent', () {
|
|
test('applies base transform and priority from node', () {
|
|
final component = RuntimeComponent(
|
|
node: const RuntimeNode(
|
|
id: 'rect',
|
|
type: RuntimeNodeType.rect,
|
|
x: 10,
|
|
y: 20,
|
|
width: 120,
|
|
height: 48,
|
|
scale: 1.5,
|
|
rotation: 0.25,
|
|
anchor: RuntimeAnchorValue.center,
|
|
layer: 7,
|
|
),
|
|
resources: GameResourceManager(),
|
|
onNodeTap: (_, __) {},
|
|
);
|
|
|
|
expect(component.position, Vector2(10, 20));
|
|
expect(component.size, Vector2(120, 48));
|
|
expect(component.scale, Vector2.all(1.5));
|
|
expect(component.angle, 0.25);
|
|
expect(component.anchor, Anchor.center);
|
|
expect(component.priority, 7);
|
|
expect(component.isVisible, isTrue);
|
|
});
|
|
|
|
test('updates node and transform', () {
|
|
final component = RuntimeComponent(
|
|
node: const RuntimeNode(id: 'node', type: RuntimeNodeType.rect),
|
|
resources: GameResourceManager(),
|
|
onNodeTap: (_, __) {},
|
|
);
|
|
|
|
component.updateNode(
|
|
const RuntimeNode(
|
|
id: 'node',
|
|
type: RuntimeNodeType.progress,
|
|
x: 30,
|
|
y: 40,
|
|
width: 200,
|
|
height: 16,
|
|
value: 0.5,
|
|
layer: 3,
|
|
),
|
|
);
|
|
|
|
expect(component.node.type, RuntimeNodeType.progress);
|
|
expect(component.node.value, 0.5);
|
|
expect(component.position, Vector2(30, 40));
|
|
expect(component.size, Vector2(200, 16));
|
|
expect(component.priority, 3);
|
|
expect(component.isVisible, isTrue);
|
|
});
|
|
|
|
test('visibility hides component subtree and disables hit testing', () {
|
|
final component = RuntimeComponent(
|
|
node: const RuntimeNode(
|
|
id: 'button',
|
|
type: RuntimeNodeType.button,
|
|
text: 'Hidden',
|
|
width: 100,
|
|
height: 40,
|
|
visible: false,
|
|
interactive: true,
|
|
),
|
|
resources: GameResourceManager(),
|
|
onNodeTap: (_, __) {},
|
|
);
|
|
|
|
expect(component.isVisible, isFalse);
|
|
expect(component.containsLocalPoint(Vector2(10, 10)), isFalse);
|
|
|
|
component.updateNode(
|
|
const RuntimeNode(
|
|
id: 'button',
|
|
type: RuntimeNodeType.button,
|
|
text: 'Shown',
|
|
width: 100,
|
|
height: 40,
|
|
visible: true,
|
|
interactive: true,
|
|
),
|
|
);
|
|
|
|
expect(component.isVisible, isTrue);
|
|
expect(component.containsLocalPoint(Vector2(10, 10)), isTrue);
|
|
});
|
|
|
|
test('supports runtime alpha override for fade commands', () {
|
|
final component = RuntimeComponent(
|
|
node: const RuntimeNode(
|
|
id: 'panel',
|
|
type: RuntimeNodeType.rect,
|
|
alpha: 0.8,
|
|
),
|
|
resources: GameResourceManager(),
|
|
onNodeTap: (_, __) {},
|
|
);
|
|
|
|
expect(component.renderAlpha, 0.8);
|
|
component.setRuntimeAlpha(0.25);
|
|
expect(component.renderAlpha, 0.25);
|
|
component.setRuntimeAlpha(2);
|
|
expect(component.renderAlpha, 1);
|
|
});
|
|
|
|
test('inherits parent alpha for prefab-like subtrees', () async {
|
|
final parent = RuntimeComponent(
|
|
node: const RuntimeNode(
|
|
id: 'parent',
|
|
type: RuntimeNodeType.panel,
|
|
alpha: 0.8,
|
|
),
|
|
resources: GameResourceManager(),
|
|
onNodeTap: (_, __) {},
|
|
);
|
|
final child = RuntimeComponent(
|
|
node: const RuntimeNode(
|
|
id: 'child',
|
|
type: RuntimeNodeType.text,
|
|
text: 'Child',
|
|
alpha: 0.5,
|
|
color: Color(0xffffffff),
|
|
),
|
|
resources: GameResourceManager(),
|
|
onNodeTap: (_, __) {},
|
|
);
|
|
parent.add(child);
|
|
parent.updateTree(0);
|
|
child.setInheritedAlpha(parent.renderAlpha);
|
|
|
|
expect(child.renderAlpha, closeTo(0.4, 0.001));
|
|
final text = child.children.whereType<TextComponent>().single;
|
|
expect(
|
|
((text.textRenderer as TextPaint).style.color!).a,
|
|
closeTo(0.4, 0.003),
|
|
);
|
|
|
|
parent.setRuntimeAlpha(0.25);
|
|
|
|
expect(child.renderAlpha, closeTo(0.125, 0.001));
|
|
final updatedText = child.children.whereType<TextComponent>().single;
|
|
expect(identical(updatedText, text), isTrue);
|
|
expect(
|
|
((updatedText.textRenderer as TextPaint).style.color!).a,
|
|
closeTo(0.125, 0.003),
|
|
);
|
|
});
|
|
|
|
test('propagates parent node alpha updates to child subtree', () {
|
|
final parent = RuntimeComponent(
|
|
node: const RuntimeNode(
|
|
id: 'parent',
|
|
type: RuntimeNodeType.panel,
|
|
alpha: 0.8,
|
|
),
|
|
resources: GameResourceManager(),
|
|
onNodeTap: (_, __) {},
|
|
);
|
|
final child = RuntimeComponent(
|
|
node: const RuntimeNode(
|
|
id: 'child',
|
|
type: RuntimeNodeType.text,
|
|
text: 'Child',
|
|
alpha: 0.5,
|
|
color: Color(0xffffffff),
|
|
),
|
|
resources: GameResourceManager(),
|
|
onNodeTap: (_, __) {},
|
|
);
|
|
parent.add(child);
|
|
parent.updateTree(0);
|
|
child.setInheritedAlpha(parent.renderAlpha);
|
|
|
|
parent.updateNode(
|
|
const RuntimeNode(
|
|
id: 'parent',
|
|
type: RuntimeNodeType.panel,
|
|
alpha: 0.6,
|
|
),
|
|
);
|
|
|
|
final text = child.children.whereType<TextComponent>().single;
|
|
expect(child.renderAlpha, closeTo(0.3, 0.001));
|
|
expect(
|
|
((text.textRenderer as TextPaint).style.color!).a,
|
|
closeTo(0.3, 0.003),
|
|
);
|
|
});
|
|
|
|
test('multiplies color alpha with node and runtime alpha', () {
|
|
expect(
|
|
composeRuntimeColorAlpha(const Color(0xffffffff), 1).a,
|
|
closeTo(1, 0.001),
|
|
);
|
|
expect(
|
|
composeRuntimeColorAlpha(const Color(0x80ffffff), 1).a,
|
|
closeTo(0.5, 0.003),
|
|
);
|
|
expect(
|
|
composeRuntimeColorAlpha(const Color(0x80ffffff), 0.5).a,
|
|
closeTo(0.25, 0.003),
|
|
);
|
|
expect(
|
|
composeRuntimeColorAlpha(const Color(0x00ffffff), 1).a,
|
|
closeTo(0, 0.001),
|
|
);
|
|
expect(
|
|
composeRuntimeColorAlpha(const Color(0x80ffffff), 0.25).a,
|
|
closeTo(0.125, 0.003),
|
|
);
|
|
});
|
|
|
|
test('computes atlas source region and clamps to image bounds', () {
|
|
expect(
|
|
runtimeImageSourceRect(
|
|
imageWidth: 100,
|
|
imageHeight: 80,
|
|
sourceX: 10,
|
|
sourceY: 12,
|
|
sourceWidth: 30,
|
|
sourceHeight: 20,
|
|
),
|
|
const Rect.fromLTWH(10, 12, 30, 20),
|
|
);
|
|
expect(
|
|
runtimeImageSourceRect(
|
|
imageWidth: 100,
|
|
imageHeight: 80,
|
|
sourceX: 90,
|
|
sourceY: 70,
|
|
sourceWidth: 30,
|
|
sourceHeight: 20,
|
|
),
|
|
const Rect.fromLTWH(90, 70, 10, 10),
|
|
);
|
|
});
|
|
|
|
test('computes nine-slice source and destination rects', () {
|
|
final parts = runtimeNineSliceRects(
|
|
source: const Rect.fromLTWH(10, 20, 30, 40),
|
|
destination: const Rect.fromLTWH(0, 0, 90, 120),
|
|
sliceLeft: 5,
|
|
sliceTop: 6,
|
|
sliceRight: 7,
|
|
sliceBottom: 8,
|
|
);
|
|
|
|
expect(parts, hasLength(9));
|
|
expect(parts.first.source, const Rect.fromLTRB(10, 20, 15, 26));
|
|
expect(parts.first.destination, const Rect.fromLTRB(0, 0, 5, 6));
|
|
expect(parts[4].source, const Rect.fromLTRB(15, 26, 33, 52));
|
|
expect(parts[4].destination, const Rect.fromLTRB(5, 6, 83, 112));
|
|
expect(parts.last.source, const Rect.fromLTRB(33, 52, 40, 60));
|
|
expect(parts.last.destination, const Rect.fromLTRB(83, 112, 90, 120));
|
|
});
|
|
|
|
test('overlaps nine-slice destination seams without changing bounds', () {
|
|
final parts = runtimeNineSliceRects(
|
|
source: const Rect.fromLTWH(0, 0, 30, 30),
|
|
destination: const Rect.fromLTWH(0, 0, 90, 90),
|
|
sliceLeft: 10,
|
|
sliceTop: 10,
|
|
sliceRight: 10,
|
|
sliceBottom: 10,
|
|
destinationOverlap: 0.5,
|
|
);
|
|
|
|
expect(parts.first.destination, const Rect.fromLTRB(0, 0, 10.5, 10.5));
|
|
expect(parts[4].destination, const Rect.fromLTRB(9.5, 9.5, 80.5, 80.5));
|
|
expect(parts.last.destination, const Rect.fromLTRB(79.5, 79.5, 90, 90));
|
|
expect(parts[4].source, const Rect.fromLTRB(10, 10, 20, 20));
|
|
});
|
|
|
|
test('updates text alpha style without rebuilding text component', () {
|
|
final component = RuntimeComponent(
|
|
node: const RuntimeNode(
|
|
id: 'text',
|
|
type: RuntimeNodeType.text,
|
|
text: 'Fade me',
|
|
alpha: 0,
|
|
color: Color(0xffffffff),
|
|
),
|
|
resources: GameResourceManager(),
|
|
onNodeTap: (_, __) {},
|
|
);
|
|
|
|
final text = component.children.whereType<TextComponent>().single;
|
|
expect(((text.textRenderer as TextPaint).style.color!).a, 0);
|
|
|
|
component.setRuntimeAlpha(1);
|
|
|
|
final updatedText = component.children.whereType<TextComponent>().single;
|
|
expect(identical(updatedText, text), isTrue);
|
|
expect(((updatedText.textRenderer as TextPaint).style.color!).a, 1);
|
|
});
|
|
|
|
test(
|
|
'updates button text alpha style without rebuilding text component',
|
|
() {
|
|
final component = RuntimeComponent(
|
|
node: const RuntimeNode(
|
|
id: 'button',
|
|
type: RuntimeNodeType.button,
|
|
text: 'Fade me',
|
|
alpha: 0,
|
|
),
|
|
resources: GameResourceManager(),
|
|
onNodeTap: (_, __) {},
|
|
);
|
|
|
|
final text = component.children.whereType<TextComponent>().single;
|
|
expect(((text.textRenderer as TextPaint).style.color!).a, 0);
|
|
|
|
component.setRuntimeAlpha(1);
|
|
|
|
final updatedText = component.children
|
|
.whereType<TextComponent>()
|
|
.single;
|
|
expect(identical(updatedText, text), isTrue);
|
|
expect(((updatedText.textRenderer as TextPaint).style.color!).a, 1);
|
|
},
|
|
);
|
|
|
|
test('applies text shadow style', () {
|
|
final component = RuntimeComponent(
|
|
node: const RuntimeNode(
|
|
id: 'text',
|
|
type: RuntimeNodeType.text,
|
|
text: 'Shadowed',
|
|
alpha: 0.5,
|
|
textShadowColor: Color(0x80000000),
|
|
textShadowOffsetX: 2,
|
|
textShadowOffsetY: 3,
|
|
textShadowBlur: 4,
|
|
),
|
|
resources: GameResourceManager(),
|
|
onNodeTap: (_, __) {},
|
|
);
|
|
|
|
final text = component.children.whereType<TextComponent>().single;
|
|
final style = (text.textRenderer as TextPaint).style;
|
|
final shadow = style.shadows!.single;
|
|
expect(shadow.color.a, closeTo(0.25, 0.003));
|
|
expect(shadow.offset.dx, 2);
|
|
expect(shadow.offset.dy, 3);
|
|
expect(shadow.blurRadius, 4);
|
|
});
|
|
|
|
test('multi-line non-button text is top aligned', () {
|
|
final component = RuntimeComponent(
|
|
node: const RuntimeNode(
|
|
id: 'text',
|
|
type: RuntimeNodeType.text,
|
|
text: 'line1\nline2',
|
|
width: 120,
|
|
height: 80,
|
|
textAlign: RuntimeTextAlignValue.left,
|
|
),
|
|
resources: GameResourceManager(),
|
|
onNodeTap: (_, __) {},
|
|
);
|
|
|
|
final text = component.children.whereType<TextComponent>().single;
|
|
expect(text.anchor, Anchor.topLeft);
|
|
expect(text.position, Vector2.zero());
|
|
});
|
|
|
|
test('only interactive nodes contain local points', () {
|
|
final passive = RuntimeComponent(
|
|
node: const RuntimeNode(
|
|
id: 'passive',
|
|
type: RuntimeNodeType.rect,
|
|
width: 100,
|
|
height: 40,
|
|
),
|
|
resources: GameResourceManager(),
|
|
onNodeTap: (_, __) {},
|
|
);
|
|
final interactive = RuntimeComponent(
|
|
node: const RuntimeNode(
|
|
id: 'button',
|
|
type: RuntimeNodeType.button,
|
|
width: 100,
|
|
height: 40,
|
|
interactive: true,
|
|
),
|
|
resources: GameResourceManager(),
|
|
onNodeTap: (_, __) {},
|
|
);
|
|
|
|
expect(passive.containsLocalPoint(Vector2(10, 10)), isFalse);
|
|
expect(interactive.containsLocalPoint(Vector2(10, 10)), isTrue);
|
|
expect(interactive.containsLocalPoint(Vector2(101, 10)), isFalse);
|
|
expect(interactive.containsLocalPoint(Vector2(10, 41)), isFalse);
|
|
});
|
|
});
|
|
}
|