Compare commits

...

2 Commits

10 changed files with 361 additions and 14 deletions
+16
View File
@@ -75,6 +75,22 @@ Game data directory selection/persistence is managed by app managers and Flutter
- **No game data discovered**: choose a valid directory in the picker and ensure files are present. - **No game data discovered**: choose a valid directory in the picker and ensure files are present.
- **Linux build/runtime issues**: verify native dependency packages are installed. - **Linux build/runtime issues**: verify native dependency packages are installed.
- **Web target limitations**: use desktop target for full native-audio/path behavior. - **Web target limitations**: use desktop target for full native-audio/path behavior.
- **Web release build wont load when opening `index.html` directly**: Flutter web output must be served over `http://` or `https://`, not opened with `file://`.
Build and serve locally:
```bash
flutter build web --release
python3 -m http.server 8080 -d build/web
```
Then open `http://localhost:8080` in your browser.
If deploying under a subpath, build with matching base href, for example:
```bash
flutter build web --release --base-href /wolf_dart/
```
## Related Modules ## Related Modules
+4 -1
View File
@@ -2,15 +2,18 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart'; import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
import 'packaged_games_loader.dart';
import 'wolf3d_gui_app.dart'; import 'wolf3d_gui_app.dart';
/// Creates the application shell after loading available Wolf3D data sets. /// Creates the application shell after loading available Wolf3D data sets.
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
final seededGames = await loadPackagedGames();
final Wolf3dFlutterEngine wolf3d = await Wolf3dFlutterEngine( final Wolf3dFlutterEngine wolf3d = await Wolf3dFlutterEngine(
debug: kDebugMode, debug: kDebugMode,
).init(); ).init(seededGames: seededGames);
if (kDebugMode) { if (kDebugMode) {
wolf3d.enableMenuHeaderBandDebugLogging(prefix: '[wolf_3d_gui]'); wolf3d.enableMenuHeaderBandDebugLogging(prefix: '[wolf_3d_gui]');
@@ -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<WolfensteinData> Function({
required GameVersion version,
required String assetDirectory,
});
Future<List<WolfensteinData>> loadPackagedGames({
PackagedGameDataLoader? loader,
}) async {
final PackagedGameDataLoader effectiveLoader =
loader ??
({required version, required assetDirectory}) =>
Wolf3dFlutterEngine.loadGameDataFromAssets(
version: version,
packageName: 'wolf_3d_assets',
assetDirectory: assetDirectory,
);
final List<WolfensteinData> games = [];
Future<void> 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;
}
+1
View File
@@ -13,6 +13,7 @@ dependencies:
file_selector: ^1.0.3 file_selector: ^1.0.3
wolf_3d_flutter: wolf_3d_flutter:
wolf_3d_dart: wolf_3d_dart:
wolf_3d_assets:
flutter: flutter:
sdk: flutter sdk: flutter
@@ -53,6 +53,7 @@ class _RecordingEngine extends Wolf3dFlutterEngine {
Future<Wolf3dFlutterEngine> init({ Future<Wolf3dFlutterEngine> init({
String? directory, String? directory,
Iterable<String>? additionalDirectories, Iterable<String>? additionalDirectories,
Iterable<WolfensteinData>? seededGames,
}) async { }) async {
initCallCount++; initCallCount++;
lastDirectory = directory; lastDirectory = directory;
@@ -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>[
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>[
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>[_sprite(1), _sprite(1), _sprite(2), _sprite(2)],
sprites: List<Sprite>.generate(436, (_) => _sprite(255)),
sounds: List<PcmSound>.generate(200, (_) => PcmSound(Uint8List(1))),
adLibSounds: const <PcmSound>[],
music: const <ImfMusic>[],
vgaImages: const <VgaImage>[],
episodes: <Episode>[
Episode(
name: 'Test Episode',
levels: <WolfLevel>[
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<int>.filled(64 * 64, color)));
@@ -88,6 +88,7 @@ class _RecordingEngine extends Wolf3dFlutterEngine {
Future<Wolf3dFlutterEngine> init({ Future<Wolf3dFlutterEngine> init({
String? directory, String? directory,
Iterable<String>? additionalDirectories, Iterable<String>? additionalDirectories,
Iterable<WolfensteinData>? seededGames,
}) async { }) async {
initCallCount++; initCallCount++;
availableGames availableGames
+24 -1
View File
@@ -39,7 +39,30 @@ final Wolf3dFlutterEngine engine = await Wolf3dFlutterEngine(
).init(); ).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 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 through the package barrel at `lib/wolf_3d_flutter.dart`. External consumers
@@ -2,6 +2,7 @@
library; library;
import 'package:flutter/foundation.dart'; 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.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.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; as desktop_windowing_support;
import 'package:wolf_3d_flutter/managers/game_data_directory_persistence.dart'; import 'package:wolf_3d_flutter/managers/game_data_directory_persistence.dart';
typedef Wolf3dAssetByteLoader = Future<ByteData> Function(String assetKey);
/// Flutter-specific host facade built on top of [Wolf3dEngine]. /// Flutter-specific host facade built on top of [Wolf3dEngine].
/// ///
/// This type keeps platform-neutral session and engine state in /// This type keeps platform-neutral session and engine state in
@@ -51,12 +54,105 @@ class Wolf3dFlutterEngine extends Wolf3dEngine {
return this; 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<WolfensteinData> 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<ByteData> loadRequired(String fileName) {
return loader(keyFor(fileName));
}
Future<ByteData> 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. /// Initializes the engine by loading available game data.
/// ///
/// If [directory] is provided, it is persisted and treated as the primary /// If [directory] is provided, it is persisted and treated as the primary
/// external search root. If omitted, a previously persisted directory is /// external search root. If omitted, a previously persisted directory is
/// used when available. [additionalDirectories] are scanned after the /// 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 /// This method scans only configured external directories, deduplicating
/// discovered versions by [GameVersion]. Shared package code does not bundle /// discovered versions by [GameVersion]. Shared package code does not bundle
@@ -64,10 +160,12 @@ class Wolf3dFlutterEngine extends Wolf3dEngine {
Future<Wolf3dFlutterEngine> init({ Future<Wolf3dFlutterEngine> init({
String? directory, String? directory,
Iterable<String>? additionalDirectories, Iterable<String>? additionalDirectories,
Iterable<WolfensteinData>? seededGames,
}) async { }) async {
await desktop_windowing_support.ensureDesktopWindowingInitialized(); await desktop_windowing_support.ensureDesktopWindowingInitialized();
await audio.init(); await audio.init();
availableGames.clear(); availableGames.clear();
_addUniqueGames(seededGames);
final String? requestedDirectory = directory?.trim(); final String? requestedDirectory = directory?.trim();
final String? resolvedDirectory = final String? resolvedDirectory =
@@ -103,14 +201,7 @@ class Wolf3dFlutterEngine extends Wolf3dEngine {
directoryPath: directoryPath, directoryPath: directoryPath,
recursive: true, recursive: true,
); );
for (final MapEntry<GameVersion, WolfensteinData> entry _addUniqueGames(externalGames.values);
in externalGames.entries) {
if (!availableGames.any(
(WolfensteinData g) => g.version == entry.key,
)) {
availableGames.add(entry.value);
}
}
} catch (e) { } catch (e) {
debugPrint('External discovery failed: $e'); debugPrint('External discovery failed: $e');
} }
@@ -119,4 +210,15 @@ class Wolf3dFlutterEngine extends Wolf3dEngine {
return this; return this;
} }
void _addUniqueGames(Iterable<WolfensteinData>? games) {
if (games == null) {
return;
}
for (final WolfensteinData game in games) {
if (!availableGames.any((g) => g.version == game.version)) {
availableGames.add(game);
}
}
}
} }
@@ -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 = <String>[];
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', ( testWidgets('GameScreen hides debug FAB when debug mode is disabled', (
tester, tester,
) async { ) 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 wallGrid = List.generate(64, (_) => List.filled(64, 0));
final objectGrid = 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; objectGrid[2][2] = MapObject.playerEast;
return WolfensteinData( return WolfensteinData(
version: GameVersion.retail, version: version,
dataVersion: DataVersion.unknown, dataVersion: DataVersion.unknown,
registry: RetailAssetRegistry(), registry: version == GameVersion.shareware
? SharewareAssetRegistry()
: RetailAssetRegistry(),
walls: [_sprite(1), _sprite(1), _sprite(2), _sprite(2)], walls: [_sprite(1), _sprite(1), _sprite(2), _sprite(2)],
sprites: List.generate(436, (_) => _sprite(255)), sprites: List.generate(436, (_) => _sprite(255)),
sounds: List.generate(200, (_) => PcmSound(Uint8List(1))), sounds: List.generate(200, (_) => PcmSound(Uint8List(1))),