497 lines
13 KiB
Dart
497 lines
13 KiB
Dart
import 'dart:async' as async;
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
import 'dart:ui' show Rect;
|
|
|
|
import 'package:flame_lua_runtime/runtime/diagnostics/runtime_diagnostics.dart';
|
|
import 'package:flame_lua_runtime/runtime/packages/game_package.dart';
|
|
import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart';
|
|
import 'package:flame_lua_runtime/runtime/resources/game_resource_manager.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
group('GameResourceManager', () {
|
|
test(
|
|
'advances generation and clears cache when mounting package',
|
|
() async {
|
|
final resources = GameResourceManager();
|
|
final first = await _createPackage('first');
|
|
final second = await _createPackage('second');
|
|
|
|
expect(resources.generation, 0);
|
|
|
|
await resources.mount(first);
|
|
expect(resources.generation, 1);
|
|
|
|
await resources.mount(second);
|
|
expect(resources.generation, 2);
|
|
},
|
|
);
|
|
|
|
test('resolves resource keys from active package', () async {
|
|
final resources = GameResourceManager();
|
|
final package = await _createPackage('resources');
|
|
|
|
await resources.mount(package);
|
|
|
|
expect(resources.resolve('tile'), endsWith('/assets/tile.png'));
|
|
});
|
|
|
|
test(
|
|
'lazy image load records failed state, error and diagnostics',
|
|
() async {
|
|
final diagnostics = RuntimeDiagnostics();
|
|
final resources = GameResourceManager(diagnostics: diagnostics);
|
|
final package = await _createPackage('lazy_failed');
|
|
|
|
await resources.mount(package);
|
|
expect(resources.imageState('tile'), GameResourceState.idle);
|
|
|
|
final image = await resources.loadImage('tile');
|
|
|
|
expect(image, isNull);
|
|
expect(resources.imageState('tile'), GameResourceState.failed);
|
|
expect(resources.imageError('tile'), isNotNull);
|
|
expect(diagnostics.entries, hasLength(1));
|
|
expect(
|
|
diagnostics.entries.single.type,
|
|
RuntimeDiagnosticType.resourceLoadError,
|
|
);
|
|
},
|
|
);
|
|
|
|
test('loads TexturePacker atlas frames for image resources', () async {
|
|
final resources = GameResourceManager();
|
|
final package = await _createTextureAtlasPackage('texture_atlas');
|
|
|
|
await resources.mount(package);
|
|
|
|
final idle = resources.textureFrame('ui', 'button_idle.png');
|
|
final pressed = resources.textureFrame('ui', 'button_pressed.png');
|
|
expect(idle?.rect, Rect.fromLTWH(2, 3, 40, 20));
|
|
expect(pressed?.rect, Rect.fromLTWH(44, 3, 40, 20));
|
|
expect(resources.textureFrame('ui', 'missing.png'), isNull);
|
|
});
|
|
|
|
test('exports image debug json and evicts failed records', () async {
|
|
final resources = GameResourceManager();
|
|
final package = await _createPackage('debug_json');
|
|
|
|
await resources.mount(package);
|
|
|
|
expect(resources.imagesDebugJson(), {
|
|
'generation': 1,
|
|
'hasPackage': true,
|
|
'count': 1,
|
|
'activeLoads': 0,
|
|
'pendingLoads': 0,
|
|
'resources': [
|
|
{
|
|
'key': 'tile',
|
|
'path': endsWith('/assets/tile.png'),
|
|
'type': GameResourceType.image,
|
|
'declared': true,
|
|
'preload': GameResourcePreload.lazy,
|
|
'state': 'idle',
|
|
'loading': false,
|
|
'ready': false,
|
|
},
|
|
],
|
|
});
|
|
|
|
await resources.loadImage('tile');
|
|
final failedJson = resources.imagesDebugJson();
|
|
final failedResources = failedJson['resources'] as List<Object?>;
|
|
final failedTile = failedResources.single as Map<String, Object?>;
|
|
|
|
expect(failedTile['state'], 'failed');
|
|
expect(failedTile['error'], isA<String>());
|
|
|
|
expect(resources.evictImage('tile'), isTrue);
|
|
expect(resources.imageState('tile'), GameResourceState.idle);
|
|
expect(resources.evictImage('tile'), isFalse);
|
|
});
|
|
|
|
test('preloads and evicts image resource groups', () async {
|
|
final resources = GameResourceManager();
|
|
final package = await _createMultiImagePackage('image_group');
|
|
|
|
await resources.mount(package);
|
|
await resources.preloadGroup('board');
|
|
|
|
expect(resources.imageState('board'), GameResourceState.ready);
|
|
expect(resources.imageState('piece'), GameResourceState.ready);
|
|
expect(resources.imageState('avatar'), GameResourceState.idle);
|
|
expect(resources.evictGroup('board'), 2);
|
|
expect(resources.imageState('board'), GameResourceState.idle);
|
|
expect(resources.imageState('piece'), GameResourceState.idle);
|
|
});
|
|
|
|
test('image LRU evicts least recently used unretained images', () async {
|
|
final resources = GameResourceManager(maxCacheEntries: 1);
|
|
final package = await _createMultiImagePackage('image_lru');
|
|
|
|
await resources.mount(package);
|
|
await resources.loadImage('board');
|
|
expect(resources.imageState('board'), GameResourceState.ready);
|
|
|
|
await resources.loadImage('avatar');
|
|
|
|
expect(resources.imageState('board'), GameResourceState.idle);
|
|
expect(resources.imageState('avatar'), GameResourceState.ready);
|
|
});
|
|
|
|
test('image LRU keeps retained images until released', () async {
|
|
final resources = GameResourceManager(maxCacheEntries: 1);
|
|
final package = await _createMultiImagePackage('image_lru_retained');
|
|
|
|
await resources.mount(package);
|
|
await resources.loadImage('board', retain: true);
|
|
await resources.loadImage('avatar');
|
|
|
|
expect(resources.imageState('board'), GameResourceState.ready);
|
|
expect(resources.imageState('avatar'), GameResourceState.idle);
|
|
|
|
resources.releaseImage('board');
|
|
await resources.loadImage('avatar');
|
|
|
|
expect(resources.imageState('board'), GameResourceState.idle);
|
|
expect(resources.imageState('avatar'), GameResourceState.ready);
|
|
});
|
|
|
|
test('deduplicates concurrent image load requests', () async {
|
|
final resources = GameResourceManager();
|
|
final package = await _createPackage('dedupe');
|
|
final countingPackage = _CountingPackage(package);
|
|
|
|
await resources.mount(countingPackage);
|
|
|
|
final first = resources.loadImage('tile');
|
|
final second = resources.loadImage('tile');
|
|
|
|
expect(countingPackage.readCount, 1);
|
|
countingPackage.releaseReads();
|
|
|
|
await Future.wait([first, second]);
|
|
|
|
expect(countingPackage.readCount, 1);
|
|
expect(resources.imageState('tile'), GameResourceState.failed);
|
|
});
|
|
|
|
test(
|
|
'drops stale image load result after dispose without diagnostics',
|
|
() async {
|
|
final diagnostics = RuntimeDiagnostics();
|
|
final resources = GameResourceManager(diagnostics: diagnostics);
|
|
final package = await _createPackage('stale_image');
|
|
final countingPackage = _CountingPackage(package);
|
|
|
|
await resources.mount(countingPackage);
|
|
final image = resources.loadImage('tile');
|
|
|
|
resources.dispose();
|
|
countingPackage.releaseReads();
|
|
|
|
expect(await image, isNull);
|
|
expect(diagnostics.entries, isEmpty);
|
|
},
|
|
);
|
|
|
|
test('optional preload failure does not fail mount', () async {
|
|
final resources = GameResourceManager();
|
|
final package = await _createPackage(
|
|
'optional_failed',
|
|
preload: GameResourcePreload.optional,
|
|
);
|
|
|
|
await resources.mount(package);
|
|
|
|
expect(resources.imageState('tile'), GameResourceState.failed);
|
|
expect(resources.imageError('tile'), isNotNull);
|
|
});
|
|
|
|
test('required preload failure fails mount', () async {
|
|
final resources = GameResourceManager();
|
|
final package = await _createPackage(
|
|
'required_failed',
|
|
preload: GameResourcePreload.required,
|
|
);
|
|
|
|
await expectLater(
|
|
resources.mount(package),
|
|
throwsA(isA<ResourceLoadException>()),
|
|
);
|
|
expect(resources.imageState('tile'), GameResourceState.failed);
|
|
expect(resources.imageError('tile'), isNotNull);
|
|
});
|
|
|
|
test('dispose clears active package and advances generation', () async {
|
|
final resources = GameResourceManager();
|
|
final package = await _createPackage('dispose');
|
|
|
|
await resources.mount(package);
|
|
expect(resources.generation, 1);
|
|
|
|
resources.dispose();
|
|
|
|
expect(resources.generation, 2);
|
|
expect(() => resources.package, throwsStateError);
|
|
});
|
|
});
|
|
}
|
|
|
|
const _pngBytes = [
|
|
0x89,
|
|
0x50,
|
|
0x4e,
|
|
0x47,
|
|
0x0d,
|
|
0x0a,
|
|
0x1a,
|
|
0x0a,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x0d,
|
|
0x49,
|
|
0x48,
|
|
0x44,
|
|
0x52,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x01,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x01,
|
|
0x08,
|
|
0x06,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x1f,
|
|
0x15,
|
|
0xc4,
|
|
0x89,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x0a,
|
|
0x49,
|
|
0x44,
|
|
0x41,
|
|
0x54,
|
|
0x78,
|
|
0x9c,
|
|
0x63,
|
|
0x00,
|
|
0x01,
|
|
0x00,
|
|
0x00,
|
|
0x05,
|
|
0x00,
|
|
0x01,
|
|
0x0d,
|
|
0x0a,
|
|
0x2d,
|
|
0xb4,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x49,
|
|
0x45,
|
|
0x4e,
|
|
0x44,
|
|
0xae,
|
|
0x42,
|
|
0x60,
|
|
0x82,
|
|
];
|
|
|
|
Future<GamePackage> _createPackage(
|
|
String name, {
|
|
String preload = GameResourcePreload.lazy,
|
|
}) async {
|
|
final root = await Directory.systemTemp.createTemp('resource_${name}_');
|
|
Directory('${root.path}/assets').createSync(recursive: true);
|
|
File('${root.path}/assets/tile.png').writeAsBytesSync(const []);
|
|
|
|
addTearDown(() {
|
|
if (root.existsSync()) {
|
|
root.deleteSync(recursive: true);
|
|
}
|
|
});
|
|
|
|
return GamePackage.file(
|
|
rootPath: root.path,
|
|
manifest: GamePackageManifest(
|
|
gameId: 'test',
|
|
name: 'Test',
|
|
version: '0.1.0',
|
|
runtimeApiVersion: 1,
|
|
entry: 'scripts/main.lua',
|
|
assetsBase: 'assets',
|
|
resources: {
|
|
'tile': GameResource(
|
|
type: 'image',
|
|
path: 'assets/tile.png',
|
|
preload: preload,
|
|
),
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<GamePackage> _createTextureAtlasPackage(String name) async {
|
|
final root = await Directory.systemTemp.createTemp('resource_${name}_');
|
|
Directory('${root.path}/assets').createSync(recursive: true);
|
|
File('${root.path}/assets/ui.png').writeAsBytesSync(_pngBytes);
|
|
File('${root.path}/assets/ui.json').writeAsStringSync('''
|
|
{
|
|
"frames": {
|
|
"button_idle.png": {
|
|
"frame": { "x": 2, "y": 3, "w": 40, "h": 20 },
|
|
"rotated": false,
|
|
"trimmed": false
|
|
}
|
|
}
|
|
}
|
|
''');
|
|
|
|
addTearDown(() {
|
|
if (root.existsSync()) {
|
|
root.deleteSync(recursive: true);
|
|
}
|
|
});
|
|
|
|
final hashAtlas = RuntimeTextureAtlas.fromJsonString(
|
|
File('${root.path}/assets/ui.json').readAsStringSync(),
|
|
);
|
|
expect(
|
|
hashAtlas.frames['button_idle.png']?.rect,
|
|
Rect.fromLTWH(2, 3, 40, 20),
|
|
);
|
|
final arrayAtlas = RuntimeTextureAtlas.fromJsonString('''
|
|
{
|
|
"frames": [
|
|
{
|
|
"filename": "button_pressed.png",
|
|
"frame": { "x": 44, "y": 3, "w": 40, "h": 20 },
|
|
"rotated": false,
|
|
"trimmed": false
|
|
}
|
|
]
|
|
}
|
|
''');
|
|
final mergedAtlas =
|
|
'''
|
|
{
|
|
"frames": {
|
|
"button_idle.png": {
|
|
"frame": { "x": 2, "y": 3, "w": 40, "h": 20 },
|
|
"rotated": false,
|
|
"trimmed": false
|
|
},
|
|
"button_pressed.png": {
|
|
"frame": { "x": ${arrayAtlas.frames['button_pressed.png']!.x}, "y": 3, "w": 40, "h": 20 },
|
|
"rotated": false,
|
|
"trimmed": false
|
|
}
|
|
}
|
|
}
|
|
''';
|
|
File('${root.path}/assets/ui.json').writeAsStringSync(mergedAtlas);
|
|
|
|
return GamePackage.file(
|
|
rootPath: root.path,
|
|
manifest: GamePackageManifest(
|
|
gameId: 'test',
|
|
name: 'Test',
|
|
version: '0.1.0',
|
|
runtimeApiVersion: 1,
|
|
entry: 'scripts/main.lua',
|
|
assetsBase: 'assets',
|
|
resources: const {
|
|
'ui': GameResource(
|
|
type: GameResourceType.image,
|
|
path: 'assets/ui.png',
|
|
atlas: 'assets/ui.json',
|
|
preload: GameResourcePreload.lazy,
|
|
),
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<GamePackage> _createMultiImagePackage(String name) async {
|
|
final root = await Directory.systemTemp.createTemp('resource_${name}_');
|
|
Directory('${root.path}/assets').createSync(recursive: true);
|
|
for (final file in ['board.png', 'piece.png', 'avatar.png']) {
|
|
File('${root.path}/assets/$file').writeAsBytesSync(_pngBytes);
|
|
}
|
|
|
|
addTearDown(() {
|
|
if (root.existsSync()) {
|
|
root.deleteSync(recursive: true);
|
|
}
|
|
});
|
|
|
|
return GamePackage.file(
|
|
rootPath: root.path,
|
|
manifest: GamePackageManifest(
|
|
gameId: 'test',
|
|
name: 'Test',
|
|
version: '0.1.0',
|
|
runtimeApiVersion: 1,
|
|
entry: 'scripts/main.lua',
|
|
assetsBase: 'assets',
|
|
resources: const {
|
|
'board': GameResource(
|
|
type: GameResourceType.image,
|
|
path: 'assets/board.png',
|
|
preload: GameResourcePreload.lazy,
|
|
group: 'board',
|
|
),
|
|
'piece': GameResource(
|
|
type: GameResourceType.image,
|
|
path: 'assets/piece.png',
|
|
preload: GameResourcePreload.lazy,
|
|
group: 'board',
|
|
),
|
|
'avatar': GameResource(
|
|
type: GameResourceType.image,
|
|
path: 'assets/avatar.png',
|
|
preload: GameResourcePreload.lazy,
|
|
group: 'hud',
|
|
),
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
class _CountingPackage extends GamePackage {
|
|
_CountingPackage(GamePackage package)
|
|
: _releaseReads = async.Completer<void>(),
|
|
super.file(rootPath: package.rootPath, manifest: package.manifest);
|
|
|
|
final async.Completer<void> _releaseReads;
|
|
int readCount = 0;
|
|
|
|
void releaseReads() {
|
|
if (!_releaseReads.isCompleted) {
|
|
_releaseReads.complete();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<ByteData> readBytes(String relativeOrAbsolutePath) async {
|
|
readCount++;
|
|
await _releaseReads.future;
|
|
return ByteData.sublistView(Uint8List(0));
|
|
}
|
|
}
|