Initial flame_lua_runtime package

This commit is contained in:
gem
2026-06-07 22:53:58 +08:00
commit 733b2fb798
262 changed files with 28439 additions and 0 deletions

View File

@@ -0,0 +1,593 @@
import 'package:flame/components.dart';
import 'package:flame_lua_runtime/runtime/models/game_diff.dart';
import 'package:flame_lua_runtime/runtime/models/runtime_event.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/render_tree_controller.dart';
import 'package:flame_lua_runtime/runtime/resources/game_resource_manager.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('RenderTreeController', () {
test('creates, updates and removes components by id', () {
final events = <RuntimeEvent>[];
final controller = RenderTreeController(
root: Component(),
resources: GameResourceManager(),
eventSink: events.add,
);
controller.apply(
NodeDiff(
creates: [
RuntimeNode(
id: 'panel',
type: RuntimeNodeType.rect,
x: 10,
y: 20,
width: 100,
height: 80,
layer: 4,
),
],
),
);
final created = controller.componentById('panel');
expect(created, isNotNull);
expect(created!.node.x, 10);
expect(created.node.y, 20);
expect(created.node.width, 100);
expect(created.node.height, 80);
expect(created.priority, 4);
controller.apply(
NodeDiff(
updates: [
NodeUpdate(
id: 'panel',
props: {'x': 30, 'visible': false, 'layer': 8},
),
],
),
);
final updated = controller.componentById('panel');
expect(updated, same(created));
expect(updated!.node.x, 30);
expect(updated.node.y, 20);
expect(updated.node.visible, isFalse);
expect(updated.priority, 8);
controller.apply(const NodeDiff(removes: [NodeRemove(id: 'panel')]));
expect(controller.componentById('panel'), isNull);
});
test('replaces an existing component when create uses same id', () {
final controller = RenderTreeController(
root: Component(),
resources: GameResourceManager(),
eventSink: (_) {},
);
controller.apply(
const NodeDiff(
creates: [RuntimeNode(id: 'node', type: RuntimeNodeType.rect)],
),
);
final first = controller.componentById('node');
controller.apply(
const NodeDiff(
creates: [
RuntimeNode(id: 'node', type: RuntimeNodeType.circle, layer: 2),
],
),
);
final second = controller.componentById('node');
expect(first, isNotNull);
expect(second, isNotNull);
expect(second, isNot(same(first)));
expect(second!.node.type, RuntimeNodeType.circle);
expect(second.priority, 2);
});
test('mounts nodes under declared parent and supports reparenting', () {
final root = Component();
final controller = RenderTreeController(
root: root,
resources: GameResourceManager(),
eventSink: (_) {},
);
controller.apply(
const NodeDiff(
creates: [
RuntimeNode(id: 'panel', type: RuntimeNodeType.panel),
RuntimeNode(
id: 'button',
type: RuntimeNodeType.button,
parent: 'panel',
),
],
),
);
final panel = controller.componentById('panel')!;
final button = controller.componentById('button')!;
expect(panel.parent, root);
expect(button.parent, panel);
controller.apply(
NodeDiff(
updates: [
NodeUpdate(id: 'button', props: {'parent': ''}),
],
),
);
expect(button.parent, root);
expect(button.node.parent, isNull);
});
test('reattaches child when parent is created later', () {
final root = Component();
final controller = RenderTreeController(
root: root,
resources: GameResourceManager(),
eventSink: (_) {},
);
controller.apply(
const NodeDiff(
creates: [
RuntimeNode(
id: 'button',
type: RuntimeNodeType.button,
parent: 'panel',
),
],
),
);
final button = controller.componentById('button')!;
expect(button.parent, root);
controller.apply(
const NodeDiff(
creates: [RuntimeNode(id: 'panel', type: RuntimeNodeType.panel)],
),
);
expect(button.parent, controller.componentById('panel'));
});
test('scrolls listView by id or point and offsets direct children', () {
final root = PositionComponent();
final events = <RuntimeEvent>[];
final controller = RenderTreeController(
root: root,
resources: GameResourceManager(),
eventSink: events.add,
);
controller.apply(
const NodeDiff(
creates: [
RuntimeNode(
id: 'list',
type: RuntimeNodeType.listView,
width: 160,
height: 60,
contentWidth: 220,
contentHeight: 140,
scrollX: 10,
scrollY: 20,
onScroll: 'list_scrolled',
),
RuntimeNode(
id: 'row',
type: RuntimeNodeType.button,
parent: 'list',
x: 40,
y: 50,
width: 140,
height: 24,
),
],
),
);
final list = controller.componentById('list')!;
final row = controller.componentById('row')!;
expect(row.parent, list);
expect(row.position, Vector2(30, 30));
expect(controller.listViewAt(Vector2(10, 10)), 'list');
expect(controller.scrollListView('list', deltaX: 30, deltaY: 50), isTrue);
expect(controller.componentById('list')!.node.scrollX, 40);
expect(controller.componentById('list')!.node.scrollY, 70);
expect(row.position, Vector2(0, -20));
expect(events.last.type, RuntimeEventType.scroll);
expect(events.last.handler, 'list_scrolled');
expect(events.last.data['scrollX'], 40);
expect(events.last.data['scrollY'], 70);
expect(
controller.scrollListViewAt(Vector2(10, 10), deltaY: 1000),
isTrue,
);
expect(controller.componentById('list')!.node.scrollY, 80);
expect(row.position, Vector2(0, -30));
expect(
controller.scrollListViewAt(Vector2(500, 500), deltaY: 20),
isFalse,
);
});
test('listView padding offsets children and reduces scroll viewport', () {
final root = PositionComponent();
final controller = RenderTreeController(
root: root,
resources: GameResourceManager(),
eventSink: (_) {},
);
controller.apply(
const NodeDiff(
creates: [
RuntimeNode(
id: 'list',
type: RuntimeNodeType.listView,
width: 120,
height: 80,
contentWidth: 200,
contentHeight: 168,
paddingLeft: 10,
paddingTop: 12,
paddingRight: 8,
paddingBottom: 6,
scrollbarVisible: false,
),
RuntimeNode(
id: 'row',
type: RuntimeNodeType.button,
parent: 'list',
x: 4,
y: 5,
width: 40,
height: 20,
),
],
),
);
final row = controller.componentById('row')!;
expect(row.position, Vector2(14, 17));
expect(
controller.scrollListView('list', deltaX: 200, deltaY: 200),
isTrue,
);
expect(controller.componentById('list')!.node.scrollX, 98);
expect(controller.componentById('list')!.node.scrollY, 106);
expect(row.position, Vector2(-84, -89));
});
test('virtualized listView culls direct children outside cache window', () {
final root = PositionComponent();
final controller = RenderTreeController(
root: root,
resources: GameResourceManager(),
eventSink: (_) {},
);
controller.apply(
const NodeDiff(
creates: [
RuntimeNode(
id: 'list',
type: RuntimeNodeType.listView,
width: 120,
height: 60,
contentHeight: 400,
virtualized: true,
cacheExtent: 0,
),
RuntimeNode(
id: 'visible_row',
type: RuntimeNodeType.button,
parent: 'list',
y: 20,
width: 100,
height: 20,
),
RuntimeNode(
id: 'culled_row',
type: RuntimeNodeType.button,
parent: 'list',
y: 180,
width: 100,
height: 20,
),
],
),
);
expect(controller.componentById('visible_row')!.isVisible, isTrue);
expect(controller.componentById('culled_row')!.isVisible, isFalse);
controller.scrollListView('list', deltaY: 150);
expect(controller.componentById('visible_row')!.isVisible, isFalse);
expect(controller.componentById('culled_row')!.isVisible, isTrue);
});
test('removes descendants when removing parent', () {
final controller = RenderTreeController(
root: Component(),
resources: GameResourceManager(),
eventSink: (_) {},
);
controller.apply(
const NodeDiff(
creates: [
RuntimeNode(id: 'panel', type: RuntimeNodeType.panel),
RuntimeNode(
id: 'button',
type: RuntimeNodeType.button,
parent: 'panel',
),
RuntimeNode(
id: 'label',
type: RuntimeNodeType.text,
parent: 'button',
),
],
),
);
controller.apply(const NodeDiff(removes: [NodeRemove(id: 'panel')]));
expect(controller.componentById('panel'), isNull);
expect(controller.componentById('button'), isNull);
expect(controller.componentById('label'), isNull);
});
test('rejects parent cycles', () {
final controller = RenderTreeController(
root: Component(),
resources: GameResourceManager(),
eventSink: (_) {},
);
controller.apply(
const NodeDiff(
creates: [
RuntimeNode(id: 'panel', type: RuntimeNodeType.panel),
RuntimeNode(
id: 'button',
type: RuntimeNodeType.button,
parent: 'panel',
),
],
),
);
expect(
() => controller.apply(
NodeDiff(
updates: [
NodeUpdate(id: 'panel', props: {'parent': 'button'}),
],
),
),
throwsFormatException,
);
expect(controller.componentById('panel')!.node.parent, isNull);
});
test('rejects invalid diff before applying any partial mutation', () {
final controller = RenderTreeController(
root: Component(),
resources: GameResourceManager(),
eventSink: (_) {},
);
controller.apply(
const NodeDiff(
creates: [
RuntimeNode(id: 'toast', type: RuntimeNodeType.panel),
RuntimeNode(id: 'panel', type: RuntimeNodeType.panel),
RuntimeNode(
id: 'button',
type: RuntimeNodeType.button,
parent: 'panel',
),
],
),
);
expect(
() => controller.apply(
NodeDiff(
removes: const [NodeRemove(id: 'toast')],
updates: [
NodeUpdate(id: 'panel', props: {'parent': 'button'}),
],
),
),
throwsFormatException,
);
expect(controller.componentById('toast'), isNotNull);
expect(controller.componentById('panel')!.node.parent, isNull);
expect(controller.componentById('button')!.node.parent, 'panel');
});
test('clear removes all tracked components', () {
final controller = RenderTreeController(
root: Component(),
resources: GameResourceManager(),
eventSink: (_) {},
);
controller.apply(
const NodeDiff(
creates: [
RuntimeNode(id: 'panel', type: RuntimeNodeType.panel),
RuntimeNode(
id: 'button',
type: RuntimeNodeType.button,
parent: 'panel',
),
],
),
);
controller.clear();
expect(controller.componentById('panel'), isNull);
expect(controller.componentById('button'), isNull);
});
test('ignores tap callback from stale replaced component', () {
final events = <RuntimeEvent>[];
final controller = RenderTreeController(
root: Component(),
resources: GameResourceManager(),
eventSink: events.add,
);
controller.apply(
const NodeDiff(
creates: [
RuntimeNode(
id: 'button',
type: RuntimeNodeType.button,
interactive: true,
onTap: 'old_tap',
),
],
),
);
final stale = controller.componentById('button')!;
controller.apply(
const NodeDiff(
creates: [
RuntimeNode(
id: 'button',
type: RuntimeNodeType.button,
interactive: true,
onTap: 'new_tap',
),
],
),
);
stale.onNodeTap(stale.node, Vector2(1, 2));
controller
.componentById('button')!
.onNodeTap(controller.componentById('button')!.node, Vector2(3, 4));
expect(events.map((event) => event.toMap()), [
{
'type': RuntimeEventType.tap,
'target': 'button',
'handler': 'new_tap',
'x': 3.0,
'y': 4.0,
},
]);
});
test('emits tap event from interactive node callback', () {
final events = <RuntimeEvent>[];
final controller = RenderTreeController(
root: Component(),
resources: GameResourceManager(),
eventSink: events.add,
);
controller.apply(
const NodeDiff(
creates: [
RuntimeNode(
id: 'button',
type: RuntimeNodeType.button,
interactive: true,
onTap: 'roll_dice',
),
],
),
);
final component = controller.componentById('button')!;
component.onNodeTap(component.node, Vector2(3, 4));
expect(events, hasLength(1));
expect(events.single.toMap(), {
'type': RuntimeEventType.tap,
'target': 'button',
'handler': 'roll_dice',
'x': 3.0,
'y': 4.0,
});
expect(events.single.targetEpoch, controller.epochOf('button'));
expect(events.single.scopeEpoch, controller.epochOf('button'));
});
test('increments epoch when node is recreated', () {
final controller = RenderTreeController(
root: Component(),
resources: GameResourceManager(),
eventSink: (_) {},
);
controller.apply(
const NodeDiff(
creates: [RuntimeNode(id: 'node', type: RuntimeNodeType.rect)],
),
);
final firstEpoch = controller.epochOf('node');
controller.apply(const NodeDiff(removes: [NodeRemove(id: 'node')]));
final removedEpoch = controller.epochOf('node');
controller.apply(
const NodeDiff(
creates: [RuntimeNode(id: 'node', type: RuntimeNodeType.rect)],
),
);
final recreatedEpoch = controller.epochOf('node');
expect(firstEpoch, 1);
expect(removedEpoch, greaterThan(firstEpoch));
expect(recreatedEpoch, greaterThan(removedEpoch));
expect(controller.isNodeEpochAlive('node', firstEpoch), isFalse);
expect(controller.isNodeEpochAlive('node', recreatedEpoch), isTrue);
});
test('ignores updates and removes for unknown ids', () {
final controller = RenderTreeController(
root: Component(),
resources: GameResourceManager(),
eventSink: (_) {},
);
controller.apply(
NodeDiff(
updates: [
NodeUpdate(id: 'missing', props: {'x': 1}),
],
removes: const [NodeRemove(id: 'missing')],
),
);
expect(controller.componentById('missing'), isNull);
});
});
}

