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,484 @@
import 'dart:async' as async;
import 'dart:io';
import 'dart:typed_data';
import 'package:flame_lua_runtime/runtime/audio/runtime_audio_manager.dart';
import 'package:flame_lua_runtime/runtime/audio/runtime_audio_player.dart';
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() {
group('RuntimeAudioManager', () {
test('preloads required audio and exposes ready state', () async {
final package = await _createPackage(
'required_audio',
preload: GameResourcePreload.required,
);
final audio = RuntimeAudioManager();
await audio.mount(package);
expect(audio.audioState('dice'), GameResourceState.ready);
audio.dispose();
});
test('lazy audio plays from package bytes', () async {
final players = <_FakeRuntimeAudioPlayer>[];
final package = await _createPackage('lazy_audio');
final audio = RuntimeAudioManager(
playerFactory: () {
final player = _FakeRuntimeAudioPlayer();
players.add(player);
return player;
},
);
await audio.mount(package);
final playback = await audio.play('dice', volume: 0.5);
expect(playback, isNotNull);
expect(audio.audioState('dice'), GameResourceState.ready);
expect(players.single.startedBytes, _audioBytes);
expect(players.single.volume, 0.5);
players.single.complete();
await playback!.done;
audio.dispose();
});
test('lazy audio failure records state, error and diagnostics', () async {
final diagnostics = RuntimeDiagnostics();
final package = await _createPackage('missing_audio', writeAudio: false);
final audio = RuntimeAudioManager(diagnostics: diagnostics);
await audio.mount(package);
final playback = await audio.play('dice');
expect(playback, isNull);
expect(audio.audioState('dice'), GameResourceState.failed);
expect(audio.audioError('dice'), isNotNull);
expect(
diagnostics.entries.single.type,
RuntimeDiagnosticType.resourceLoadError,
);
audio.dispose();
});
test(
'exports audio debug json, evicts and retries failed records',
() async {
final root = await Directory.systemTemp.createTemp('audio_retry_');
Directory('${root.path}/assets').createSync(recursive: true);
addTearDown(() {
if (root.existsSync()) {
root.deleteSync(recursive: true);
}
});
final package = _createPackageAt(
root,
preload: GameResourcePreload.lazy,
);
final audio = RuntimeAudioManager();
await audio.mount(package);
expect(audio.audioDebugJson(), {
'generation': 1,
'hasPackage': true,
'count': 1,
'activeLoads': 0,
'pendingLoads': 0,
'activePlayers': 0,
'pooledPlayers': 0,
'channels': <String>[],
'resources': [
{
'key': 'dice',
'path': endsWith('/assets/dice.wav'),
'type': GameResourceType.audio,
'declared': true,
'preload': GameResourcePreload.lazy,
'state': 'idle',
'loading': false,
'ready': false,
},
],
});
expect(await audio.retryAudio('dice'), isFalse);
expect(audio.audioState('dice'), GameResourceState.failed);
expect(audio.audioDebugJson().toString(), contains('error'));
File('${root.path}/assets/dice.wav').writeAsBytesSync(_audioBytes);
expect(await audio.retryAudio('dice'), isTrue);
expect(audio.audioState('dice'), GameResourceState.ready);
expect(audio.evictAudio('dice'), isTrue);
expect(audio.audioState('dice'), GameResourceState.idle);
expect(audio.evictAudio('dice'), isFalse);
audio.dispose();
},
);
test('preloads and evicts audio resource groups', () async {
final package = await _createMultiAudioPackage('audio_group');
final audio = RuntimeAudioManager();
await audio.mount(package);
await audio.preloadGroup('scene');
expect(audio.audioState('dice'), GameResourceState.ready);
expect(audio.audioState('click'), GameResourceState.ready);
expect(audio.audioState('bgm'), GameResourceState.idle);
expect(audio.evictGroup('scene'), 2);
expect(audio.audioState('dice'), GameResourceState.idle);
expect(audio.audioState('click'), GameResourceState.idle);
audio.dispose();
});
test('limits concurrent audio reads during group preload', () async {
final package = _CountingAudioPackage(
await _createMultiAudioPackage('audio_concurrency'),
);
final audio = RuntimeAudioManager(maxConcurrentLoads: 1);
await audio.mount(package);
await audio.preloadGroup('scene');
expect(package.maxActiveReads, 1);
expect(audio.audioState('dice'), GameResourceState.ready);
expect(audio.audioState('click'), GameResourceState.ready);
audio.dispose();
});
test(
'drops stale audio load result after dispose without diagnostics',
() async {
final diagnostics = RuntimeDiagnostics();
final package = _BlockingAudioPackage(
await _createPackage('stale_audio'),
);
final audio = RuntimeAudioManager(diagnostics: diagnostics);
await audio.mount(package);
final playback = audio.play('dice');
audio.dispose();
package.releaseReads();
expect(await playback, isNull);
expect(diagnostics.entries, isEmpty);
},
);
test('audio LRU evicts least recently used bytes', () async {
final package = await _createMultiAudioPackage('audio_lru');
final audio = RuntimeAudioManager(maxCacheEntries: 1);
await audio.mount(package);
expect(await audio.retryAudio('dice'), isTrue);
expect(audio.audioState('dice'), GameResourceState.ready);
expect(await audio.retryAudio('bgm'), isTrue);
expect(audio.audioState('dice'), GameResourceState.idle);
expect(audio.audioState('bgm'), GameResourceState.ready);
audio.dispose();
});
test('reuses pooled one-shot audio players', () async {
final players = <_FakeRuntimeAudioPlayer>[];
final package = await _createPackage('sfx_pool');
final audio = RuntimeAudioManager(
maxSfxPoolSize: 1,
playerFactory: () {
final player = _FakeRuntimeAudioPlayer();
players.add(player);
return player;
},
);
await audio.mount(package);
final first = await audio.play('dice');
players.single.complete();
await first!.done;
expect(audio.audioDebugJson()['pooledPlayers'], 1);
final second = await audio.play('dice');
players.single.complete();
await second!.done;
expect(players, hasLength(1));
expect(players.single.startCount, 2);
audio.dispose();
});
test('plays, pauses, resumes and stops bgm channel', () async {
final players = <_FakeRuntimeAudioPlayer>[];
final package = await _createPackage('bgm_audio');
final audio = RuntimeAudioManager(
playerFactory: () {
final player = _FakeRuntimeAudioPlayer();
players.add(player);
return player;
},
);
await audio.mount(package);
final playback = await audio.playBgm(
'dice',
channel: 'music',
volume: 0.3,
loop: true,
);
expect(playback, isNotNull);
expect(audio.hasBgm(channel: 'music'), isTrue);
expect(players.single.startedBytes, _audioBytes);
expect(players.single.volume, 0.3);
expect(players.single.loop, isTrue);
await audio.pauseBgm(channel: 'music');
await audio.resumeBgm(channel: 'music');
await audio.stopBgm(channel: 'music');
expect(players.single.paused, isTrue);
expect(players.single.resumed, isTrue);
expect(players.single.stopped, isTrue);
expect(audio.hasBgm(channel: 'music'), isFalse);
audio.dispose();
});
test('starting same bgm channel replaces previous playback', () async {
final players = <_FakeRuntimeAudioPlayer>[];
final package = await _createPackage('replace_bgm_audio');
final audio = RuntimeAudioManager(
playerFactory: () {
final player = _FakeRuntimeAudioPlayer();
players.add(player);
return player;
},
);
await audio.mount(package);
await audio.playBgm('dice', channel: 'music');
await audio.playBgm('dice', channel: 'music');
expect(players, hasLength(2));
expect(players.first.stopped, isTrue);
expect(audio.hasBgm(channel: 'music'), isTrue);
audio.dispose();
});
test('required audio preload failure fails mount', () async {
final package = await _createPackage(
'required_missing_audio',
preload: GameResourcePreload.required,
writeAudio: false,
);
final audio = RuntimeAudioManager();
await expectLater(
audio.mount(package),
throwsA(isA<ResourceLoadException>()),
);
expect(audio.audioState('dice'), GameResourceState.failed);
audio.dispose();
});
});
}
const _audioBytes = [1, 2, 3, 4, 5];
Future<GamePackage> _createPackage(
String name, {
String preload = GameResourcePreload.lazy,
bool writeAudio = true,
}) async {
final root = await Directory.systemTemp.createTemp('audio_${name}_');
Directory('${root.path}/assets').createSync(recursive: true);
if (writeAudio) {
File('${root.path}/assets/dice.wav').writeAsBytesSync(_audioBytes);
}
addTearDown(() {
if (root.existsSync()) {
root.deleteSync(recursive: true);
}
});
return _createPackageAt(root, preload: preload);
}
Future<GamePackage> _createMultiAudioPackage(String name) async {
final root = await Directory.systemTemp.createTemp('audio_${name}_');
Directory('${root.path}/assets').createSync(recursive: true);
File('${root.path}/assets/dice.wav').writeAsBytesSync(_audioBytes);
File('${root.path}/assets/click.wav').writeAsBytesSync([6, 7, 8]);
File('${root.path}/assets/bgm.wav').writeAsBytesSync([9, 10, 11, 12]);
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 {
'dice': GameResource(
type: GameResourceType.audio,
path: 'assets/dice.wav',
preload: GameResourcePreload.lazy,
group: 'scene',
),
'click': GameResource(
type: GameResourceType.audio,
path: 'assets/click.wav',
preload: GameResourcePreload.lazy,
group: 'scene',
),
'bgm': GameResource(
type: GameResourceType.audio,
path: 'assets/bgm.wav',
preload: GameResourcePreload.lazy,
group: 'music',
),
},
),
);
}
GamePackage _createPackageAt(
Directory root, {
String preload = GameResourcePreload.lazy,
}) {
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: {
'dice': GameResource(
type: GameResourceType.audio,
path: 'assets/dice.wav',
preload: preload,
),
},
),
);
}
class _CountingAudioPackage extends GamePackage {
_CountingAudioPackage(GamePackage package)
: super.file(rootPath: package.rootPath, manifest: package.manifest);
var activeReads = 0;
var maxActiveReads = 0;
@override
Future<ByteData> readBytes(String relativeOrAbsolutePath) async {
activeReads++;
if (activeReads > maxActiveReads) {
maxActiveReads = activeReads;
}
await Future<void>.delayed(const Duration(milliseconds: 5));
try {
return await super.readBytes(relativeOrAbsolutePath);
} finally {
activeReads--;
}
}
}
class _BlockingAudioPackage extends GamePackage {
_BlockingAudioPackage(GamePackage package)
: _releaseReads = async.Completer<void>(),
super.file(rootPath: package.rootPath, manifest: package.manifest);
final async.Completer<void> _releaseReads;
void releaseReads() {
if (!_releaseReads.isCompleted) {
_releaseReads.complete();
}
}
@override
Future<ByteData> readBytes(String relativeOrAbsolutePath) async {
await _releaseReads.future;
return super.readBytes(relativeOrAbsolutePath);
}
}
class _FakeRuntimeAudioPlayer implements RuntimeAudioPlayer {
async.Completer<void> _done = async.Completer<void>();
List<int>? startedBytes;
double? volume;
var loop = false;
var paused = false;
var resumed = false;
var stopped = false;
var disposed = false;
var startCount = 0;
@override
Future<void> get done => _done.future;
@override
Future<void> start(
Uint8List bytes, {
required double volume,
bool loop = false,
}) async {
if (_done.isCompleted) {
_done = async.Completer<void>();
}
startCount++;
startedBytes = bytes.toList(growable: false);
this.volume = volume;
this.loop = loop;
}
void complete() {
if (!_done.isCompleted) {
_done.complete();
}
}
@override
Future<void> pause() async {
paused = true;
}
@override
Future<void> resume() async {
resumed = true;
}
@override
Future<void> stop() async {
stopped = true;
complete();
}
@override
Future<void> dispose() async {
disposed = true;
complete();
}
}