diff --git a/apps/wolf_3d_gui/lib/game_data_picker_manager.dart b/apps/wolf_3d_gui/lib/game_data_picker_manager.dart new file mode 100644 index 0000000..4a23360 --- /dev/null +++ b/apps/wolf_3d_gui/lib/game_data_picker_manager.dart @@ -0,0 +1,614 @@ +library; + +import 'dart:async'; +import 'dart:collection'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:file_selector/file_selector.dart'; +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; +import 'package:wolf_3d_flutter/wolf_3d_flutter.dart'; + +/// Validation state for an individual required data file. +enum GameDataFileState { + /// The file was not found in the selected sources. + missing, + + /// The file exists and satisfies the current validation rule. + ready, + + /// The file exists, but its content is not recognized as a supported build. + warning, +} + +/// Validation state for a scanned game version. +enum GameDataVersionState { + /// One or more required files are missing. + incomplete, + + /// All files exist, but the VSWAP checksum is not recognized. + checksumWarning, + + /// All required files exist and the VSWAP checksum is recognized. + ready, +} + +/// Analysis result for a single required data file. +class GameDataFileAnalysis { + /// Creates an analyzed file entry. + const GameDataFileAnalysis({ + required this.file, + required this.expectedName, + required this.state, + this.sourcePath, + this.sourceName, + this.note, + }); + + /// Logical file slot required by the engine. + final GameFile file; + + /// Canonical filename expected for this slot. + final String expectedName; + + /// Validation state for the file slot. + final GameDataFileState state; + + /// Absolute path of the selected source file, if found. + final String? sourcePath; + + /// Actual filename used from disk, including aliases like MAPTEMP. + final String? sourceName; + + /// Extra note shown in the UI for warnings or aliases. + final String? note; +} + +/// Analysis result for a specific Wolf3D release. +class GameDataVersionAnalysis { + /// Creates a version analysis. + const GameDataVersionAnalysis({ + required this.version, + required this.state, + required this.files, + required this.dataVersion, + this.vswapChecksum, + }); + + /// Game release being analyzed. + final GameVersion version; + + /// Aggregate validation state. + final GameDataVersionState state; + + /// File-level analysis for all required slots. + final List files; + + /// Resolved identity derived from the VSWAP checksum. + final DataVersion dataVersion; + + /// Lowercase MD5 checksum of the discovered VSWAP file, if present. + final String? vswapChecksum; + + /// Whether this version can be loaded immediately. + bool get isReady => state == GameDataVersionState.ready; +} + +/// Aggregated scan results for a selected set of data sources. +class GameDataScanResult { + /// Creates a scan result. + const GameDataScanResult({ + required this.scannedDirectories, + required this.versions, + }); + + /// Ordered directories scanned for matching files. + final List scannedDirectories; + + /// Version analyses produced from the selected sources. + final List versions; + + /// Ready versions that can be loaded or imported immediately. + List get readyVersions => + versions.where((analysis) => analysis.isReady).toList(growable: false); + + /// The sole ready version when the scan is unambiguous. + GameDataVersionAnalysis? get soleReadyVersion { + final List ready = readyVersions; + return ready.length == 1 ? ready.single : null; + } +} + +class _SelectedSources { + const _SelectedSources({ + required this.directories, + required this.filesByVersion, + }); + + final List directories; + final Map> filesByVersion; +} + +/// Callback used to open a directory picker. +typedef PickDirectoryCallback = + Future Function({ + String? confirmButtonText, + }); + +/// Callback used to open a multi-file picker. +typedef PickFilesCallback = Future> Function(); + +/// Callback used to compute a VSWAP checksum for version identity. +typedef ChecksumComputer = Future Function(String filePath); + +/// Coordinates GUI app-specific game-data selection flows. +class GameDataPickerManager { + /// Creates a picker workflow manager bound to [engine]. + /// + /// [pickDirectory] and [pickFiles] are injectable test seams. Production + /// usage should rely on their defaults from `file_selector`. + GameDataPickerManager({ + required this.engine, + PickDirectoryCallback? pickDirectory, + PickFilesCallback? pickFiles, + ChecksumComputer? computeChecksum, + this.importRootDirectory, + }) : _pickDirectory = pickDirectory ?? getDirectoryPath, + _pickFiles = pickFiles ?? openFiles, + _computeChecksum = + computeChecksum ?? GameDataPickerManager._defaultChecksumComputer; + + /// Engine facade reloaded when users choose new data locations. + final Wolf3dFlutterEngine engine; + + final PickDirectoryCallback _pickDirectory; + final PickFilesCallback _pickFiles; + final ChecksumComputer _computeChecksum; + + /// Optional override for where imported files are copied during tests. + final String? importRootDirectory; + + bool _isLoadingGameData = false; + String? _pickerError; + GameDataScanResult? _scanResult; + GameVersion? _selectedReadyVersion; + _SelectedSources? _selectedSources; + + /// Whether a picker/reload operation is currently in progress. + bool get isLoadingGameData => _isLoadingGameData; + + /// Last picker/reload error, if any. + String? get pickerError => _pickerError; + + /// Most recent scan result for user-selected sources. + GameDataScanResult? get scanResult => _scanResult; + + /// Currently selected ready version for use/import actions. + GameVersion? get selectedReadyVersion => _selectedReadyVersion; + + /// Prompts the user for a game-data directory and analyzes its contents. + Future pickGameDataDirectory({ + required void Function() notifyChanged, + }) async { + _isLoadingGameData = true; + _pickerError = null; + notifyChanged(); + + try { + final String? directoryPath = await _pickDirectory( + confirmButtonText: 'Use this folder', + ); + + if (directoryPath == null || directoryPath.trim().isEmpty) { + return; + } + + await _scanDirectories( + directories: [directoryPath.trim()], + notifyChanged: notifyChanged, + ); + } catch (error) { + _pickerError = 'Unable to load selected directory: $error'; + } finally { + _isLoadingGameData = false; + notifyChanged(); + } + } + + /// Prompts the user for one or more game-data files and analyzes them. + Future pickGameDataFiles({ + required void Function() notifyChanged, + }) async { + _isLoadingGameData = true; + _pickerError = null; + notifyChanged(); + + try { + final List selectedFiles = await _pickFiles(); + + 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) { + _pickerError = 'Selected files do not expose local filesystem paths.'; + return; + } + + final List orderedDirectories = directories.toList( + growable: false, + ); + await _scanDirectories( + directories: orderedDirectories, + notifyChanged: notifyChanged, + ); + } catch (error) { + _pickerError = 'Unable to load selected files: $error'; + } finally { + _isLoadingGameData = false; + notifyChanged(); + } + } + + /// Selects which ready version subsequent actions should use. + void selectReadyVersion(GameVersion version) { + _selectedReadyVersion = version; + } + + /// Loads the currently selected ready version from the original sources. + Future useSelectedData({ + required void Function() notifyChanged, + }) async { + final _SelectedSources? selectedSources = _selectedSources; + final GameVersion? selectedVersion = _selectedReadyVersion; + if (selectedSources == null || selectedVersion == null) { + _pickerError = 'Select a complete game version first.'; + notifyChanged(); + return; + } + + _isLoadingGameData = true; + _pickerError = null; + notifyChanged(); + + try { + await _loadSelectedVersionFromDirectories( + selectedVersion: selectedVersion, + primaryDirectory: selectedSources.directories.first, + additionalDirectories: selectedSources.directories.skip(1), + ); + } catch (error) { + _pickerError = 'Unable to load selected data: $error'; + } finally { + _isLoadingGameData = false; + notifyChanged(); + } + } + + /// Copies the currently selected ready version into the app config folder. + Future importSelectedData({ + required void Function() notifyChanged, + }) async { + final _SelectedSources? selectedSources = _selectedSources; + final GameVersion? selectedVersion = _selectedReadyVersion; + if (selectedSources == null || selectedVersion == null) { + _pickerError = 'Select a complete game version first.'; + notifyChanged(); + return; + } + + final Map? sourceFiles = + selectedSources.filesByVersion[selectedVersion]; + if (sourceFiles == null || sourceFiles.length < GameFile.values.length) { + _pickerError = 'The selected version is missing required files.'; + notifyChanged(); + return; + } + + _isLoadingGameData = true; + _pickerError = null; + notifyChanged(); + + try { + final String importRoot = + importRootDirectory ?? _defaultImportRootDirectory(); + final String destinationDirectory = + '$importRoot/game_data/${selectedVersion.fileExtension.toLowerCase()}'; + final Directory destination = Directory(destinationDirectory); + await destination.create(recursive: true); + + for (final GameFile file in GameFile.values) { + final String sourcePath = sourceFiles[file]!; + final String destinationPath = + '$destinationDirectory/${file.baseName}.${selectedVersion.fileExtension}'; + await File(sourcePath).copy(destinationPath); + } + + await _loadSelectedVersionFromDirectories( + selectedVersion: selectedVersion, + primaryDirectory: destinationDirectory, + ); + } catch (error) { + _pickerError = 'Unable to import selected data: $error'; + } finally { + _isLoadingGameData = false; + notifyChanged(); + } + } + + /// Returns the parent directory component from [path]. + 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); + } + + Future _scanDirectories({ + required List directories, + required void Function() notifyChanged, + }) async { + _scanResult = null; + _selectedReadyVersion = null; + _selectedSources = null; + notifyChanged(); + + final _SelectedSources selectedSources = await _collectSelectedSources( + directories, + ); + _selectedSources = selectedSources; + + final List versions = []; + for (final GameVersion version in GameVersion.values) { + versions.add( + await _analyzeVersion( + version: version, + filesBySlot: + selectedSources.filesByVersion[version] ?? {}, + ), + ); + } + + _scanResult = GameDataScanResult( + scannedDirectories: selectedSources.directories, + versions: versions, + ); + + final List readyVersions = + _scanResult!.readyVersions; + if (readyVersions.isNotEmpty) { + _selectedReadyVersion = readyVersions.first.version; + } + } + + Future<_SelectedSources> _collectSelectedSources( + List directories, + ) async { + final LinkedHashSet orderedDirectories = LinkedHashSet(); + final Map> filesByVersion = + >{}; + + for (final String rawDirectory in directories) { + final String normalizedDirectory = rawDirectory.trim(); + if (normalizedDirectory.isEmpty) { + continue; + } + + orderedDirectories.add(normalizedDirectory); + + final Directory directory = Directory(normalizedDirectory); + if (!await directory.exists()) { + continue; + } + + await for (final FileSystemEntity entity in directory.list( + recursive: true, + followLinks: false, + )) { + if (entity is! File) { + continue; + } + + final String fileName = entity.uri.pathSegments.last.toUpperCase(); + final _MatchedGameFile? match = _matchGameFile(fileName); + if (match == null) { + continue; + } + + final Map versionFiles = filesByVersion.putIfAbsent( + match.version, + () => {}, + ); + versionFiles.putIfAbsent(match.file, () => entity.path); + } + } + + return _SelectedSources( + directories: orderedDirectories.toList(growable: false), + filesByVersion: filesByVersion, + ); + } + + _MatchedGameFile? _matchGameFile(String fileName) { + for (final GameVersion version in GameVersion.values) { + final String extension = version.fileExtension.toUpperCase(); + for (final GameFile file in GameFile.values) { + final String expectedName = '${file.baseName}.$extension'; + if (fileName == expectedName) { + return _MatchedGameFile(version: version, file: file); + } + + if (file == GameFile.gameMaps && fileName == 'MAPTEMP.$extension') { + return _MatchedGameFile(version: version, file: file); + } + } + } + + return null; + } + + Future _analyzeVersion({ + required GameVersion version, + required Map filesBySlot, + }) async { + DataVersion dataVersion = DataVersion.unknown; + String? checksum; + + final String? vswapPath = filesBySlot[GameFile.vswap]; + if (vswapPath != null) { + checksum = await _computeChecksum(vswapPath); + dataVersion = DataVersion.fromChecksum(checksum); + } + + final List files = []; + for (final GameFile file in GameFile.values) { + final String expectedName = '${file.baseName}.${version.fileExtension}'; + final String? sourcePath = filesBySlot[file]; + final String? sourceName = sourcePath?.split(RegExp(r'[/\\]')).last; + + if (sourcePath == null) { + files.add( + GameDataFileAnalysis( + file: file, + expectedName: expectedName, + state: GameDataFileState.missing, + ), + ); + continue; + } + + if (file == GameFile.vswap) { + files.add( + GameDataFileAnalysis( + file: file, + expectedName: expectedName, + state: dataVersion == DataVersion.unknown + ? GameDataFileState.warning + : GameDataFileState.ready, + sourcePath: sourcePath, + sourceName: sourceName, + note: dataVersion == DataVersion.unknown + ? 'Checksum not recognized.' + : 'Checksum matches ${dataVersion.name}.', + ), + ); + continue; + } + + files.add( + GameDataFileAnalysis( + file: file, + expectedName: expectedName, + state: GameDataFileState.ready, + sourcePath: sourcePath, + sourceName: sourceName, + note: sourceName != null && sourceName.toUpperCase() != expectedName + ? 'Using alias $sourceName.' + : null, + ), + ); + } + + final bool hasMissingFiles = files.any( + (GameDataFileAnalysis file) => file.state == GameDataFileState.missing, + ); + final bool hasChecksumWarning = files.any( + (GameDataFileAnalysis file) => file.state == GameDataFileState.warning, + ); + + return GameDataVersionAnalysis( + version: version, + state: hasMissingFiles + ? GameDataVersionState.incomplete + : hasChecksumWarning + ? GameDataVersionState.checksumWarning + : GameDataVersionState.ready, + files: files, + dataVersion: dataVersion, + vswapChecksum: checksum, + ); + } + + static Future _defaultChecksumComputer(String filePath) async { + final Digest digest = md5.convert(await File(filePath).readAsBytes()); + return digest.toString(); + } + + Future _loadSelectedVersionFromDirectories({ + required GameVersion selectedVersion, + required String primaryDirectory, + Iterable? additionalDirectories, + }) async { + await engine.init( + directory: primaryDirectory, + additionalDirectories: additionalDirectories, + ); + + final List matchingGames = engine.availableGames + .where((WolfensteinData game) => game.version == selectedVersion) + .toList(growable: false); + + if (matchingGames.isEmpty) { + throw StateError( + 'Selected version ${selectedVersion.name} was not discovered after loading the chosen data.', + ); + } + + engine.availableGames + ..clear() + ..addAll(matchingGames); + engine.setActiveGame(engine.availableGames.first); + } + + static String _defaultImportRootDirectory() { + 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'; + } +} + +class _MatchedGameFile { + const _MatchedGameFile({required this.version, required this.file}); + + final GameVersion version; + final GameFile file; +} diff --git a/apps/wolf_3d_gui/lib/main.dart b/apps/wolf_3d_gui/lib/main.dart index ef2a633..9662ce0 100644 --- a/apps/wolf_3d_gui/lib/main.dart +++ b/apps/wolf_3d_gui/lib/main.dart @@ -2,6 +2,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:wolf_3d_flutter/wolf_3d_flutter.dart'; +import 'wolf3d_gui_app.dart'; + /// Creates the application shell after loading available Wolf3D data sets. void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -12,7 +14,7 @@ void main() async { runApp( MaterialApp( - home: Wolf3dApp(engine: wolf3d), + home: Wolf3dGuiApp(engine: wolf3d), ), ); } diff --git a/apps/wolf_3d_gui/lib/no_game_data_screen.dart b/apps/wolf_3d_gui/lib/no_game_data_screen.dart new file mode 100644 index 0000000..a6314db --- /dev/null +++ b/apps/wolf_3d_gui/lib/no_game_data_screen.dart @@ -0,0 +1,487 @@ +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'; + +import 'game_data_picker_manager.dart'; + +/// GUI-host fallback screen shown when no Wolf3D game data is discovered. +class NoGameDataScreen extends StatelessWidget { + /// Creates the no-data screen with app-owned setup actions. + const NoGameDataScreen({ + super.key, + this.configuredDataDirectory, + this.onPickGameDataDirectory, + this.onPickGameDataFiles, + this.onUseSelectedData, + this.onImportSelectedData, + this.onSelectReadyVersion, + this.isLoadingGameData = false, + this.pickerError, + this.scanResult, + this.selectedReadyVersion, + }); + + /// 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; + + /// Invoked when the user wants to load the selected ready version directly. + final Future Function()? onUseSelectedData; + + /// Invoked when the user wants to import the selected ready version. + final Future Function()? onImportSelectedData; + + /// Invoked when the ready-version dropdown changes. + final ValueChanged? onSelectReadyVersion; + + /// Whether the host is currently reloading after picker selection. + final bool isLoadingGameData; + + /// Optional picker/reload error shown to the user. + final String? pickerError; + + /// Most recent scan result for user-selected files or directories. + final GameDataScanResult? scanResult; + + /// Currently selected ready version. + final GameVersion? selectedReadyVersion; + + static Color _colorFromVgaIndex(int index) { + final int packed = ColorPalette.vga32Bit[index]; + 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); + static final Color _warningColor = _colorFromVgaIndex(14); + static final Color _mutedColor = _colorFromVgaIndex(8); + + static String _versionLabel(GameVersion version) { + switch (version) { + case GameVersion.shareware: + return 'Wolf3D Shareware'; + case GameVersion.retail: + return 'Wolf3D Retail'; + case GameVersion.spearOfDestiny: + return 'Spear of Destiny'; + case GameVersion.spearOfDestinyDemo: + return 'Spear of Destiny Demo'; + } + } + + static String _stateLabel(GameDataVersionState state) { + switch (state) { + case GameDataVersionState.incomplete: + return 'Incomplete'; + case GameDataVersionState.checksumWarning: + return 'Unknown checksum'; + case GameDataVersionState.ready: + return 'Ready'; + } + } + + static Color _stateColor(GameDataVersionState state) { + switch (state) { + case GameDataVersionState.ready: + return _emphasisColor; + case GameDataVersionState.checksumWarning: + return _warningColor; + case GameDataVersionState.incomplete: + return _mutedColor; + } + } + + static Color _fileStateColor(GameDataFileState state) { + switch (state) { + case GameDataFileState.ready: + return _emphasisColor; + case GameDataFileState.warning: + return _warningColor; + case GameDataFileState.missing: + return _mutedColor; + } + } + + @override + Widget build(BuildContext context) { + final List readyVersions = + scanResult?.readyVersions ?? []; + + return Scaffold( + backgroundColor: _backgroundColor, + body: LayoutBuilder( + builder: (BuildContext context, BoxConstraints viewportConstraints) { + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight - 48, + ), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 640), + child: DecoratedBox( + decoration: BoxDecoration( + color: _panelColor, + border: Border.all(color: _borderColor, width: 2), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'WOLF3D DATA NOT FOUND', + style: TextStyle( + color: _titleColor, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Text( + 'No game files were discovered.\n\n' + 'Select a game-data directory, or select one or more game-data files.', + style: TextStyle( + color: _bodyColor, + fontSize: 15, + height: 1.4, + ), + ), + const SizedBox(height: 12), + Text( + 'A complete version can be loaded directly or imported into the app config folder.', + style: TextStyle( + color: _emphasisColor, + fontSize: 14, + height: 1.35, + 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 (scanResult != null) ...[ + const SizedBox(height: 18), + _ScanSummary( + bodyColor: _bodyColor, + mutedColor: _mutedColor, + scanResult: scanResult!, + ), + const SizedBox(height: 12), + ...scanResult!.versions.map( + (GameDataVersionAnalysis analysis) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _VersionCard( + panelColor: _backgroundColor, + borderColor: _borderColor, + titleColor: _titleColor, + bodyColor: _bodyColor, + mutedColor: _mutedColor, + analysis: analysis, + ), + ), + ), + ], + if (readyVersions.isNotEmpty) ...[ + const SizedBox(height: 4), + DropdownButtonFormField( + initialValue: + selectedReadyVersion ?? + readyVersions.first.version, + dropdownColor: _panelColor, + style: TextStyle(color: _bodyColor), + decoration: InputDecoration( + labelText: 'Complete version', + labelStyle: TextStyle(color: _bodyColor), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: _borderColor), + ), + ), + items: readyVersions + .map( + ( + GameDataVersionAnalysis analysis, + ) => DropdownMenuItem( + value: analysis.version, + child: Text( + '${_versionLabel(analysis.version)} (${analysis.dataVersion.name})', + ), + ), + ) + .toList(growable: false), + onChanged: isLoadingGameData + ? null + : onSelectReadyVersion, + ), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ElevatedButton( + onPressed: isLoadingGameData + ? null + : onUseSelectedData, + child: const Text('Use selected data'), + ), + ElevatedButton( + onPressed: isLoadingGameData + ? null + : onImportSelectedData, + child: const Text('Import selected data'), + ), + ], + ), + ], + 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( + padding: const EdgeInsets.only(top: 12), + child: Text( + 'Configured data directory: ${configuredDataDirectory!.trim()}', + style: TextStyle( + color: _bodyColor, + fontSize: 13, + height: 1.3, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + }, + ), + ); + } +} + +class _ScanSummary extends StatelessWidget { + const _ScanSummary({ + required this.bodyColor, + required this.mutedColor, + required this.scanResult, + }); + + final Color bodyColor; + final Color mutedColor; + final GameDataScanResult scanResult; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Scanned locations:', + style: TextStyle( + color: bodyColor, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + ...scanResult.scannedDirectories.map( + (String directory) => Text( + directory, + style: TextStyle( + color: mutedColor, + fontSize: 12, + height: 1.3, + ), + ), + ), + ], + ); + } +} + +class _VersionCard extends StatelessWidget { + const _VersionCard({ + required this.panelColor, + required this.borderColor, + required this.titleColor, + required this.bodyColor, + required this.mutedColor, + required this.analysis, + }); + + final Color panelColor; + final Color borderColor; + final Color titleColor; + final Color bodyColor; + final Color mutedColor; + final GameDataVersionAnalysis analysis; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: panelColor, + border: Border.all( + color: NoGameDataScreen._stateColor(analysis.state), + width: 1, + ), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + NoGameDataScreen._versionLabel(analysis.version), + style: TextStyle( + color: titleColor, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + NoGameDataScreen._stateLabel(analysis.state), + style: TextStyle( + color: NoGameDataScreen._stateColor(analysis.state), + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + if (analysis.vswapChecksum != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'VSWAP checksum: ${analysis.vswapChecksum}', + style: TextStyle( + color: mutedColor, + fontSize: 12, + height: 1.3, + ), + ), + ), + const SizedBox(height: 10), + ...analysis.files.map( + (GameDataFileAnalysis file) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 126, + child: Text( + file.expectedName, + style: TextStyle( + color: bodyColor, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + file.sourceName ?? 'Missing', + style: TextStyle( + color: NoGameDataScreen._fileStateColor( + file.state, + ), + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + if (file.note != null) + Text( + file.note!, + style: TextStyle( + color: mutedColor, + fontSize: 11, + height: 1.3, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/wolf_3d_gui/lib/wolf3d_gui_app.dart b/apps/wolf_3d_gui/lib/wolf3d_gui_app.dart new file mode 100644 index 0000000..95072d0 --- /dev/null +++ b/apps/wolf_3d_gui/lib/wolf3d_gui_app.dart @@ -0,0 +1,156 @@ +library; + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:wolf_3d_flutter/wolf_3d_flutter.dart'; + +import 'game_data_picker_manager.dart'; +import 'no_game_data_screen.dart'; + +/// GUI-host application shell that owns setup/import UX. +class Wolf3dGuiApp extends StatefulWidget { + /// Creates the GUI host shell for a prepared engine facade. + const Wolf3dGuiApp({ + super.key, + required this.engine, + this.pickerManager, + }); + + /// Shared initialized facade that owns game data, input, and audio services. + final Wolf3dFlutterEngine engine; + + /// Optional injected picker manager used by tests. + final GameDataPickerManager? pickerManager; + + @override + State createState() => _Wolf3dGuiAppState(); +} + +class _Wolf3dGuiAppState extends State + with WidgetsBindingObserver { + late final GameDataPickerManager _pickerManager; + Future? _shutdownFuture; + + @override + void initState() { + super.initState(); + _pickerManager = + widget.pickerManager ?? GameDataPickerManager(engine: widget.engine); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + unawaited(_ensureAudioShutdown()); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.detached) { + unawaited(_ensureAudioShutdown()); + } + } + + Future _ensureAudioShutdown() { + final Future? existing = _shutdownFuture; + if (existing != null) { + return existing; + } + + final Future shutdown = widget.engine.shutdownAudio(); + _shutdownFuture = shutdown; + return shutdown; + } + + Future _pickGameDataDirectory() { + return _runPickerAction( + () => _pickerManager.pickGameDataDirectory( + notifyChanged: () { + if (mounted) { + setState(() {}); + } + }, + ), + ); + } + + Future _pickGameDataFiles() { + return _runPickerAction( + () => _pickerManager.pickGameDataFiles( + notifyChanged: () { + if (mounted) { + setState(() {}); + } + }, + ), + ); + } + + Future _runPickerAction(Future Function() action) async { + await action(); + + final GameDataVersionAnalysis? soleReadyVersion = + _pickerManager.scanResult?.soleReadyVersion; + if (soleReadyVersion == null || + !mounted || + widget.engine.availableGames.isNotEmpty) { + return; + } + + if (_pickerManager.selectedReadyVersion != soleReadyVersion.version) { + setState(() { + _pickerManager.selectReadyVersion(soleReadyVersion.version); + }); + } + + await _useSelectedData(); + } + + Future _useSelectedData() { + return _pickerManager.useSelectedData( + notifyChanged: () { + if (mounted) { + setState(() {}); + } + }, + ); + } + + Future _importSelectedData() { + return _pickerManager.importSelectedData( + notifyChanged: () { + if (mounted) { + setState(() {}); + } + }, + ); + } + + @override + Widget build(BuildContext context) { + return widget.engine.availableGames.isEmpty + ? NoGameDataScreen( + configuredDataDirectory: widget.engine.configuredDataDirectory, + onPickGameDataDirectory: _pickGameDataDirectory, + onPickGameDataFiles: _pickGameDataFiles, + onUseSelectedData: _useSelectedData, + onImportSelectedData: _importSelectedData, + onSelectReadyVersion: (version) { + if (version == null) { + return; + } + setState(() { + _pickerManager.selectReadyVersion(version); + }); + }, + isLoadingGameData: _pickerManager.isLoadingGameData, + pickerError: _pickerManager.pickerError, + scanResult: _pickerManager.scanResult, + selectedReadyVersion: _pickerManager.selectedReadyVersion, + ) + : GameScreen(wolf3d: widget.engine); + } +} diff --git a/apps/wolf_3d_gui/pubspec.yaml b/apps/wolf_3d_gui/pubspec.yaml index 0ea1cca..1a18e48 100644 --- a/apps/wolf_3d_gui/pubspec.yaml +++ b/apps/wolf_3d_gui/pubspec.yaml @@ -9,10 +9,14 @@ environment: resolution: workspace dependencies: + crypto: ^3.0.6 + file_selector: ^1.0.3 wolf_3d_flutter: + wolf_3d_dart: flutter: sdk: flutter + path: ^1.9.1 dev_dependencies: flutter_test: 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 new file mode 100644 index 0000000..6e466dc --- /dev/null +++ b/apps/wolf_3d_gui/test/game_data_picker_manager_test.dart @@ -0,0 +1,316 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as path; +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'; +import 'package:wolf_3d_gui/game_data_picker_manager.dart'; + +class _NoopAudio implements EngineAudio { + @override + WolfensteinData? activeGame; + + @override + Future debugSoundTest() async {} + + @override + Future init() async {} + + @override + void playLevelMusic(Music music) {} + + @override + void playMenuMusic() {} + + @override + void playSoundEffect(SoundEffect effect) {} + + @override + void playSoundEffectId(int sfxId) {} + + @override + Future stopAllAudio() async {} + + @override + void stopMusic() {} + + @override + void dispose() {} +} + +class _RecordingEngine extends Wolf3dFlutterEngine { + _RecordingEngine({required _NoopAudio audio}) : super(audioBackend: audio); + + int initCallCount = 0; + String? lastDirectory; + List lastAdditionalDirectories = []; + List discoveredGames = []; + + @override + Future init({ + String? directory, + Iterable? additionalDirectories, + }) async { + initCallCount++; + lastDirectory = directory; + lastAdditionalDirectories = + additionalDirectories?.toList(growable: false) ?? []; + availableGames + ..clear() + ..addAll(discoveredGames); + return this; + } +} + +void main() { + group('GameDataPickerManager', () { + test( + 'pickGameDataDirectory scans selected directory and exposes ready version', + () async { + final engine = _RecordingEngine(audio: _NoopAudio()); + int notifyCount = 0; + final Directory tempDir = await Directory.systemTemp.createTemp( + 'wolf3d_scan_', + ); + addTearDown(() async => tempDir.delete(recursive: true)); + await _writeRetailFiles(tempDir.path, useMapTempAlias: false); + + final manager = GameDataPickerManager( + engine: engine, + pickDirectory: ({String? confirmButtonText}) async => + ' ${tempDir.path} ', + computeChecksum: (_) async => DataVersion.version14Retail.checksum, + ); + + await manager.pickGameDataDirectory( + notifyChanged: () => notifyCount++, + ); + + expect(notifyCount, 3); + expect(manager.isLoadingGameData, isFalse); + expect(manager.pickerError, isNull); + expect(engine.initCallCount, 0); + expect(manager.scanResult, isNotNull); + expect(manager.scanResult!.readyVersions, hasLength(1)); + expect(manager.selectedReadyVersion, GameVersion.retail); + }, + ); + + test( + 'pickGameDataFiles records checksum warnings for unknown builds', + () async { + final engine = _RecordingEngine(audio: _NoopAudio()); + final Directory tempDir = await Directory.systemTemp.createTemp( + 'wolf3d_warn_', + ); + addTearDown(() async => tempDir.delete(recursive: true)); + await _writeRetailFiles(tempDir.path, useMapTempAlias: false); + + final manager = GameDataPickerManager( + engine: engine, + pickFiles: () async => [ + XFile(path.join(tempDir.path, 'VSWAP.WL6')), + XFile(path.join(tempDir.path, 'MAPHEAD.WL6')), + ], + computeChecksum: (_) async => 'unknown-checksum', + ); + + await manager.pickGameDataFiles(notifyChanged: () {}); + + expect(engine.initCallCount, 0); + expect(manager.pickerError, isNull); + expect(manager.selectedReadyVersion, isNull); + expect( + manager.scanResult!.versions + .singleWhere( + (analysis) => analysis.version == GameVersion.retail, + ) + .state, + GameDataVersionState.checksumWarning, + ); + }, + ); + + test('pickGameDataFiles reports missing directory paths', () async { + final engine = _RecordingEngine(audio: _NoopAudio()); + final manager = GameDataPickerManager( + engine: engine, + pickFiles: () async => [XFile('VSWAP.WL6')], + ); + + await manager.pickGameDataFiles(notifyChanged: () {}); + + expect(engine.initCallCount, 0); + expect( + manager.pickerError, + 'Selected files do not expose local filesystem paths.', + ); + }); + + test( + 'useSelectedData reloads selected directories for the ready version', + () async { + final engine = _RecordingEngine(audio: _NoopAudio()); + engine.discoveredGames = [ + _buildTestData(GameVersion.retail), + _buildTestData(GameVersion.shareware), + ]; + final Directory one = await Directory.systemTemp.createTemp( + 'wolf3d_one_', + ); + final Directory two = await Directory.systemTemp.createTemp( + 'wolf3d_two_', + ); + addTearDown(() async => one.delete(recursive: true)); + addTearDown(() async => two.delete(recursive: true)); + await _writeRetailFiles(one.path, useMapTempAlias: false); + await File( + path.join(two.path, 'VSWAP.WL1'), + ).writeAsBytes([1, 2, 3]); + + final manager = GameDataPickerManager( + engine: engine, + pickFiles: () async => [ + XFile(path.join(one.path, 'VSWAP.WL6')), + XFile(path.join(one.path, 'MAPHEAD.WL6')), + XFile(path.join(two.path, 'VSWAP.WL1')), + ], + computeChecksum: (filePath) async => filePath.endsWith('WL6') + ? DataVersion.version14Retail.checksum + : 'unknown-checksum', + ); + + await manager.pickGameDataFiles(notifyChanged: () {}); + await manager.useSelectedData(notifyChanged: () {}); + + expect(engine.initCallCount, 1); + expect(engine.lastDirectory, one.path); + expect(engine.lastAdditionalDirectories, [two.path]); + expect(engine.availableGames, hasLength(1)); + expect(engine.availableGames.single.version, GameVersion.retail); + expect(engine.maybeActiveGame?.version, GameVersion.retail); + }, + ); + + test( + 'importSelectedData copies canonical files into config game_data folder', + () async { + final Directory sourceDir = await Directory.systemTemp.createTemp( + 'wolf3d_source_', + ); + final Directory importRoot = await Directory.systemTemp.createTemp( + 'wolf3d_import_', + ); + addTearDown(() async => sourceDir.delete(recursive: true)); + addTearDown(() async => importRoot.delete(recursive: true)); + await _writeRetailFiles(sourceDir.path, useMapTempAlias: true); + + final engine = _RecordingEngine( + audio: _NoopAudio(), + ); + final manager = GameDataPickerManager( + engine: engine, + pickDirectory: ({String? confirmButtonText}) async => sourceDir.path, + computeChecksum: (_) async => DataVersion.version10Retail.checksum, + importRootDirectory: importRoot.path, + ); + + await manager.pickGameDataDirectory(notifyChanged: () {}); + await manager.importSelectedData(notifyChanged: () {}); + + final String importedDirectory = path.join( + importRoot.path, + 'game_data', + 'wl6', + ); + expect(engine.initCallCount, 1); + expect(engine.lastDirectory, importedDirectory); + expect( + File(path.join(importedDirectory, 'GAMEMAPS.WL6')).existsSync(), + isTrue, + ); + expect( + File(path.join(importedDirectory, 'MAPTEMP.WL6')).existsSync(), + isFalse, + ); + expect( + File(path.join(importedDirectory, 'VSWAP.WL6')).existsSync(), + isTrue, + ); + }, + ); + }); +} + +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.retail + ? RetailAssetRegistry() + : SharewareAssetRegistry(), + 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))); + +Future _writeRetailFiles( + String directoryPath, { + required bool useMapTempAlias, +}) async { + final Map> files = >{ + 'VSWAP.WL6': [1, 2, 3, 4], + 'MAPHEAD.WL6': [5], + ...(useMapTempAlias + ? >{ + 'MAPTEMP.WL6': [6], + } + : >{ + 'GAMEMAPS.WL6': [6], + }), + 'VGADICT.WL6': [7], + 'VGAHEAD.WL6': [8], + 'VGAGRAPH.WL6': [9], + 'AUDIOHED.WL6': [10], + 'AUDIOT.WL6': [11], + }; + + for (final MapEntry> entry in files.entries) { + await File(path.join(directoryPath, entry.key)).writeAsBytes(entry.value); + } +} diff --git a/apps/wolf_3d_gui/test/wolf3d_gui_app_test.dart b/apps/wolf_3d_gui/test/wolf3d_gui_app_test.dart new file mode 100644 index 0000000..6b93188 --- /dev/null +++ b/apps/wolf_3d_gui/test/wolf3d_gui_app_test.dart @@ -0,0 +1,213 @@ +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'; +import 'package:wolf_3d_gui/game_data_picker_manager.dart'; +import 'package:wolf_3d_gui/wolf3d_gui_app.dart'; + +class _CountingAudio implements EngineAudio { + @override + WolfensteinData? activeGame; + + int stopAllAudioCallCount = 0; + int disposeCallCount = 0; + + @override + Future debugSoundTest() async {} + + @override + Future init() async {} + + @override + void playLevelMusic(Music music) {} + + @override + void playMenuMusic() {} + + @override + void playSoundEffect(SoundEffect effect) {} + + @override + void playSoundEffectId(int sfxId) {} + + @override + Future stopAllAudio() async { + stopAllAudioCallCount++; + await Future.delayed(const Duration(milliseconds: 1)); + } + + @override + void stopMusic() {} + + @override + void dispose() { + disposeCallCount++; + } +} + +class _NoopAudio implements EngineAudio { + @override + WolfensteinData? activeGame; + + @override + Future debugSoundTest() async {} + + @override + Future init() async {} + + @override + void playLevelMusic(Music music) {} + + @override + void playMenuMusic() {} + + @override + void playSoundEffect(SoundEffect effect) {} + + @override + void playSoundEffectId(int sfxId) {} + + @override + Future stopAllAudio() async {} + + @override + void stopMusic() {} + + @override + void dispose() {} +} + +class _RecordingEngine extends Wolf3dFlutterEngine { + _RecordingEngine({required _NoopAudio audio}) : super(audioBackend: audio); + + int initCallCount = 0; + List discoveredGames = []; + + @override + Future init({ + String? directory, + Iterable? additionalDirectories, + }) async { + initCallCount++; + availableGames + ..clear() + ..addAll(discoveredGames); + return this; + } +} + +class _AutoUsePickerManager extends GameDataPickerManager { + _AutoUsePickerManager({required super.engine}); + + int pickDirectoryCallCount = 0; + int useSelectedDataCallCount = 0; + GameVersion? _selectedReadyVersion; + + static final GameDataScanResult _singleReadyScan = GameDataScanResult( + scannedDirectories: const ['/tmp/wolf'], + versions: [ + GameDataVersionAnalysis( + version: GameVersion.retail, + state: GameDataVersionState.ready, + files: GameFile.values + .map( + (GameFile file) => GameDataFileAnalysis( + file: file, + expectedName: '${file.baseName}.WL6', + state: GameDataFileState.ready, + sourcePath: '/tmp/wolf/${file.baseName}.WL6', + sourceName: '${file.baseName}.WL6', + ), + ) + .toList(growable: false), + dataVersion: DataVersion.version14Retail, + vswapChecksum: DataVersion.version14Retail.checksum, + ), + ], + ); + + @override + GameDataScanResult? get scanResult => _singleReadyScan; + + @override + GameVersion? get selectedReadyVersion => _selectedReadyVersion; + + @override + Future pickGameDataDirectory({ + required void Function() notifyChanged, + }) async { + pickDirectoryCallCount++; + notifyChanged(); + } + + @override + void selectReadyVersion(GameVersion version) { + _selectedReadyVersion = version; + } + + @override + Future useSelectedData({ + required void Function() notifyChanged, + }) async { + useSelectedDataCallCount++; + notifyChanged(); + } +} + +void main() { + testWidgets('Wolf3dGuiApp forwards configured directory to no-data screen', ( + tester, + ) async { + final audio = _NoopAudio(); + final wolf3d = Wolf3dFlutterEngine(audioBackend: audio) + ..configuredDataDirectory = '/tmp/wolf-data'; + + await tester.pumpWidget( + MaterialApp( + home: Wolf3dGuiApp(engine: wolf3d), + ), + ); + + expect(find.textContaining('Configured data directory:'), findsOneWidget); + expect(find.textContaining('/tmp/wolf-data'), findsOneWidget); + }); + + testWidgets('Wolf3dGuiApp dispose path shuts down audio', (tester) async { + final audio = _CountingAudio(); + final wolf3d = Wolf3dFlutterEngine(audioBackend: audio); + + await tester.pumpWidget( + MaterialApp( + home: Wolf3dGuiApp(engine: wolf3d), + ), + ); + + await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink())); + await tester.pump(const Duration(milliseconds: 10)); + + expect(audio.stopAllAudioCallCount, 1); + expect(audio.disposeCallCount, 1); + }); + + testWidgets('Wolf3dGuiApp auto-uses an unambiguous ready scan', ( + tester, + ) async { + final engine = _RecordingEngine(audio: _NoopAudio()); + final manager = _AutoUsePickerManager(engine: engine); + + await tester.pumpWidget( + MaterialApp( + home: Wolf3dGuiApp(engine: engine, pickerManager: manager), + ), + ); + + await tester.tap(find.text('Select data directory')); + await tester.pump(); + await tester.pump(); + + expect(manager.pickDirectoryCallCount, 1); + expect(manager.selectedReadyVersion, GameVersion.retail); + expect(manager.useSelectedDataCallCount, 1); + }); +} diff --git a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart index b0e9ae4..ef1851e 100644 --- a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart +++ b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart @@ -330,17 +330,21 @@ class WolfEngine { final SaveGameFile file = saveGameCodec.decode(bytes); GameSessionSnapshot snapshot = file.snapshot; - int gameIndex = snapshot.currentGameIndex; - if (gameIndex < 0 || gameIndex >= _availableGames.length) { + int gameIndex = _availableGames.indexWhere( + (game) => + game.version == file.gameVersion && + game.dataVersion.name == file.dataVersionName, + ); + if (gameIndex < 0) { gameIndex = _availableGames.indexWhere( - (game) => - game.version == file.gameVersion && - game.dataVersion.name == file.dataVersionName, + (game) => game.version == file.gameVersion, ); - if (gameIndex < 0) { - gameIndex = _availableGames.indexWhere( - (game) => game.version == file.gameVersion, - ); + } + if (gameIndex < 0) { + final int snapshotGameIndex = snapshot.currentGameIndex; + if (snapshotGameIndex >= 0 && + snapshotGameIndex < _availableGames.length) { + gameIndex = snapshotGameIndex; } } diff --git a/packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.dart b/packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.dart index e15fa11..9d213ae 100644 --- a/packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.dart +++ b/packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.dart @@ -444,9 +444,90 @@ void main() { expect(retailEngine.hasLoadableSave, isFalse); expect(sharewareEngine.hasLoadableSave, isTrue); }); + + test( + 'loadFromSlot prefers save metadata over stale snapshot game index', + () async { + final input = _TestInput(); + final persistence = _InMemorySaveGamePersistence(); + final engine = _buildMultiGameEngine( + input: input, + difficulty: Difficulty.medium, + saveGamePersistence: persistence, + ); + + engine.init(); + + final GameSessionSnapshot retailSnapshot = engine.captureSaveState(); + final GameSessionSnapshot staleIndexSnapshot = + _copySnapshotWithGameIndex( + retailSnapshot, + 1, + ); + + final SaveGameFile mismatchedFile = SaveGameFile( + slot: 0, + gameVersion: GameVersion.retail, + dataVersionName: DataVersion.unknown.name, + description: 'Retail save with stale snapshot index', + createdAtMs: DateTime.now().millisecondsSinceEpoch, + snapshot: staleIndexSnapshot, + checksum: 0, + ); + + await persistence.save( + slot: 0, + version: GameVersion.retail, + bytes: engine.saveGameCodec.encode(mismatchedFile), + ); + + final bool loaded = await engine.loadFromSlot(0); + expect(loaded, isTrue); + expect(engine.data.version, GameVersion.retail); + expect(engine.currentGameIndex, 0); + + final bool saved = await engine.saveToSlot( + 1, + description: 'After load', + ); + expect(saved, isTrue); + expect( + await persistence.exists(slot: 1, version: GameVersion.retail), + isTrue, + ); + expect( + await persistence.exists(slot: 1, version: GameVersion.shareware), + isFalse, + ); + }, + ); }); } +GameSessionSnapshot _copySnapshotWithGameIndex( + GameSessionSnapshot snapshot, + int gameIndex, +) { + return GameSessionSnapshot( + currentGameIndex: gameIndex, + currentEpisodeIndex: snapshot.currentEpisodeIndex, + currentLevelIndex: snapshot.currentLevelIndex, + returnLevelIndex: snapshot.returnLevelIndex, + difficulty: snapshot.difficulty, + timeAliveMs: snapshot.timeAliveMs, + lastAcousticAlertTime: snapshot.lastAcousticAlertTime, + isMapOverlayVisible: snapshot.isMapOverlayVisible, + isMenuOverlayVisible: snapshot.isMenuOverlayVisible, + player: snapshot.player, + currentLevel: snapshot.currentLevel, + areaGrid: snapshot.areaGrid, + areasByPlayer: snapshot.areasByPlayer, + entities: snapshot.entities, + doors: snapshot.doors, + pushwalls: snapshot.pushwalls, + ); +} + WolfEngine _buildMultiGameEngine({ required _TestInput input, required Difficulty? difficulty, diff --git a/packages/wolf_3d_flutter/lib/managers/game_data_picker_manager.dart b/packages/wolf_3d_flutter/lib/managers/game_data_picker_manager.dart deleted file mode 100644 index 33ea0ac..0000000 --- a/packages/wolf_3d_flutter/lib/managers/game_data_picker_manager.dart +++ /dev/null @@ -1,144 +0,0 @@ -library; - -import 'dart:collection'; - -import 'package:file_selector/file_selector.dart'; -import 'package:wolf_3d_flutter/wolf_3d_flutter.dart'; - -/// Callback used to open a directory picker. -typedef PickDirectoryCallback = - Future Function({ - String? confirmButtonText, - }); - -/// Callback used to open a multi-file picker. -typedef PickFilesCallback = Future> Function(); - -/// Coordinates game-data selection flows for the no-data setup screen. -class GameDataPickerManager { - /// Creates a picker workflow manager bound to [engine]. - /// - /// [pickDirectory] and [pickFiles] are injectable test seams. Production - /// usage should rely on their defaults from `file_selector`. - GameDataPickerManager({ - required this.engine, - PickDirectoryCallback? pickDirectory, - PickFilesCallback? pickFiles, - }) : _pickDirectory = pickDirectory ?? getDirectoryPath, - _pickFiles = pickFiles ?? openFiles; - - /// Engine facade reloaded when users choose new data locations. - final Wolf3dFlutterEngine engine; - - final PickDirectoryCallback _pickDirectory; - final PickFilesCallback _pickFiles; - - bool _isLoadingGameData = false; - String? _pickerError; - - /// Whether a picker/reload operation is currently in progress. - bool get isLoadingGameData => _isLoadingGameData; - - /// Last picker/reload error, if any. - String? get pickerError => _pickerError; - - /// Prompts the user for a game-data directory and reloads discovery. - /// - /// Calls [notifyChanged] before and after the async workflow so UIs can - /// update loading/error state. - Future pickGameDataDirectory({ - required void Function() notifyChanged, - }) async { - _isLoadingGameData = true; - _pickerError = null; - notifyChanged(); - - try { - final String? directoryPath = await _pickDirectory( - confirmButtonText: 'Use this folder', - ); - - if (directoryPath == null || directoryPath.trim().isEmpty) { - return; - } - - await engine.init(directory: directoryPath.trim()); - } catch (error) { - _pickerError = 'Unable to load selected directory: $error'; - } finally { - _isLoadingGameData = false; - notifyChanged(); - } - } - - /// Prompts the user for one or more game-data files and reloads discovery. - /// - /// Selected file paths are collapsed into unique parent directories, then - /// passed to [Wolf3dFlutterEngine.init] as one primary directory plus - /// [Wolf3dFlutterEngine.init] `additionalDirectories`. - Future pickGameDataFiles({ - required void Function() notifyChanged, - }) async { - _isLoadingGameData = true; - _pickerError = null; - notifyChanged(); - - try { - final List selectedFiles = await _pickFiles(); - - 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) { - _pickerError = 'Selected files do not expose local filesystem paths.'; - return; - } - - final List orderedDirectories = directories.toList( - growable: false, - ); - await engine.init( - directory: orderedDirectories.first, - additionalDirectories: orderedDirectories.skip(1), - ); - } catch (error) { - _pickerError = 'Unable to load selected files: $error'; - } finally { - _isLoadingGameData = false; - notifyChanged(); - } - } - - /// Returns the parent directory component from [path]. - 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); - } -} diff --git a/packages/wolf_3d_flutter/lib/managers/wolf3d_app_manager.dart b/packages/wolf_3d_flutter/lib/managers/wolf3d_app_manager.dart deleted file mode 100644 index 9099c90..0000000 --- a/packages/wolf_3d_flutter/lib/managers/wolf3d_app_manager.dart +++ /dev/null @@ -1,53 +0,0 @@ -library; - -import 'dart:async'; - -import 'package:wolf_3d_flutter/wolf_3d_flutter.dart'; - -/// Coordinates app-shell setup actions and shutdown behavior. -class Wolf3dAppManager { - /// Creates an app-shell manager bound to [engine]. - /// - /// [pickerManager] can be provided in tests to control picker behavior. - Wolf3dAppManager({ - required this.engine, - GameDataPickerManager? pickerManager, - }) : _pickerManager = pickerManager ?? GameDataPickerManager(engine: engine); - - /// Engine facade managed by the app shell. - final Wolf3dFlutterEngine engine; - final GameDataPickerManager _pickerManager; - Future? _shutdownFuture; - - /// Forwarded picker loading flag for UI binding. - bool get isLoadingGameData => _pickerManager.isLoadingGameData; - - /// Forwarded picker error text for UI binding. - String? get pickerError => _pickerManager.pickerError; - - /// Shuts down engine audio exactly once and reuses in-flight shutdown calls. - Future ensureAudioShutdown() { - final existing = _shutdownFuture; - if (existing != null) { - return existing; - } - - final shutdown = engine.shutdownAudio(); - _shutdownFuture = shutdown; - return shutdown; - } - - /// Delegates directory picker + reload behavior. - Future pickGameDataDirectory({ - required void Function() notifyChanged, - }) async { - await _pickerManager.pickGameDataDirectory(notifyChanged: notifyChanged); - } - - /// Delegates file picker + reload behavior. - Future pickGameDataFiles({ - required void Function() notifyChanged, - }) async { - await _pickerManager.pickGameDataFiles(notifyChanged: notifyChanged); - } -} 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 deleted file mode 100644 index 399d1b6..0000000 --- a/packages/wolf_3d_flutter/lib/screens/no_game_data_screen.dart +++ /dev/null @@ -1,174 +0,0 @@ -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, - 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; - 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: _backgroundColor, - body: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 640), - child: DecoratedBox( - decoration: BoxDecoration( - color: _panelColor, - border: Border.all(color: _borderColor, width: 2), - ), - child: Padding( - padding: EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'WOLF3D DATA NOT FOUND', - style: TextStyle( - color: _titleColor, - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 16), - Text( - 'No game files were discovered.\n\n' - 'Select a game-data directory, or select one or more game-data files.', - style: TextStyle( - 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, - ), - ), - 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( - padding: const EdgeInsets.only(top: 12), - child: Text( - 'Configured data directory: ${configuredDataDirectory!.trim()}', - style: TextStyle( - color: _bodyColor, - fontSize: 13, - height: 1.3, - ), - ), - ), - ], - ), - ), - ), - ), - ), - ), - ); - } -} diff --git a/packages/wolf_3d_flutter/lib/widgets/wolf3d_app.dart b/packages/wolf_3d_flutter/lib/widgets/wolf3d_app.dart deleted file mode 100644 index b05708e..0000000 --- a/packages/wolf_3d_flutter/lib/widgets/wolf3d_app.dart +++ /dev/null @@ -1,79 +0,0 @@ -library; - -import 'dart:async'; - -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 StatefulWidget { - /// Shared initialized facade that owns game data, input, and audio services. - final Wolf3dFlutterEngine engine; - - const Wolf3dApp({ - super.key, - required this.engine, - }); - - @override - State createState() => _Wolf3dAppState(); -} - -class _Wolf3dAppState extends State with WidgetsBindingObserver { - late final Wolf3dAppManager _manager; - - @override - void initState() { - super.initState(); - _manager = Wolf3dAppManager(engine: widget.engine); - WidgetsBinding.instance.addObserver(this); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - unawaited(_manager.ensureAudioShutdown()); - super.dispose(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.detached) { - unawaited(_manager.ensureAudioShutdown()); - } - } - - Future _pickGameDataDirectory() { - return _manager.pickGameDataDirectory( - notifyChanged: () { - if (mounted) { - setState(() {}); - } - }, - ); - } - - Future _pickGameDataFiles() { - return _manager.pickGameDataFiles( - notifyChanged: () { - if (mounted) { - setState(() {}); - } - }, - ); - } - - @override - Widget build(BuildContext context) { - return widget.engine.availableGames.isEmpty - ? NoGameDataScreen( - configuredDataDirectory: widget.engine.configuredDataDirectory, - onPickGameDataDirectory: _pickGameDataDirectory, - onPickGameDataFiles: _pickGameDataFiles, - isLoadingGameData: _manager.isLoadingGameData, - pickerError: _manager.pickerError, - ) - : 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 297542e..ad2980b 100644 --- a/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart +++ b/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart @@ -2,7 +2,6 @@ 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'; @@ -18,7 +17,6 @@ export 'audio/wolf3d_platform_audio.dart' show Wolf3dPlatformAudio; export 'managers/game_app_lifecycle_manager.dart' show GameAppLifecycleManager; export 'managers/game_data_directory_persistence.dart' show DefaultGameDataDirectoryPersistence; -export 'managers/game_data_picker_manager.dart' show GameDataPickerManager; export 'managers/game_display_manager.dart' show GameDisplayManager; export 'managers/game_persistence_manager.dart' show GamePersistenceManager; export 'managers/game_renderer_mode_manager.dart' @@ -31,22 +29,19 @@ export 'managers/game_screen_input_manager.dart' HostShortcutRegistry, GameScreenInputManager, isAltEnterShortcut; -export 'managers/wolf3d_app_manager.dart' show Wolf3dAppManager; export 'screens/audio_gallery.dart' show AudioGallery; export 'screens/debug_tools_screen.dart' show DebugToolsScreen; export 'screens/game_screen.dart' show GameScreen; -export 'screens/no_game_data_screen.dart' show NoGameDataScreen; export 'screens/sprite_gallery.dart' show SpriteGallery; export 'screens/vga_gallery.dart' show VgaGallery; export 'widgets/gallery_game_selector.dart' show GalleryGameSelector, formatGalleryGameTitle; -export 'widgets/wolf3d_app.dart' show Wolf3dApp; export 'widgets/wolf_menu_shell.dart' show WolfMenuShell; /// Flutter-specific host facade built on top of [Wolf3dEngine]. /// /// This type keeps platform-neutral session/engine state in the Dart package -/// while owning Flutter-only concerns such as bundle loading and discovery. +/// while owning Flutter-only concerns such as platform discovery helpers. class Wolf3dFlutterEngine extends Wolf3dEngine { /// Creates an empty facade that must be initialized with [init]. Wolf3dFlutterEngine({ @@ -89,8 +84,9 @@ class Wolf3dFlutterEngine extends Wolf3dEngine { /// used when available. [additionalDirectories] are scanned after the /// primary directory and are not persisted. /// - /// This method merges bundled package data (when present) with discovered - /// external directories, deduplicating versions by [GameVersion]. + /// This method scans only configured external directories, deduplicating + /// discovered versions by [GameVersion]. Shared package code does not bundle + /// or import game data on behalf of host applications. Future init({ String? directory, Iterable? additionalDirectories, @@ -110,35 +106,6 @@ class Wolf3dFlutterEngine extends Wolf3dEngine { await dataDirectoryPersistence.saveDataDirectory(requestedDirectory); } - // Bundled assets let the GUI work out of the box on supported platforms. - final versionsToTry = [ - (version: GameVersion.retail, path: 'retail'), - (version: GameVersion.shareware, path: 'shareware'), - ]; - - for (final version in versionsToTry) { - try { - final ext = version.version.fileExtension; - final folder = 'packages/wolf_3d_assets/assets/${version.path}'; - - final data = WolfensteinLoader.loadFromBytes( - version: version.version, - vswap: await _tryLoad('$folder/VSWAP.$ext'), - mapHead: await _tryLoad('$folder/MAPHEAD.$ext'), - gameMaps: await _tryLoad('$folder/GAMEMAPS.$ext'), - vgaDict: await _tryLoad('$folder/VGADICT.$ext'), - vgaHead: await _tryLoad('$folder/VGAHEAD.$ext'), - vgaGraph: await _tryLoad('$folder/VGAGRAPH.$ext'), - audioHed: await _tryLoad('$folder/AUDIOHED.$ext'), - audioT: await _tryLoad('$folder/AUDIOT.$ext'), - ); - - availableGames.add(data); - } catch (e) { - debugPrint(e.toString()); - } - } - // 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 = {}; @@ -175,14 +142,4 @@ class Wolf3dFlutterEngine extends Wolf3dEngine { return this; } - - /// Loads an asset from the Flutter bundle, returning `null` when absent. - Future _tryLoad(String path) async { - try { - return await rootBundle.load(path); - } catch (e) { - debugPrint("Asset not found: $path"); - return null; - } - } } diff --git a/packages/wolf_3d_flutter/pubspec.yaml b/packages/wolf_3d_flutter/pubspec.yaml index 56253fb..9a1da9b 100644 --- a/packages/wolf_3d_flutter/pubspec.yaml +++ b/packages/wolf_3d_flutter/pubspec.yaml @@ -14,7 +14,6 @@ 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/app_managers_test.dart b/packages/wolf_3d_flutter/test/app_managers_test.dart deleted file mode 100644 index 3baff3b..0000000 --- a/packages/wolf_3d_flutter/test/app_managers_test.dart +++ /dev/null @@ -1,195 +0,0 @@ -import 'package:file_selector/file_selector.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; - - int stopAllAudioCallCount = 0; - int disposeCallCount = 0; - - @override - Future debugSoundTest() async {} - - @override - Future init() async {} - - @override - void playLevelMusic(Music music) {} - - @override - void playMenuMusic() {} - - @override - void playSoundEffect(SoundEffect effect) {} - - @override - void playSoundEffectId(int sfxId) {} - - @override - Future stopAllAudio() async { - stopAllAudioCallCount++; - } - - @override - void stopMusic() {} - - @override - void dispose() { - disposeCallCount++; - } -} - -class _RecordingEngine extends Wolf3dFlutterEngine { - _RecordingEngine({required _NoopAudio audio}) : super(audioBackend: audio); - - int initCallCount = 0; - String? lastDirectory; - List lastAdditionalDirectories = []; - - @override - Future init({ - String? directory, - Iterable? additionalDirectories, - }) async { - initCallCount++; - lastDirectory = directory; - lastAdditionalDirectories = - additionalDirectories?.toList(growable: false) ?? []; - return this; - } -} - -void main() { - group('GameDataPickerManager', () { - test('pickGameDataDirectory reloads selected directory', () async { - final engine = _RecordingEngine(audio: _NoopAudio()); - int notifyCount = 0; - final manager = GameDataPickerManager( - engine: engine, - pickDirectory: ({String? confirmButtonText}) async => ' /tmp/wolf ', - ); - - await manager.pickGameDataDirectory( - notifyChanged: () => notifyCount++, - ); - - expect(notifyCount, 2); - expect(manager.isLoadingGameData, isFalse); - expect(manager.pickerError, isNull); - expect(engine.initCallCount, 1); - expect(engine.lastDirectory, '/tmp/wolf'); - expect(engine.lastAdditionalDirectories, isEmpty); - }); - - test('pickGameDataDirectory ignores empty selection', () async { - final engine = _RecordingEngine(audio: _NoopAudio()); - final manager = GameDataPickerManager( - engine: engine, - pickDirectory: ({String? confirmButtonText}) async => ' ', - ); - - await manager.pickGameDataDirectory(notifyChanged: () {}); - - expect(engine.initCallCount, 0); - expect(manager.pickerError, isNull); - }); - - test( - 'pickGameDataFiles deduplicates directories and forwards extras', - () async { - final engine = _RecordingEngine(audio: _NoopAudio()); - final manager = GameDataPickerManager( - engine: engine, - pickFiles: () async => [ - XFile('/a/one/VSWAP.WL6'), - XFile('/a/one/MAPHEAD.WL6'), - XFile('/a/two/VSWAP.WL1'), - ], - ); - - await manager.pickGameDataFiles(notifyChanged: () {}); - - expect(engine.initCallCount, 1); - expect(engine.lastDirectory, '/a/one'); - expect(engine.lastAdditionalDirectories, ['/a/two']); - expect(manager.pickerError, isNull); - }, - ); - - test('pickGameDataFiles reports missing directory paths', () async { - final engine = _RecordingEngine(audio: _NoopAudio()); - final manager = GameDataPickerManager( - engine: engine, - pickFiles: () async => [ - XFile('VSWAP.WL6'), - XFile('MAPHEAD.WL6'), - ], - ); - - await manager.pickGameDataFiles(notifyChanged: () {}); - - expect(engine.initCallCount, 0); - expect( - manager.pickerError, - 'Selected files do not expose local filesystem paths.', - ); - }); - - test('pickGameDataDirectory captures thrown picker error', () async { - final engine = _RecordingEngine(audio: _NoopAudio()); - final manager = GameDataPickerManager( - engine: engine, - pickDirectory: ({String? confirmButtonText}) async { - throw StateError('picker failed'); - }, - ); - - await manager.pickGameDataDirectory(notifyChanged: () {}); - - expect(engine.initCallCount, 0); - expect( - manager.pickerError, - contains('Unable to load selected directory'), - ); - expect(manager.isLoadingGameData, isFalse); - }); - }); - - group('Wolf3dAppManager', () { - test('ensureAudioShutdown only executes once', () async { - final audio = _NoopAudio(); - final engine = _RecordingEngine(audio: audio); - final manager = Wolf3dAppManager(engine: engine); - - await Future.wait(>[ - manager.ensureAudioShutdown(), - manager.ensureAudioShutdown(), - ]); - - expect(audio.stopAllAudioCallCount, 1); - expect(audio.disposeCallCount, 1); - }); - - test('forwards picker state from injected picker manager', () async { - final engine = _RecordingEngine(audio: _NoopAudio()); - final pickerManager = GameDataPickerManager( - engine: engine, - pickDirectory: ({String? confirmButtonText}) async => '/tmp/wolf', - ); - final manager = Wolf3dAppManager( - engine: engine, - pickerManager: pickerManager, - ); - - await manager.pickGameDataDirectory(notifyChanged: () {}); - - expect(manager.isLoadingGameData, isFalse); - expect(manager.pickerError, isNull); - expect(engine.lastDirectory, '/tmp/wolf'); - }); - }); -} 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 33fa6b8..6ea04b8 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 @@ -1,83 +1,9 @@ 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 debugSoundTest() async {} - - @override - Future init() async {} - - @override - void playLevelMusic(Music music) {} - - @override - void playMenuMusic() {} - - @override - void playSoundEffect(SoundEffect effect) {} - - @override - void playSoundEffectId(int sfxId) {} - - @override - Future stopAllAudio() async {} - - @override - void stopMusic() {} - - @override - void dispose() {} -} - -class _CountingAudio implements EngineAudio { - @override - WolfensteinData? activeGame; - - int stopAllAudioCallCount = 0; - int disposeCallCount = 0; - - @override - Future debugSoundTest() async {} - - @override - Future init() async {} - - @override - void playLevelMusic(Music music) {} - - @override - void playMenuMusic() {} - - @override - void playSoundEffect(SoundEffect effect) {} - - @override - void playSoundEffectId(int sfxId) {} - - @override - Future stopAllAudio() async { - stopAllAudioCallCount++; - await Future.delayed(const Duration(milliseconds: 1)); - } - - @override - void stopMusic() {} - - @override - void dispose() { - disposeCallCount++; - } -} - void main() { group('DefaultGameDataDirectoryPersistence', () { test('saves and loads configured directory', () async { @@ -217,37 +143,4 @@ void main() { expect(await persistence.load(), 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); - }); - - testWidgets('Wolf3dApp dispose path shuts down audio', (tester) async { - final audio = _CountingAudio(); - final wolf3d = Wolf3dFlutterEngine(audioBackend: audio); - - await tester.pumpWidget( - MaterialApp( - home: Wolf3dApp(engine: wolf3d), - ), - ); - - await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink())); - await tester.pump(const Duration(milliseconds: 10)); - - expect(audio.stopAllAudioCallCount, 1); - expect(audio.disposeCallCount, 1); - }); } 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 d157440..c3794bd 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 @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; @@ -66,6 +67,33 @@ void main() { expect(wolf3d.isDebugEnabled, isTrue); }); + test( + 'init without configured external data does not load bundled games', + () async { + final tempDir = await Directory.systemTemp.createTemp( + 'wolf3d-empty-config-', + ); + 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, + ); + + await wolf3d.init(); + + expect(wolf3d.configuredDataDirectory, isNull); + expect(wolf3d.availableGames, isEmpty); + }, + ); + testWidgets('GameScreen hides debug FAB when debug mode is disabled', ( tester, ) async {