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:
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user