feat: Add tests for Wolf3dGuiApp and refactor game data picker management

- Introduced unit tests for the Wolf3dGuiApp to ensure proper directory configuration and audio management.
- Refactored the GameDataPickerManager to streamline directory and file selection processes.
- Removed obsolete GameDataPickerManager and Wolf3dAppManager classes to simplify the architecture.
- Enhanced the Wolf3dFlutterEngine to improve game version handling during save loading.
- Updated existing tests to reflect changes in the game data management structure.
- Removed unnecessary dependencies and cleaned up the codebase for better maintainability.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-24 14:35:20 +01:00
parent b980174905
commit ce4dd8d61d
18 changed files with 1919 additions and 810 deletions
@@ -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<GameDataFileAnalysis> 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<String> scannedDirectories;
/// Version analyses produced from the selected sources.
final List<GameDataVersionAnalysis> versions;
/// Ready versions that can be loaded or imported immediately.
List<GameDataVersionAnalysis> get readyVersions =>
versions.where((analysis) => analysis.isReady).toList(growable: false);
/// The sole ready version when the scan is unambiguous.
GameDataVersionAnalysis? get soleReadyVersion {
final List<GameDataVersionAnalysis> ready = readyVersions;
return ready.length == 1 ? ready.single : null;
}
}
class _SelectedSources {
const _SelectedSources({
required this.directories,
required this.filesByVersion,
});
final List<String> directories;
final Map<GameVersion, Map<GameFile, String>> filesByVersion;
}
/// Callback used to open a directory picker.
typedef PickDirectoryCallback =
Future<String?> Function({
String? confirmButtonText,
});
/// Callback used to open a multi-file picker.
typedef PickFilesCallback = Future<List<XFile>> Function();
/// Callback used to compute a VSWAP checksum for version identity.
typedef ChecksumComputer = Future<String> 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<void> 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: <String>[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<void> pickGameDataFiles({
required void Function() notifyChanged,
}) async {
_isLoadingGameData = true;
_pickerError = null;
notifyChanged();
try {
final List<XFile> selectedFiles = await _pickFiles();
if (selectedFiles.isEmpty) {
return;
}
final LinkedHashSet<String> directories = LinkedHashSet<String>();
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<String> 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<void> 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<void> 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<GameFile, String>? 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<void> _scanDirectories({
required List<String> directories,
required void Function() notifyChanged,
}) async {
_scanResult = null;
_selectedReadyVersion = null;
_selectedSources = null;
notifyChanged();
final _SelectedSources selectedSources = await _collectSelectedSources(
directories,
);
_selectedSources = selectedSources;
final List<GameDataVersionAnalysis> versions = <GameDataVersionAnalysis>[];
for (final GameVersion version in GameVersion.values) {
versions.add(
await _analyzeVersion(
version: version,
filesBySlot:
selectedSources.filesByVersion[version] ?? <GameFile, String>{},
),
);
}
_scanResult = GameDataScanResult(
scannedDirectories: selectedSources.directories,
versions: versions,
);
final List<GameDataVersionAnalysis> readyVersions =
_scanResult!.readyVersions;
if (readyVersions.isNotEmpty) {
_selectedReadyVersion = readyVersions.first.version;
}
}
Future<_SelectedSources> _collectSelectedSources(
List<String> directories,
) async {
final LinkedHashSet<String> orderedDirectories = LinkedHashSet<String>();
final Map<GameVersion, Map<GameFile, String>> filesByVersion =
<GameVersion, Map<GameFile, String>>{};
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<GameFile, String> versionFiles = filesByVersion.putIfAbsent(
match.version,
() => <GameFile, String>{},
);
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<GameDataVersionAnalysis> _analyzeVersion({
required GameVersion version,
required Map<GameFile, String> 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<GameDataFileAnalysis> files = <GameDataFileAnalysis>[];
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<String> _defaultChecksumComputer(String filePath) async {
final Digest digest = md5.convert(await File(filePath).readAsBytes());
return digest.toString();
}
Future<void> _loadSelectedVersionFromDirectories({
required GameVersion selectedVersion,
required String primaryDirectory,
Iterable<String>? additionalDirectories,
}) async {
await engine.init(
directory: primaryDirectory,
additionalDirectories: additionalDirectories,
);
final List<WolfensteinData> 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;
}
+3 -1
View File
@@ -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),
),
);
}
@@ -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<void> Function()? onPickGameDataDirectory;
/// Invoked when the user requests selecting one or more data files.
final Future<void> Function()? onPickGameDataFiles;
/// Invoked when the user wants to load the selected ready version directly.
final Future<void> Function()? onUseSelectedData;
/// Invoked when the user wants to import the selected ready version.
final Future<void> Function()? onImportSelectedData;
/// Invoked when the ready-version dropdown changes.
final ValueChanged<GameVersion?>? 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<GameDataVersionAnalysis> readyVersions =
scanResult?.readyVersions ?? <GameDataVersionAnalysis>[];
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<GameVersion>(
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<GameVersion>(
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,
),
),
],
),
),
],
),
),
),
],
),
),
);
}
}
+156
View File
@@ -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<Wolf3dGuiApp> createState() => _Wolf3dGuiAppState();
}
class _Wolf3dGuiAppState extends State<Wolf3dGuiApp>
with WidgetsBindingObserver {
late final GameDataPickerManager _pickerManager;
Future<void>? _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<void> _ensureAudioShutdown() {
final Future<void>? existing = _shutdownFuture;
if (existing != null) {
return existing;
}
final Future<void> shutdown = widget.engine.shutdownAudio();
_shutdownFuture = shutdown;
return shutdown;
}
Future<void> _pickGameDataDirectory() {
return _runPickerAction(
() => _pickerManager.pickGameDataDirectory(
notifyChanged: () {
if (mounted) {
setState(() {});
}
},
),
);
}
Future<void> _pickGameDataFiles() {
return _runPickerAction(
() => _pickerManager.pickGameDataFiles(
notifyChanged: () {
if (mounted) {
setState(() {});
}
},
),
);
}
Future<void> _runPickerAction(Future<void> 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<void> _useSelectedData() {
return _pickerManager.useSelectedData(
notifyChanged: () {
if (mounted) {
setState(() {});
}
},
);
}
Future<void> _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);
}
}
+4
View File
@@ -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:
@@ -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<void> debugSoundTest() async {}
@override
Future<void> init() async {}
@override
void playLevelMusic(Music music) {}
@override
void playMenuMusic() {}
@override
void playSoundEffect(SoundEffect effect) {}
@override
void playSoundEffectId(int sfxId) {}
@override
Future<void> stopAllAudio() async {}
@override
void stopMusic() {}
@override
void dispose() {}
}
class _RecordingEngine extends Wolf3dFlutterEngine {
_RecordingEngine({required _NoopAudio audio}) : super(audioBackend: audio);
int initCallCount = 0;
String? lastDirectory;
List<String> lastAdditionalDirectories = <String>[];
List<WolfensteinData> discoveredGames = <WolfensteinData>[];
@override
Future<Wolf3dFlutterEngine> init({
String? directory,
Iterable<String>? additionalDirectories,
}) async {
initCallCount++;
lastDirectory = directory;
lastAdditionalDirectories =
additionalDirectories?.toList(growable: false) ?? <String>[];
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>[
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>[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 = <WolfensteinData>[
_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(<int>[1, 2, 3]);
final manager = GameDataPickerManager(
engine: engine,
pickFiles: () async => <XFile>[
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, <String>[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>[_sprite(1), _sprite(1), _sprite(2), _sprite(2)],
sprites: List<Sprite>.generate(436, (_) => _sprite(255)),
sounds: List<PcmSound>.generate(200, (_) => PcmSound(Uint8List(1))),
adLibSounds: const <PcmSound>[],
music: const <ImfMusic>[],
vgaImages: const <VgaImage>[],
episodes: <Episode>[
Episode(
name: 'Test Episode',
levels: <WolfLevel>[
WolfLevel(
name: 'Test Level',
wallGrid: wallGrid,
areaGrid: List.generate(64, (_) => List.filled(64, -1)),
objectGrid: objectGrid,
music: Music.level01,
),
],
),
],
);
}
Sprite _sprite(int color) =>
Sprite(Uint8List.fromList(List<int>.filled(64 * 64, color)));
Future<void> _writeRetailFiles(
String directoryPath, {
required bool useMapTempAlias,
}) async {
final Map<String, List<int>> files = <String, List<int>>{
'VSWAP.WL6': <int>[1, 2, 3, 4],
'MAPHEAD.WL6': <int>[5],
...(useMapTempAlias
? <String, List<int>>{
'MAPTEMP.WL6': <int>[6],
}
: <String, List<int>>{
'GAMEMAPS.WL6': <int>[6],
}),
'VGADICT.WL6': <int>[7],
'VGAHEAD.WL6': <int>[8],
'VGAGRAPH.WL6': <int>[9],
'AUDIOHED.WL6': <int>[10],
'AUDIOT.WL6': <int>[11],
};
for (final MapEntry<String, List<int>> entry in files.entries) {
await File(path.join(directoryPath, entry.key)).writeAsBytes(entry.value);
}
}
@@ -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<void> debugSoundTest() async {}
@override
Future<void> init() async {}
@override
void playLevelMusic(Music music) {}
@override
void playMenuMusic() {}
@override
void playSoundEffect(SoundEffect effect) {}
@override
void playSoundEffectId(int sfxId) {}
@override
Future<void> stopAllAudio() async {
stopAllAudioCallCount++;
await Future<void>.delayed(const Duration(milliseconds: 1));
}
@override
void stopMusic() {}
@override
void dispose() {
disposeCallCount++;
}
}
class _NoopAudio implements EngineAudio {
@override
WolfensteinData? activeGame;
@override
Future<void> debugSoundTest() async {}
@override
Future<void> init() async {}
@override
void playLevelMusic(Music music) {}
@override
void playMenuMusic() {}
@override
void playSoundEffect(SoundEffect effect) {}
@override
void playSoundEffectId(int sfxId) {}
@override
Future<void> stopAllAudio() async {}
@override
void stopMusic() {}
@override
void dispose() {}
}
class _RecordingEngine extends Wolf3dFlutterEngine {
_RecordingEngine({required _NoopAudio audio}) : super(audioBackend: audio);
int initCallCount = 0;
List<WolfensteinData> discoveredGames = <WolfensteinData>[];
@override
Future<Wolf3dFlutterEngine> init({
String? directory,
Iterable<String>? 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 <String>['/tmp/wolf'],
versions: <GameDataVersionAnalysis>[
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<void> pickGameDataDirectory({
required void Function() notifyChanged,
}) async {
pickDirectoryCallCount++;
notifyChanged();
}
@override
void selectReadyVersion(GameVersion version) {
_selectedReadyVersion = version;
}
@override
Future<void> 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);
});
}