diff --git a/apps/wolf_3d_gui/lib/main.dart b/apps/wolf_3d_gui/lib/main.dart index 3f16a3e..de4c383 100644 --- a/apps/wolf_3d_gui/lib/main.dart +++ b/apps/wolf_3d_gui/lib/main.dart @@ -2,15 +2,18 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:wolf_3d_flutter/wolf_3d_flutter.dart'; +import 'packaged_games_loader.dart'; import 'wolf3d_gui_app.dart'; /// Creates the application shell after loading available Wolf3D data sets. void main() async { WidgetsFlutterBinding.ensureInitialized(); + final seededGames = await loadPackagedGames(); + final Wolf3dFlutterEngine wolf3d = await Wolf3dFlutterEngine( debug: kDebugMode, - ).init(); + ).init(seededGames: seededGames); if (kDebugMode) { wolf3d.enableMenuHeaderBandDebugLogging(prefix: '[wolf_3d_gui]'); diff --git a/apps/wolf_3d_gui/lib/packaged_games_loader.dart b/apps/wolf_3d_gui/lib/packaged_games_loader.dart new file mode 100644 index 0000000..a9fd671 --- /dev/null +++ b/apps/wolf_3d_gui/lib/packaged_games_loader.dart @@ -0,0 +1,54 @@ +import 'package:flutter/foundation.dart'; +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; +import 'package:wolf_3d_flutter/wolf_3d_flutter.dart'; + +typedef PackagedGameDataLoader = + Future Function({ + required GameVersion version, + required String assetDirectory, + }); + +Future> loadPackagedGames({ + PackagedGameDataLoader? loader, +}) async { + final PackagedGameDataLoader effectiveLoader = + loader ?? + ({required version, required assetDirectory}) => + Wolf3dFlutterEngine.loadGameDataFromAssets( + version: version, + packageName: 'wolf_3d_assets', + assetDirectory: assetDirectory, + ); + + final List games = []; + + Future tryLoad({ + required GameVersion version, + required String assetDirectory, + }) async { + try { + games.add( + await effectiveLoader( + version: version, + assetDirectory: assetDirectory, + ), + ); + } catch (e) { + debugPrint( + 'Packaged game load skipped for ${version.label} ($assetDirectory): $e', + ); + } + } + + await tryLoad(version: GameVersion.retail, assetDirectory: 'assets/retail'); + await tryLoad( + version: GameVersion.shareware, + assetDirectory: 'assets/shareware', + ); + await tryLoad( + version: GameVersion.spearOfDestinyDemo, + assetDirectory: 'assets/sod/shareware', + ); + + return games; +} diff --git a/apps/wolf_3d_gui/pubspec.yaml b/apps/wolf_3d_gui/pubspec.yaml index 1a18e48..280a21a 100644 --- a/apps/wolf_3d_gui/pubspec.yaml +++ b/apps/wolf_3d_gui/pubspec.yaml @@ -13,6 +13,7 @@ dependencies: file_selector: ^1.0.3 wolf_3d_flutter: wolf_3d_dart: + wolf_3d_assets: flutter: sdk: flutter diff --git a/apps/wolf_3d_gui/test/game_data_picker_manager_test.dart b/apps/wolf_3d_gui/test/game_data_picker_manager_test.dart index 6e466dc..f8826ca 100644 --- a/apps/wolf_3d_gui/test/game_data_picker_manager_test.dart +++ b/apps/wolf_3d_gui/test/game_data_picker_manager_test.dart @@ -53,6 +53,7 @@ class _RecordingEngine extends Wolf3dFlutterEngine { Future init({ String? directory, Iterable? additionalDirectories, + Iterable? seededGames, }) async { initCallCount++; lastDirectory = directory; diff --git a/apps/wolf_3d_gui/test/packaged_games_loader_test.dart b/apps/wolf_3d_gui/test/packaged_games_loader_test.dart new file mode 100644 index 0000000..aa052cc --- /dev/null +++ b/apps/wolf_3d_gui/test/packaged_games_loader_test.dart @@ -0,0 +1,91 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; +import 'package:wolf_3d_gui/packaged_games_loader.dart'; + +void main() { + group('loadPackagedGames', () { + test('loads known packaged directories in order', () async { + final requested = <(GameVersion, String)>[]; + + final games = await loadPackagedGames( + loader: ({required version, required assetDirectory}) async { + requested.add((version, assetDirectory)); + return _buildTestData(version); + }, + ); + + expect(requested, <(GameVersion, String)>[ + (GameVersion.retail, 'assets/retail'), + (GameVersion.shareware, 'assets/shareware'), + (GameVersion.spearOfDestinyDemo, 'assets/sod/shareware'), + ]); + expect(games.map((g) => g.version), [ + GameVersion.retail, + GameVersion.shareware, + GameVersion.spearOfDestinyDemo, + ]); + }); + + test('skips failing packaged loads and keeps successful ones', () async { + final games = await loadPackagedGames( + loader: ({required version, required assetDirectory}) async { + if (version == GameVersion.shareware) { + throw Exception('shareware unavailable'); + } + return _buildTestData(version); + }, + ); + + expect(games.map((g) => g.version), [ + GameVersion.retail, + GameVersion.spearOfDestinyDemo, + ]); + }); + }); +} + +WolfensteinData _buildTestData(GameVersion version) { + final wallGrid = List.generate(64, (_) => List.filled(64, 0)); + final objectGrid = List.generate(64, (_) => List.filled(64, 0)); + + for (int i = 0; i < 64; i++) { + wallGrid[0][i] = 2; + wallGrid[63][i] = 2; + wallGrid[i][0] = 2; + wallGrid[i][63] = 2; + } + objectGrid[2][2] = MapObject.playerEast; + + return WolfensteinData( + version: version, + dataVersion: DataVersion.unknown, + registry: version == GameVersion.shareware + ? SharewareAssetRegistry() + : RetailAssetRegistry(), + walls: [_sprite(1), _sprite(1), _sprite(2), _sprite(2)], + sprites: List.generate(436, (_) => _sprite(255)), + sounds: List.generate(200, (_) => PcmSound(Uint8List(1))), + adLibSounds: const [], + music: const [], + vgaImages: const [], + episodes: [ + Episode( + name: 'Test Episode', + levels: [ + WolfLevel( + name: 'Test Level', + wallGrid: wallGrid, + areaGrid: List.generate(64, (_) => List.filled(64, -1)), + objectGrid: objectGrid, + music: Music.level01, + ), + ], + ), + ], + ); +} + +Sprite _sprite(int color) => + Sprite(Uint8List.fromList(List.filled(64 * 64, color))); diff --git a/apps/wolf_3d_gui/test/wolf3d_gui_app_test.dart b/apps/wolf_3d_gui/test/wolf3d_gui_app_test.dart index 6b93188..516d6e7 100644 --- a/apps/wolf_3d_gui/test/wolf3d_gui_app_test.dart +++ b/apps/wolf_3d_gui/test/wolf3d_gui_app_test.dart @@ -88,6 +88,7 @@ class _RecordingEngine extends Wolf3dFlutterEngine { Future init({ String? directory, Iterable? additionalDirectories, + Iterable? seededGames, }) async { initCallCount++; availableGames diff --git a/packages/wolf_3d_flutter/README.md b/packages/wolf_3d_flutter/README.md index f8d0dc0..3e3cc59 100644 --- a/packages/wolf_3d_flutter/README.md +++ b/packages/wolf_3d_flutter/README.md @@ -39,7 +39,30 @@ final Wolf3dFlutterEngine engine = await Wolf3dFlutterEngine( ).init(); ``` -`init()` handles platform setup, audio init, and configured game-data discovery. +`init()` handles platform setup, audio init, configured external discovery, and +optional seeded game injection. + +To load packaged game data from `wolf_3d_assets`, use +`Wolf3dFlutterEngine.loadGameDataFromAssets(...)` and pass the result via +`seededGames`: + +```dart +final retail = await Wolf3dFlutterEngine.loadGameDataFromAssets( + version: GameVersion.retail, + packageName: 'wolf_3d_assets', + assetDirectory: 'assets/retail', +); + +final shareware = await Wolf3dFlutterEngine.loadGameDataFromAssets( + version: GameVersion.shareware, + packageName: 'wolf_3d_assets', + assetDirectory: 'assets/shareware', +); + +final Wolf3dFlutterEngine engine = await Wolf3dFlutterEngine( + debug: kDebugMode, +).init(seededGames: [retail, shareware]); +``` The facade itself lives in `lib/engine/wolf3d_flutter_engine.dart` and is re-exported through the package barrel at `lib/wolf_3d_flutter.dart`. External consumers diff --git a/packages/wolf_3d_flutter/lib/engine/wolf3d_flutter_engine.dart b/packages/wolf_3d_flutter/lib/engine/wolf3d_flutter_engine.dart index 3afde3a..6082339 100644 --- a/packages/wolf_3d_flutter/lib/engine/wolf3d_flutter_engine.dart +++ b/packages/wolf_3d_flutter/lib/engine/wolf3d_flutter_engine.dart @@ -2,6 +2,7 @@ library; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:wolf_3d_dart/wolf_3d_data.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_engine.dart'; @@ -11,6 +12,8 @@ import 'package:wolf_3d_flutter/managers/desktop_windowing_support.dart' as desktop_windowing_support; import 'package:wolf_3d_flutter/managers/game_data_directory_persistence.dart'; +typedef Wolf3dAssetByteLoader = Future Function(String assetKey); + /// Flutter-specific host facade built on top of [Wolf3dEngine]. /// /// This type keeps platform-neutral session and engine state in @@ -51,12 +54,105 @@ class Wolf3dFlutterEngine extends Wolf3dEngine { return this; } + /// Routes shared menu header band diagnostics to [logger]. + /// + /// Pass `null` to disable menu header band diagnostics. + @override + Wolf3dFlutterEngine setMenuHeaderBandDebugLogger( + void Function(String message)? logger, + ) { + super.setMenuHeaderBandDebugLogger(logger); + return this; + } + + /// Enables menu header band diagnostics with an optional [prefix]. + @override + Wolf3dFlutterEngine enableMenuHeaderBandDebugLogging({ + String prefix = '[MENU_HEADER_BAND]', + }) { + super.enableMenuHeaderBandDebugLogging(prefix: prefix); + return this; + } + + /// Disables menu header band diagnostics. + @override + Wolf3dFlutterEngine disableMenuHeaderBandDebugLogging() { + super.disableMenuHeaderBandDebugLogging(); + return this; + } + + /// Loads a game data set from Flutter packaged assets. + /// + /// Intended for loading from dependency asset packages such as + /// `wolf_3d_assets` by passing [packageName] and [assetDirectory], then + /// supplying the result to [init] via [seededGames]. + static Future loadGameDataFromAssets({ + required GameVersion version, + required String assetDirectory, + String? packageName = 'wolf_3d_assets', + AssetRegistry? registryOverride, + Wolf3dAssetByteLoader? assetLoader, + }) async { + final String ext = version.fileExtension; + final Wolf3dAssetByteLoader loader = assetLoader ?? rootBundle.load; + + final String normalizedDirectory = assetDirectory.trim().replaceAll( + RegExp(r'^/+|/+$'), + '', + ); + if (normalizedDirectory.isEmpty) { + throw ArgumentError.value( + assetDirectory, + 'assetDirectory', + 'Must not be empty.', + ); + } + + String keyFor(String fileName) { + final path = '$normalizedDirectory/$fileName'; + final String? normalizedPackage = packageName?.trim(); + if (normalizedPackage == null || normalizedPackage.isEmpty) { + return path; + } + return 'packages/$normalizedPackage/$path'; + } + + Future loadRequired(String fileName) { + return loader(keyFor(fileName)); + } + + Future loadWithFallback( + String primaryName, + String fallbackName, + ) async { + try { + return await loadRequired(primaryName); + } catch (_) { + return loadRequired(fallbackName); + } + } + + return WolfensteinLoader.loadFromBytes( + version: version, + vswap: await loadRequired('VSWAP.$ext'), + mapHead: await loadRequired('MAPHEAD.$ext'), + gameMaps: await loadWithFallback('GAMEMAPS.$ext', 'MAPTEMP.$ext'), + vgaDict: await loadRequired('VGADICT.$ext'), + vgaHead: await loadRequired('VGAHEAD.$ext'), + vgaGraph: await loadRequired('VGAGRAPH.$ext'), + audioHed: await loadRequired('AUDIOHED.$ext'), + audioT: await loadRequired('AUDIOT.$ext'), + registryOverride: registryOverride, + ); + } + /// Initializes the engine by loading available game data. /// /// If [directory] is provided, it is persisted and treated as the primary /// external search root. If omitted, a previously persisted directory is /// used when available. [additionalDirectories] are scanned after the - /// primary directory and are not persisted. + /// primary directory and are not persisted. [seededGames] are merged first, + /// enabling hosts to inject data from packaged assets. /// /// This method scans only configured external directories, deduplicating /// discovered versions by [GameVersion]. Shared package code does not bundle @@ -64,10 +160,12 @@ class Wolf3dFlutterEngine extends Wolf3dEngine { Future init({ String? directory, Iterable? additionalDirectories, + Iterable? seededGames, }) async { await desktop_windowing_support.ensureDesktopWindowingInitialized(); await audio.init(); availableGames.clear(); + _addUniqueGames(seededGames); final String? requestedDirectory = directory?.trim(); final String? resolvedDirectory = @@ -103,14 +201,7 @@ class Wolf3dFlutterEngine extends Wolf3dEngine { directoryPath: directoryPath, recursive: true, ); - for (final MapEntry entry - in externalGames.entries) { - if (!availableGames.any( - (WolfensteinData g) => g.version == entry.key, - )) { - availableGames.add(entry.value); - } - } + _addUniqueGames(externalGames.values); } catch (e) { debugPrint('External discovery failed: $e'); } @@ -119,4 +210,15 @@ class Wolf3dFlutterEngine extends Wolf3dEngine { return this; } + + void _addUniqueGames(Iterable? games) { + if (games == null) { + return; + } + for (final WolfensteinData game in games) { + if (!availableGames.any((g) => g.version == game.version)) { + availableGames.add(game); + } + } + } } diff --git a/packages/wolf_3d_flutter/test/wolf_3d_flutter_debug_mode_test.dart b/packages/wolf_3d_flutter/test/wolf_3d_flutter_debug_mode_test.dart index c3794bd..377b4f6 100644 --- a/packages/wolf_3d_flutter/test/wolf_3d_flutter_debug_mode_test.dart +++ b/packages/wolf_3d_flutter/test/wolf_3d_flutter_debug_mode_test.dart @@ -94,6 +94,57 @@ void main() { }, ); + test('init can seed available games from host-provided data', () async { + final tempDir = await Directory.systemTemp.createTemp( + 'wolf3d-seeded-games-', + ); + addTearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + final persistence = DefaultGameDataDirectoryPersistence( + filePath: '${tempDir.path}/settings.json', + ); + final wolf3d = Wolf3dFlutterEngine( + audioBackend: _NoopAudio(), + dataDirectoryPersistence: persistence, + ); + final retail = _buildTestData(version: GameVersion.retail); + final shareware = _buildTestData(version: GameVersion.shareware); + + await wolf3d.init(seededGames: [retail, shareware]); + + expect(wolf3d.availableGames, hasLength(2)); + expect( + wolf3d.availableGames.map((g) => g.version), + containsAll([GameVersion.retail, GameVersion.shareware]), + ); + }); + + test('loadGameDataFromAssets prefixes keys for package assets', () async { + final requestedKeys = []; + + await expectLater( + () => Wolf3dFlutterEngine.loadGameDataFromAssets( + version: GameVersion.retail, + assetDirectory: 'assets/retail', + packageName: 'wolf_3d_assets', + assetLoader: (String key) async { + requestedKeys.add(key); + throw Exception('Expected test stop after first key.'); + }, + ), + throwsException, + ); + + expect( + requestedKeys, + contains('packages/wolf_3d_assets/assets/retail/VSWAP.WL6'), + ); + }); + testWidgets('GameScreen hides debug FAB when debug mode is disabled', ( tester, ) async { @@ -168,7 +219,9 @@ class _TestWolf3d extends Wolf3dFlutterEngine { } } -WolfensteinData _buildTestData() { +WolfensteinData _buildTestData({ + GameVersion version = GameVersion.retail, +}) { final wallGrid = List.generate(64, (_) => List.filled(64, 0)); final objectGrid = List.generate(64, (_) => List.filled(64, 0)); @@ -181,9 +234,11 @@ WolfensteinData _buildTestData() { objectGrid[2][2] = MapObject.playerEast; return WolfensteinData( - version: GameVersion.retail, + version: version, dataVersion: DataVersion.unknown, - registry: RetailAssetRegistry(), + registry: version == GameVersion.shareware + ? SharewareAssetRegistry() + : RetailAssetRegistry(), walls: [_sprite(1), _sprite(1), _sprite(2), _sprite(2)], sprites: List.generate(436, (_) => _sprite(255)), sounds: List.generate(200, (_) => PcmSound(Uint8List(1))),