View File

@@ -0,0 +1,165 @@
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_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('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);
});
});
}

View File

@@ -0,0 +1,98 @@
import 'package:flame/components.dart';
import 'package:flame_lua_runtime/runtime/display/runtime_viewport.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('RuntimeViewport', () {
test('fits design size inside screen with letterboxing', () {
final transform = RuntimeViewport.compute(
screenSize: Vector2(1280, 720),
config: const RuntimeViewportConfig(
designWidth: 720,
designHeight: 720,
),
);
expect(transform.x, 280);
expect(transform.y, 0);
expect(transform.width, 720);
expect(transform.height, 720);
expect(transform.scaleX, 1);
expect(transform.scaleY, 1);
});
test('fills screen by preserving aspect ratio and cropping', () {
final transform = RuntimeViewport.compute(
screenSize: Vector2(1280, 720),
config: const RuntimeViewportConfig(
designWidth: 720,
designHeight: 720,
scaleMode: RuntimeScaleMode.fill,
),
);
expect(transform.x, 0);
expect(transform.y, -280);
expect(transform.width, 1280);
expect(transform.height, 1280);
expect(transform.scaleX, closeTo(1.777777, 0.00001));
expect(transform.scaleY, closeTo(1.777777, 0.00001));
});
test('stretches design independently on both axes', () {
final transform = RuntimeViewport.compute(
screenSize: Vector2(1440, 720),
config: const RuntimeViewportConfig(
designWidth: 720,
designHeight: 720,
scaleMode: RuntimeScaleMode.stretch,
),
);
expect(transform.x, 0);
expect(transform.y, 0);
expect(transform.width, 1440);
expect(transform.height, 720);
expect(transform.scaleX, 2);
expect(transform.scaleY, 1);
});
test('centers design without scaling', () {
final transform = RuntimeViewport.compute(
screenSize: Vector2(1000, 800),
config: const RuntimeViewportConfig(
designWidth: 720,
designHeight: 720,
scaleMode: RuntimeScaleMode.none,
),
);
expect(transform.x, 140);
expect(transform.y, 40);
expect(transform.width, 720);
expect(transform.height, 720);
expect(transform.scaleX, 1);
expect(transform.scaleY, 1);
});
test('applies transform to root component', () {
final root = PositionComponent();
RuntimeViewport.apply(
root,
const RuntimeViewportTransform(
x: 10,
y: 20,
width: 300,
height: 400,
scaleX: 2,
scaleY: 3,
scaleMode: RuntimeScaleMode.stretch,
),
);
expect(root.position, Vector2(10, 20));
expect(root.size, Vector2(300, 400));
expect(root.scale, Vector2(2, 3));
});
});
}