From 70b4fc3fe07fea7d034d66b3f66c74af4ef662e0 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 23 Mar 2026 19:30:50 +0100 Subject: [PATCH] feat: Add file selector support and enhance game data directory management Signed-off-by: Hans Kokx --- apps/wolf_3d_gui/lib/main.dart | 7 - .../flutter/generated_plugin_registrant.cc | 4 + .../linux/flutter/generated_plugins.cmake | 1 + .../game_data_directory_persistence_io.dart | 30 ++++- .../lib/screens/no_game_data_screen.dart | 70 +++++++++- .../lib/widgets/wolf3d_app.dart | 123 +++++++++++++++++- .../wolf_3d_flutter/lib/wolf_3d_flutter.dart | 45 +++++-- packages/wolf_3d_flutter/pubspec.yaml | 1 + .../game_data_directory_persistence_test.dart | 37 +++++- 9 files changed, 284 insertions(+), 34 deletions(-) diff --git a/apps/wolf_3d_gui/lib/main.dart b/apps/wolf_3d_gui/lib/main.dart index 2b40a33..ef2a633 100644 --- a/apps/wolf_3d_gui/lib/main.dart +++ b/apps/wolf_3d_gui/lib/main.dart @@ -1,10 +1,3 @@ -/// Flutter entry point for the GUI host application. -/// -/// The GUI bootstraps bundled and discoverable game data through -/// [Wolf3dFlutterEngine] -/// before presenting the game-selection flow. -library; - import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:wolf_3d_flutter/wolf_3d_flutter.dart'; diff --git a/apps/wolf_3d_gui/linux/flutter/generated_plugin_registrant.cc b/apps/wolf_3d_gui/linux/flutter/generated_plugin_registrant.cc index d7f5477..b1d8f58 100644 --- a/apps/wolf_3d_gui/linux/flutter/generated_plugin_registrant.cc +++ b/apps/wolf_3d_gui/linux/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include @@ -14,6 +15,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); diff --git a/apps/wolf_3d_gui/linux/flutter/generated_plugins.cmake b/apps/wolf_3d_gui/linux/flutter/generated_plugins.cmake index de49b37..fd8b5c7 100644 --- a/apps/wolf_3d_gui/linux/flutter/generated_plugins.cmake +++ b/apps/wolf_3d_gui/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_linux + file_selector_linux screen_retriever_linux window_manager ) diff --git a/packages/wolf_3d_flutter/lib/managers/game_data_directory_persistence_io.dart b/packages/wolf_3d_flutter/lib/managers/game_data_directory_persistence_io.dart index cadcbc0..2da20ec 100644 --- a/packages/wolf_3d_flutter/lib/managers/game_data_directory_persistence_io.dart +++ b/packages/wolf_3d_flutter/lib/managers/game_data_directory_persistence_io.dart @@ -22,7 +22,7 @@ class DefaultGameDataDirectoryPersistence { return _resolvedPath!; } - _resolvedPath = '$platformConfigDir/data_source.json'; + _resolvedPath = '$platformConfigDir/settings.json'; return _resolvedPath!; } @@ -61,18 +61,36 @@ class DefaultGameDataDirectoryPersistence { try { final normalized = directoryPath?.trim(); final file = File(filePath); + Map existingPayload = {}; + + if (await file.exists()) { + try { + final String raw = await file.readAsString(); + final Object? decoded = jsonDecode(raw); + if (decoded is Map) { + existingPayload = decoded; + } + } catch (_) { + // Ignore malformed existing content. + } + } await file.parent.create(recursive: true); if (normalized == null || normalized.isEmpty) { - if (await file.exists()) { - await file.delete(); + existingPayload.remove('dataDirectory'); + if (existingPayload.isEmpty) { + if (await file.exists()) { + await file.delete(); + } + return; } + final payload = jsonEncode(existingPayload); + await file.writeAsString(payload, flush: true); return; } - final payload = jsonEncode({ - 'dataDirectory': normalized, - }); + existingPayload['dataDirectory'] = normalized; + final payload = jsonEncode(existingPayload); await file.writeAsString(payload, flush: true); } catch (_) { // Best-effort only. diff --git a/packages/wolf_3d_flutter/lib/screens/no_game_data_screen.dart b/packages/wolf_3d_flutter/lib/screens/no_game_data_screen.dart index 8c53497..399d1b6 100644 --- a/packages/wolf_3d_flutter/lib/screens/no_game_data_screen.dart +++ b/packages/wolf_3d_flutter/lib/screens/no_game_data_screen.dart @@ -9,11 +9,27 @@ class NoGameDataScreen extends StatelessWidget { const NoGameDataScreen({ super.key, this.configuredDataDirectory, + this.onPickGameDataDirectory, + this.onPickGameDataFiles, + this.isLoadingGameData = false, + this.pickerError, }); /// Previously configured external game-data directory, if any. final String? configuredDataDirectory; + /// Invoked when the user requests selecting a game-data directory. + final Future Function()? onPickGameDataDirectory; + + /// Invoked when the user requests selecting one or more data files. + final Future Function()? onPickGameDataFiles; + + /// Whether the host is currently reloading after picker selection. + final bool isLoadingGameData; + + /// Optional picker/reload error shown to the user. + final String? pickerError; + static Color _colorFromVgaIndex(int index) { final int packed = ColorPalette.vga32Bit[index]; // 0xAABBGGRR final int r = packed & 0xFF; @@ -64,7 +80,7 @@ class NoGameDataScreen extends StatelessWidget { const SizedBox(height: 16), Text( 'No game files were discovered.\n\n' - 'Select a game-data directory in setup.', + 'Select a game-data directory, or select one or more game-data files.', style: TextStyle( color: _bodyColor, fontSize: 15, @@ -81,6 +97,58 @@ class NoGameDataScreen extends StatelessWidget { fontWeight: FontWeight.w600, ), ), + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ElevatedButton( + onPressed: isLoadingGameData + ? null + : onPickGameDataDirectory, + child: Text( + isLoadingGameData + ? 'Loading data...' + : 'Select data directory', + ), + ), + ElevatedButton( + onPressed: isLoadingGameData + ? null + : onPickGameDataFiles, + child: Text( + isLoadingGameData + ? 'Loading data...' + : 'Select data files', + ), + ), + ], + ), + if (isLoadingGameData) + Padding( + padding: const EdgeInsets.only(top: 10), + child: Text( + 'Scanning selected locations...', + style: TextStyle( + color: _bodyColor, + fontSize: 13, + height: 1.3, + ), + ), + ), + if (pickerError != null && pickerError!.trim().isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + pickerError!.trim(), + style: TextStyle( + color: _emphasisColor, + fontSize: 13, + height: 1.3, + fontWeight: FontWeight.w600, + ), + ), + ), if (configuredDataDirectory != null && configuredDataDirectory!.trim().isNotEmpty) Padding( diff --git a/packages/wolf_3d_flutter/lib/widgets/wolf3d_app.dart b/packages/wolf_3d_flutter/lib/widgets/wolf3d_app.dart index aed5398..6f2baca 100644 --- a/packages/wolf_3d_flutter/lib/widgets/wolf3d_app.dart +++ b/packages/wolf_3d_flutter/lib/widgets/wolf3d_app.dart @@ -1,11 +1,14 @@ library; +import 'dart:collection'; + +import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:wolf_3d_flutter/wolf_3d_flutter.dart'; /// Minimal app shell that binds a prepared [Wolf3dFlutterEngine] instance to /// host screens. -class Wolf3dApp extends StatelessWidget { +class Wolf3dApp extends StatefulWidget { /// Shared initialized facade that owns game data, input, and audio services. final Wolf3dFlutterEngine engine; @@ -14,12 +17,124 @@ class Wolf3dApp extends StatelessWidget { required this.engine, }); + @override + State createState() => _Wolf3dAppState(); +} + +class _Wolf3dAppState extends State { + bool _isLoadingGameData = false; + String? _pickerError; + + Future _pickGameDataDirectory() async { + setState(() { + _isLoadingGameData = true; + _pickerError = null; + }); + + try { + final String? directoryPath = await getDirectoryPath( + confirmButtonText: 'Use this folder', + ); + + if (directoryPath == null || directoryPath.trim().isEmpty) { + return; + } + + await widget.engine.init(directory: directoryPath.trim()); + } catch (error) { + setState(() { + _pickerError = 'Unable to load selected directory: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoadingGameData = false; + }); + } + } + } + + Future _pickGameDataFiles() async { + setState(() { + _isLoadingGameData = true; + _pickerError = null; + }); + + try { + final List selectedFiles = await openFiles(); + + if (selectedFiles.isEmpty) { + return; + } + + final LinkedHashSet directories = LinkedHashSet(); + for (final XFile file in selectedFiles) { + final String directory = _directoryFromFilePath(file.path); + if (directory.isNotEmpty) { + directories.add(directory); + } + } + + if (directories.isEmpty) { + setState(() { + _pickerError = 'Selected files do not expose local filesystem paths.'; + }); + return; + } + + final List orderedDirectories = directories.toList( + growable: false, + ); + await widget.engine.init( + directory: orderedDirectories.first, + additionalDirectories: orderedDirectories.skip(1), + ); + } catch (error) { + setState(() { + _pickerError = 'Unable to load selected files: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoadingGameData = false; + }); + } + } + } + + String _directoryFromFilePath(String path) { + final String trimmedPath = path.trim(); + if (trimmedPath.isEmpty) { + return ''; + } + + final int slashIndex = trimmedPath.lastIndexOf('/'); + final int backslashIndex = trimmedPath.lastIndexOf(r'\'); + final int separatorIndex = slashIndex > backslashIndex + ? slashIndex + : backslashIndex; + + if (separatorIndex < 0) { + return ''; + } + + if (separatorIndex == 0) { + return trimmedPath[0]; + } + + return trimmedPath.substring(0, separatorIndex); + } + @override Widget build(BuildContext context) { - return engine.availableGames.isEmpty + return widget.engine.availableGames.isEmpty ? NoGameDataScreen( - configuredDataDirectory: engine.configuredDataDirectory, + configuredDataDirectory: widget.engine.configuredDataDirectory, + onPickGameDataDirectory: _pickGameDataDirectory, + onPickGameDataFiles: _pickGameDataFiles, + isLoadingGameData: _isLoadingGameData, + pickerError: _pickerError, ) - : GameScreen(wolf3d: engine); + : GameScreen(wolf3d: widget.engine); } } diff --git a/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart b/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart index c2e1307..500a25f 100644 --- a/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart +++ b/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart @@ -83,6 +83,7 @@ class Wolf3dFlutterEngine extends Wolf3dEngine { /// Initializes the engine by loading available game data. Future init({ String? directory, + Iterable? additionalDirectories, }) async { await desktop_windowing_support.ensureDesktopWindowingInitialized(); await audio.init(); @@ -128,21 +129,37 @@ 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 && resolvedDirectory != null) { - try { - final externalGames = await WolfensteinLoader.discover( - directoryPath: resolvedDirectory, - recursive: true, - ); - for (var entry in externalGames.entries) { - if (!availableGames.any((g) => g.version == entry.key)) { - availableGames.add(entry.value); - } + // On non-web platforms, also scan local filesystem locations for + // user-supplied data folders so the host can pick up extra versions. + final Set directoriesToScan = {}; + if (resolvedDirectory != null && resolvedDirectory.isNotEmpty) { + directoriesToScan.add(resolvedDirectory); + } + + if (additionalDirectories != null) { + for (final String directoryPath in additionalDirectories) { + final String trimmedPath = directoryPath.trim(); + if (trimmedPath.isNotEmpty) { + directoriesToScan.add(trimmedPath); + } + } + } + + if (!kIsWeb && directoriesToScan.isNotEmpty) { + for (final String directoryPath in directoriesToScan) { + try { + final externalGames = await WolfensteinLoader.discover( + directoryPath: directoryPath, + recursive: true, + ); + for (var entry in externalGames.entries) { + if (!availableGames.any((g) => g.version == entry.key)) { + availableGames.add(entry.value); + } + } + } catch (e) { + debugPrint("External discovery failed: $e"); } - } catch (e) { - debugPrint("External discovery failed: $e"); } } diff --git a/packages/wolf_3d_flutter/pubspec.yaml b/packages/wolf_3d_flutter/pubspec.yaml index 9a1da9b..56253fb 100644 --- a/packages/wolf_3d_flutter/pubspec.yaml +++ b/packages/wolf_3d_flutter/pubspec.yaml @@ -14,6 +14,7 @@ dependencies: flutter: sdk: flutter audioplayers: ^6.6.0 + file_selector: ^1.0.3 window_manager: ^0.5.1 dev_dependencies: diff --git a/packages/wolf_3d_flutter/test/game_data_directory_persistence_test.dart b/packages/wolf_3d_flutter/test/game_data_directory_persistence_test.dart index 56f345f..5aa036e 100644 --- a/packages/wolf_3d_flutter/test/game_data_directory_persistence_test.dart +++ b/packages/wolf_3d_flutter/test/game_data_directory_persistence_test.dart @@ -51,7 +51,7 @@ void main() { }); final persistence = DefaultGameDataDirectoryPersistence( - filePath: '${tempDir.path}/data_source.json', + filePath: '${tempDir.path}/settings.json', ); await persistence.saveDataDirectory('/tmp/wolf-data'); @@ -70,7 +70,7 @@ void main() { } }); - final path = '${tempDir.path}/data_source.json'; + final path = '${tempDir.path}/settings.json'; final persistence = DefaultGameDataDirectoryPersistence(filePath: path); await persistence.saveDataDirectory('/tmp/wolf-data'); @@ -79,6 +79,39 @@ void main() { expect(await File(path).exists(), isFalse); expect(await persistence.loadDataDirectory(), isNull); }); + + test('preserves unrelated settings when updating data directory', () 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}/settings.json'; + final file = File(path); + await file.parent.create(recursive: true); + await file.writeAsString( + '{"renderScale":2.0,"preferSoftwareRenderer":false}', + ); + + final persistence = DefaultGameDataDirectoryPersistence(filePath: path); + + await persistence.saveDataDirectory('/tmp/wolf-data'); + + final updated = await file.readAsString(); + expect(updated, contains('"renderScale":2.0')); + expect(updated, contains('"preferSoftwareRenderer":false')); + expect(updated, contains('"dataDirectory":"/tmp/wolf-data"')); + + await persistence.saveDataDirectory(null); + final cleared = await file.readAsString(); + expect(cleared, contains('"renderScale":2.0')); + expect(cleared, contains('"preferSoftwareRenderer":false')); + expect(cleared.contains('"dataDirectory"'), isFalse); + }); }); testWidgets('Wolf3dApp forwards configured directory to no-data screen', (