feat: Enhance CLI and GUI to support configurable game data directory persistence

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-23 19:19:50 +01:00
parent 5ef59d9980
commit 569a3386a8
14 changed files with 402 additions and 66 deletions
+54 -15
View File
@@ -6,6 +6,7 @@ library;
import 'dart:io';
import 'package:args/args.dart';
import 'package:wolf_3d_cli/cli_game_loop.dart';
import 'package:wolf_3d_dart/wolf_3d_audio.dart';
import 'package:wolf_3d_dart/wolf_3d_data.dart';
@@ -21,28 +22,66 @@ void exitCleanly(int code) {
exit(code);
}
/// Launches the CLI renderer against the bundled retail asset set.
void main() async {
stdout.write("Discovering game data...");
// Resolve the asset package relative to this executable so the CLI can run
// from the repo without additional configuration.
final scriptUri = Platform.script;
/// Launches the CLI renderer using discoverable or user-provided game data.
void main(List<String> arguments) async {
final argParser = ArgParser()
..addOption(
'data-directory',
abbr: 'd',
valueHelp: 'path',
help: 'Directory containing Wolf3D data files.',
)
..addFlag(
'help',
abbr: 'h',
negatable: false,
help: 'Show usage information.',
);
final targetUri = scriptUri.resolve(
'../../../packages/wolf_3d_assets/assets/retail',
);
final targetPath = targetUri.toFilePath();
late final ArgResults parsedArgs;
try {
parsedArgs = argParser.parse(arguments);
} on FormatException catch (e) {
stderr.writeln(e.message);
stderr.writeln('Usage: wolf_3d_cli [options]');
stderr.writeln(argParser.usage);
exitCleanly(64);
}
if (parsedArgs.flag('help')) {
stdout.writeln('Usage: wolf_3d_cli [options]');
stdout.writeln(argParser.usage);
exitCleanly(0);
}
final String? rawPath = parsedArgs.option('data-directory');
final String? dataDirectory = rawPath != null && rawPath.trim().isNotEmpty
? rawPath.trim()
: null;
if (dataDirectory != null) {
stdout.write('Discovering game data in "$dataDirectory"...');
} else {
stdout.write('Discovering game data...');
}
final availableGames = await WolfensteinLoader.discover(
directoryPath: targetPath,
directoryPath: dataDirectory,
recursive: true,
);
if (availableGames.isEmpty) {
stderr.writeln('\nNo Wolf3D game files were found at: $targetPath');
stderr.writeln(
'Please provide valid game data files before starting the CLI host.',
);
if (dataDirectory == null) {
stderr.writeln('\nNo Wolf3D game data was discovered.');
stderr.writeln('Provide a game-data directory with one of these flags:');
stderr.writeln(' --data-directory <path>');
stderr.writeln(' -d <path>');
} else {
stderr.writeln('\nNo Wolf3D game files were found at: $dataDirectory');
stderr.writeln(
'Please provide valid game data files before starting the CLI host.',
);
}
exitCleanly(1);
}
+1 -1
View File
@@ -8,5 +8,5 @@ environment:
resolution: workspace
dependencies:
args: ^2.7.0
wolf_3d_dart:
wolf_3d_assets:
+3 -8
View File
@@ -7,24 +7,19 @@ library;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
/// Creates the application shell after loading available Wolf3D data sets.
void main() async {
WidgetsFlutterBinding.ensureInitialized();
if (supportsDesktopWindowing) {
await windowManager.ensureInitialized();
}
final Wolf3dFlutterEngine wolf3d = await Wolf3dFlutterEngine().init(
final Wolf3dFlutterEngine wolf3d = await Wolf3dFlutterEngine(
debug: kDebugMode,
);
).init();
runApp(
MaterialApp(
home: Wolf3dApp(wolf3d: wolf3d),
home: Wolf3dApp(engine: wolf3d),
),
);
}
+1 -3
View File
@@ -9,9 +9,7 @@ environment:
resolution: workspace
dependencies:
wolf_3d_dart:
wolf_3d_flutter: any
window_manager: ^0.5.1
wolf_3d_flutter:
flutter:
sdk: flutter
@@ -1,6 +1,8 @@
library;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:window_manager/window_manager.dart';
/// Whether desktop window-management APIs are expected to work on this host.
bool get supportsDesktopWindowing {
@@ -15,3 +17,21 @@ bool get supportsDesktopWindowing {
_ => false,
};
}
/// Ensures desktop windowing plugin state is initialized when supported.
///
/// This safely no-ops on non-desktop targets and in hosts where the plugin is
/// not registered.
Future<void> ensureDesktopWindowingInitialized({
WindowManager? windowing,
}) async {
if (!supportsDesktopWindowing) {
return;
}
try {
await (windowing ?? windowManager).ensureInitialized();
} on MissingPluginException {
// No-op on hosts where the window manager plugin is unavailable.
}
}
@@ -0,0 +1,4 @@
library;
export 'game_data_directory_persistence_stub.dart'
if (dart.library.io) 'game_data_directory_persistence_io.dart';
@@ -0,0 +1,99 @@
library;
import 'dart:convert';
import 'dart:io';
/// Persists the configured game-data directory path as JSON.
class DefaultGameDataDirectoryPersistence {
DefaultGameDataDirectoryPersistence({String? filePath})
: _filePath = filePath;
final String? _filePath;
String? _resolvedPath;
/// Absolute JSON file path used for persistence.
String get filePath {
if (_resolvedPath != null) {
return _resolvedPath!;
}
if (_filePath != null && _filePath.isNotEmpty) {
_resolvedPath = _filePath;
return _resolvedPath!;
}
_resolvedPath = '$platformConfigDir/data_source.json';
return _resolvedPath!;
}
/// Loads a previously persisted data-directory path.
Future<String?> loadDataDirectory() async {
try {
final file = File(filePath);
if (!await file.exists()) {
return null;
}
final raw = await file.readAsString();
if (raw.isEmpty) {
return null;
}
final decoded = jsonDecode(raw);
if (decoded is! Map<String, Object?>) {
return null;
}
final Object? value = decoded['dataDirectory'];
if (value is! String) {
return null;
}
final normalized = value.trim();
return normalized.isEmpty ? null : normalized;
} catch (_) {
return null;
}
}
/// Persists [directoryPath] for future startups.
Future<void> saveDataDirectory(String? directoryPath) async {
try {
final normalized = directoryPath?.trim();
final file = File(filePath);
await file.parent.create(recursive: true);
if (normalized == null || normalized.isEmpty) {
if (await file.exists()) {
await file.delete();
}
return;
}
final payload = jsonEncode(<String, Object>{
'dataDirectory': normalized,
});
await file.writeAsString(payload, flush: true);
} catch (_) {
// Best-effort only.
}
}
String get platformConfigDir {
if (Platform.isLinux) {
final String xdg = Platform.environment['XDG_CONFIG_HOME'] ?? '';
final String home = Platform.environment['HOME'] ?? '.';
return xdg.isNotEmpty ? '$xdg/wolf3d' : '$home/.config/wolf3d';
}
if (Platform.isMacOS) {
final String home = Platform.environment['HOME'] ?? '.';
return '$home/Library/Application Support/wolf3d';
}
if (Platform.isWindows) {
final String appData = Platform.environment['APPDATA'] ?? '.';
return '$appData/wolf3d';
}
final String home = Platform.environment['HOME'] ?? '.';
return '$home/.config/wolf3d';
}
}
@@ -0,0 +1,14 @@
library;
/// No-op persistence used on platforms without `dart:io` support.
class DefaultGameDataDirectoryPersistence {
DefaultGameDataDirectoryPersistence({String? filePath});
/// Loads a previously persisted data-directory path.
Future<String?> loadDataDirectory() async {
return null;
}
/// Persists [directoryPath] for future startups.
Future<void> saveDataDirectory(String? directoryPath) async {}
}
@@ -1,15 +1,42 @@
library;
import 'package:flutter/material.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
/// Fallback screen shown when no Wolf3D game data files are discovered.
class NoGameDataScreen extends StatelessWidget {
const NoGameDataScreen({super.key});
const NoGameDataScreen({
super.key,
this.configuredDataDirectory,
});
/// Previously configured external game-data directory, if any.
final String? configuredDataDirectory;
static Color _colorFromVgaIndex(int index) {
final int packed = ColorPalette.vga32Bit[index]; // 0xAABBGGRR
final int r = packed & 0xFF;
final int g = (packed >> 8) & 0xFF;
final int b = (packed >> 16) & 0xFF;
return Color((0xFF << 24) | (r << 16) | (g << 8) | b);
}
static final Color _backgroundColor = _colorFromVgaIndex(111);
static final Color _panelColor = _colorFromVgaIndex(103);
static final Color _borderColor = _colorFromVgaIndex(87);
static final Color _titleColor = _colorFromVgaIndex(
WolfMenuPalette.headerTextIndex,
);
static final Color _bodyColor = _colorFromVgaIndex(
WolfMenuPalette.unselectedTextIndex,
);
static final Color _emphasisColor = _colorFromVgaIndex(10);
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF140000),
backgroundColor: _backgroundColor,
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
@@ -17,10 +44,10 @@ class NoGameDataScreen extends StatelessWidget {
constraints: const BoxConstraints(maxWidth: 640),
child: DecoratedBox(
decoration: BoxDecoration(
color: const Color(0xFF590002),
border: Border.all(color: const Color(0xFFB00000), width: 2),
color: _panelColor,
border: Border.all(color: _borderColor, width: 2),
),
child: const Padding(
child: Padding(
padding: EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -29,25 +56,44 @@ class NoGameDataScreen extends StatelessWidget {
Text(
'WOLF3D DATA NOT FOUND',
style: TextStyle(
color: Color(0xFFFFF700),
color: _titleColor,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 16),
const SizedBox(height: 16),
Text(
'No game files were discovered.\n\n'
'Add Wolfenstein 3D data files to one of these locations:\n'
'- packages/wolf_3d_assets/assets/retail\n'
'- packages/wolf_3d_assets/assets/shareware\n'
'- or a discoverable local game-data folder.\n\n'
'Restart the app after adding the files.',
'Select a game-data directory in setup.',
style: TextStyle(
color: Colors.white,
color: _bodyColor,
fontSize: 15,
height: 1.4,
),
),
const SizedBox(height: 12),
Text(
'Once all required files are present, the game starts automatically.',
style: TextStyle(
color: _emphasisColor,
fontSize: 14,
height: 1.35,
fontWeight: FontWeight.w600,
),
),
if (configuredDataDirectory != null &&
configuredDataDirectory!.trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
'Configured data directory: ${configuredDataDirectory!.trim()}',
style: TextStyle(
color: _bodyColor,
fontSize: 13,
height: 1.3,
),
),
),
],
),
),
@@ -7,17 +7,19 @@ import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
/// host screens.
class Wolf3dApp extends StatelessWidget {
/// Shared initialized facade that owns game data, input, and audio services.
final Wolf3dFlutterEngine wolf3d;
final Wolf3dFlutterEngine engine;
const Wolf3dApp({
super.key,
required this.wolf3d,
required this.engine,
});
@override
Widget build(BuildContext context) {
return wolf3d.availableGames.isEmpty
? const NoGameDataScreen()
: GameScreen(wolf3d: wolf3d);
return engine.availableGames.isEmpty
? NoGameDataScreen(
configuredDataDirectory: engine.configuredDataDirectory,
)
: GameScreen(wolf3d: engine);
}
}
@@ -7,13 +7,17 @@ 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';
import 'package:wolf_3d_flutter/audio/wolf3d_platform_audio.dart';
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';
import 'package:wolf_3d_flutter/wolf_3d_input_flutter.dart';
export 'package:wolf_3d_dart/wolf_3d_audio.dart' show DebugMusicPlayer;
export 'audio/wolf3d_platform_audio.dart' show Wolf3dPlatformAudio;
export 'managers/desktop_windowing_support.dart' show supportsDesktopWindowing;
export 'managers/game_app_lifecycle_manager.dart' show GameAppLifecycleManager;
export 'managers/game_data_directory_persistence.dart'
show DefaultGameDataDirectoryPersistence;
export 'managers/game_display_manager.dart' show GameDisplayManager;
export 'managers/game_persistence_manager.dart' show GamePersistenceManager;
export 'managers/game_renderer_mode_manager.dart'
@@ -44,12 +48,26 @@ export 'widgets/wolf_menu_shell.dart' show WolfMenuShell;
class Wolf3dFlutterEngine extends Wolf3dEngine {
/// Creates an empty facade that must be initialized with [init].
Wolf3dFlutterEngine({
bool debug = false,
EngineAudio? audioBackend,
Wolf3dFlutterInput? inputBackend,
}) : super(
DefaultGameDataDirectoryPersistence? dataDirectoryPersistence,
}) : dataDirectoryPersistence =
dataDirectoryPersistence ?? DefaultGameDataDirectoryPersistence(),
super(
audio: audioBackend ?? Wolf3dPlatformAudio(),
input: inputBackend ?? Wolf3dFlutterInput(),
);
) {
if (debug) {
enableDebug();
}
}
/// Persists and restores the preferred external game-data directory.
final DefaultGameDataDirectoryPersistence dataDirectoryPersistence;
/// Last configured/loaded external game-data directory path.
String? configuredDataDirectory;
/// Shared Flutter input adapter reused by gameplay screens.
@override
@@ -63,18 +81,24 @@ class Wolf3dFlutterEngine extends Wolf3dEngine {
}
/// Initializes the engine by loading available game data.
///
/// Set [debug] to `true` to explicitly enable host-level debug affordances.
Future<Wolf3dFlutterEngine> init({
String? directory,
bool debug = false,
}) async {
if (debug) {
enableDebug();
}
await desktop_windowing_support.ensureDesktopWindowingInitialized();
await audio.init();
availableGames.clear();
final String? requestedDirectory = directory?.trim();
final String? resolvedDirectory =
requestedDirectory != null && requestedDirectory.isNotEmpty
? requestedDirectory
: await dataDirectoryPersistence.loadDataDirectory();
configuredDataDirectory = resolvedDirectory;
if (requestedDirectory != null && requestedDirectory.isNotEmpty) {
await dataDirectoryPersistence.saveDataDirectory(requestedDirectory);
}
// Bundled assets let the GUI work out of the box on supported platforms.
final versionsToTry = [
(version: GameVersion.retail, path: 'retail'),
@@ -106,10 +130,10 @@ class Wolf3dFlutterEngine extends Wolf3dEngine {
// On non-web platforms, also scan the local filesystem for user-supplied
// data folders so the host can pick up extra versions automatically.
if (!kIsWeb) {
if (!kIsWeb && resolvedDirectory != null) {
try {
final externalGames = await WolfensteinLoader.discover(
directoryPath: directory,
directoryPath: resolvedDirectory,
recursive: true,
);
for (var entry in externalGames.entries) {
@@ -135,9 +159,3 @@ class Wolf3dFlutterEngine extends Wolf3dEngine {
}
}
}
/// Backward-compatible alias for the previous Flutter host facade name.
typedef Wolf3dFlutter = Wolf3dFlutterEngine;
/// Backward-compatible alias for the legacy Flutter host facade name.
typedef Wolf3d = Wolf3dFlutterEngine;
-1
View File
@@ -13,7 +13,6 @@ dependencies:
wolf_3d_dart:
flutter:
sdk: flutter
wolf_3d_assets: any
audioplayers: ^6.6.0
window_manager: ^0.5.1
@@ -0,0 +1,99 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
class _NoopAudio implements EngineAudio {
@override
WolfensteinData? activeGame;
@override
Future<void> debugSoundTest() async {}
@override
Future<void> init() async {}
@override
void playLevelMusic(Music music) {}
@override
void playMenuMusic() {}
@override
void playSoundEffect(SoundEffect effect) {}
@override
void playSoundEffectId(int sfxId) {}
@override
Future<void> stopAllAudio() async {}
@override
void stopMusic() {}
@override
void dispose() {}
}
void main() {
group('DefaultGameDataDirectoryPersistence', () {
test('saves and loads configured directory', () async {
final tempDir = await Directory.systemTemp.createTemp(
'wolf3d-data-config-',
);
addTearDown(() async {
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
});
final persistence = DefaultGameDataDirectoryPersistence(
filePath: '${tempDir.path}/data_source.json',
);
await persistence.saveDataDirectory('/tmp/wolf-data');
final loaded = await persistence.loadDataDirectory();
expect(loaded, '/tmp/wolf-data');
});
test('clears persisted path when saving null', () async {
final tempDir = await Directory.systemTemp.createTemp(
'wolf3d-data-config-',
);
addTearDown(() async {
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
});
final path = '${tempDir.path}/data_source.json';
final persistence = DefaultGameDataDirectoryPersistence(filePath: path);
await persistence.saveDataDirectory('/tmp/wolf-data');
await persistence.saveDataDirectory(null);
expect(await File(path).exists(), isFalse);
expect(await persistence.loadDataDirectory(), isNull);
});
});
testWidgets('Wolf3dApp forwards configured directory to no-data screen', (
tester,
) async {
final wolf3d = Wolf3dFlutterEngine(audioBackend: _NoopAudio())
..configuredDataDirectory = '/tmp/wolf-data';
await tester.pumpWidget(
MaterialApp(
home: Wolf3dApp(engine: wolf3d),
),
);
expect(find.textContaining('Configured data directory:'), findsOneWidget);
expect(find.textContaining('/tmp/wolf-data'), findsOneWidget);
});
}
@@ -55,10 +55,13 @@ void main() {
expect(wolf3d.isDebugEnabled, isTrue);
});
test('init(debug: true) enables debug mode', () async {
final wolf3d = Wolf3dFlutterEngine(audioBackend: _NoopAudio());
test('constructor(debug: true) enables debug mode', () async {
final wolf3d = Wolf3dFlutterEngine(
audioBackend: _NoopAudio(),
debug: true,
);
await wolf3d.init(debug: true);
await wolf3d.init();
expect(wolf3d.isDebugEnabled, isTrue);
});