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:flutter/material.dart';
|
||||||
import 'package:wolf_3d_flutter/wolf_3d_flutter.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.
|
/// Creates the application shell after loading available Wolf3D data sets.
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -12,7 +14,7 @@ void main() async {
|
|||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
MaterialApp(
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,10 +9,14 @@ environment:
|
|||||||
resolution: workspace
|
resolution: workspace
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
crypto: ^3.0.6
|
||||||
|
file_selector: ^1.0.3
|
||||||
wolf_3d_flutter:
|
wolf_3d_flutter:
|
||||||
|
wolf_3d_dart:
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
path: ^1.9.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -330,17 +330,21 @@ class WolfEngine {
|
|||||||
final SaveGameFile file = saveGameCodec.decode(bytes);
|
final SaveGameFile file = saveGameCodec.decode(bytes);
|
||||||
GameSessionSnapshot snapshot = file.snapshot;
|
GameSessionSnapshot snapshot = file.snapshot;
|
||||||
|
|
||||||
int gameIndex = snapshot.currentGameIndex;
|
int gameIndex = _availableGames.indexWhere(
|
||||||
if (gameIndex < 0 || gameIndex >= _availableGames.length) {
|
(game) =>
|
||||||
|
game.version == file.gameVersion &&
|
||||||
|
game.dataVersion.name == file.dataVersionName,
|
||||||
|
);
|
||||||
|
if (gameIndex < 0) {
|
||||||
gameIndex = _availableGames.indexWhere(
|
gameIndex = _availableGames.indexWhere(
|
||||||
(game) =>
|
(game) => game.version == file.gameVersion,
|
||||||
game.version == file.gameVersion &&
|
|
||||||
game.dataVersion.name == file.dataVersionName,
|
|
||||||
);
|
);
|
||||||
if (gameIndex < 0) {
|
}
|
||||||
gameIndex = _availableGames.indexWhere(
|
if (gameIndex < 0) {
|
||||||
(game) => game.version == file.gameVersion,
|
final int snapshotGameIndex = snapshot.currentGameIndex;
|
||||||
);
|
if (snapshotGameIndex >= 0 &&
|
||||||
|
snapshotGameIndex < _availableGames.length) {
|
||||||
|
gameIndex = snapshotGameIndex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -444,9 +444,90 @@ void main() {
|
|||||||
expect(retailEngine.hasLoadableSave, isFalse);
|
expect(retailEngine.hasLoadableSave, isFalse);
|
||||||
expect(sharewareEngine.hasLoadableSave, isTrue);
|
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({
|
WolfEngine _buildMultiGameEngine({
|
||||||
required _TestInput input,
|
required _TestInput input,
|
||||||
required Difficulty? difficulty,
|
required Difficulty? difficulty,
|
||||||
|
|||||||
@@ -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<String?> Function({
|
|
||||||
String? confirmButtonText,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Callback used to open a multi-file picker.
|
|
||||||
typedef PickFilesCallback = Future<List<XFile>> 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<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 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<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 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<void>? _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<void> ensureAudioShutdown() {
|
|
||||||
final existing = _shutdownFuture;
|
|
||||||
if (existing != null) {
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
final shutdown = engine.shutdownAudio();
|
|
||||||
_shutdownFuture = shutdown;
|
|
||||||
return shutdown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delegates directory picker + reload behavior.
|
|
||||||
Future<void> pickGameDataDirectory({
|
|
||||||
required void Function() notifyChanged,
|
|
||||||
}) async {
|
|
||||||
await _pickerManager.pickGameDataDirectory(notifyChanged: notifyChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delegates file picker + reload behavior.
|
|
||||||
Future<void> pickGameDataFiles({
|
|
||||||
required void Function() notifyChanged,
|
|
||||||
}) async {
|
|
||||||
await _pickerManager.pickGameDataFiles(notifyChanged: notifyChanged);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<void> Function()? onPickGameDataDirectory;
|
|
||||||
|
|
||||||
/// Invoked when the user requests selecting one or more data files.
|
|
||||||
final Future<void> 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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<Wolf3dApp> createState() => _Wolf3dAppState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _Wolf3dAppState extends State<Wolf3dApp> 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<void> _pickGameDataDirectory() {
|
|
||||||
return _manager.pickGameDataDirectory(
|
|
||||||
notifyChanged: () {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
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.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_engine.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_app_lifecycle_manager.dart' show GameAppLifecycleManager;
|
||||||
export 'managers/game_data_directory_persistence.dart'
|
export 'managers/game_data_directory_persistence.dart'
|
||||||
show DefaultGameDataDirectoryPersistence;
|
show DefaultGameDataDirectoryPersistence;
|
||||||
export 'managers/game_data_picker_manager.dart' show GameDataPickerManager;
|
|
||||||
export 'managers/game_display_manager.dart' show GameDisplayManager;
|
export 'managers/game_display_manager.dart' show GameDisplayManager;
|
||||||
export 'managers/game_persistence_manager.dart' show GamePersistenceManager;
|
export 'managers/game_persistence_manager.dart' show GamePersistenceManager;
|
||||||
export 'managers/game_renderer_mode_manager.dart'
|
export 'managers/game_renderer_mode_manager.dart'
|
||||||
@@ -31,22 +29,19 @@ export 'managers/game_screen_input_manager.dart'
|
|||||||
HostShortcutRegistry,
|
HostShortcutRegistry,
|
||||||
GameScreenInputManager,
|
GameScreenInputManager,
|
||||||
isAltEnterShortcut;
|
isAltEnterShortcut;
|
||||||
export 'managers/wolf3d_app_manager.dart' show Wolf3dAppManager;
|
|
||||||
export 'screens/audio_gallery.dart' show AudioGallery;
|
export 'screens/audio_gallery.dart' show AudioGallery;
|
||||||
export 'screens/debug_tools_screen.dart' show DebugToolsScreen;
|
export 'screens/debug_tools_screen.dart' show DebugToolsScreen;
|
||||||
export 'screens/game_screen.dart' show GameScreen;
|
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/sprite_gallery.dart' show SpriteGallery;
|
||||||
export 'screens/vga_gallery.dart' show VgaGallery;
|
export 'screens/vga_gallery.dart' show VgaGallery;
|
||||||
export 'widgets/gallery_game_selector.dart'
|
export 'widgets/gallery_game_selector.dart'
|
||||||
show GalleryGameSelector, formatGalleryGameTitle;
|
show GalleryGameSelector, formatGalleryGameTitle;
|
||||||
export 'widgets/wolf3d_app.dart' show Wolf3dApp;
|
|
||||||
export 'widgets/wolf_menu_shell.dart' show WolfMenuShell;
|
export 'widgets/wolf_menu_shell.dart' show WolfMenuShell;
|
||||||
|
|
||||||
/// Flutter-specific host facade built on top of [Wolf3dEngine].
|
/// Flutter-specific host facade built on top of [Wolf3dEngine].
|
||||||
///
|
///
|
||||||
/// This type keeps platform-neutral session/engine state in the Dart package
|
/// 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 {
|
class Wolf3dFlutterEngine extends Wolf3dEngine {
|
||||||
/// Creates an empty facade that must be initialized with [init].
|
/// Creates an empty facade that must be initialized with [init].
|
||||||
Wolf3dFlutterEngine({
|
Wolf3dFlutterEngine({
|
||||||
@@ -89,8 +84,9 @@ class Wolf3dFlutterEngine extends Wolf3dEngine {
|
|||||||
/// used when available. [additionalDirectories] are scanned after the
|
/// used when available. [additionalDirectories] are scanned after the
|
||||||
/// primary directory and are not persisted.
|
/// primary directory and are not persisted.
|
||||||
///
|
///
|
||||||
/// This method merges bundled package data (when present) with discovered
|
/// This method scans only configured external directories, deduplicating
|
||||||
/// external directories, deduplicating versions by [GameVersion].
|
/// discovered versions by [GameVersion]. Shared package code does not bundle
|
||||||
|
/// or import game data on behalf of host applications.
|
||||||
Future<Wolf3dFlutterEngine> init({
|
Future<Wolf3dFlutterEngine> init({
|
||||||
String? directory,
|
String? directory,
|
||||||
Iterable<String>? additionalDirectories,
|
Iterable<String>? additionalDirectories,
|
||||||
@@ -110,35 +106,6 @@ class Wolf3dFlutterEngine extends Wolf3dEngine {
|
|||||||
await dataDirectoryPersistence.saveDataDirectory(requestedDirectory);
|
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
|
// On non-web platforms, also scan local filesystem locations for
|
||||||
// user-supplied data folders so the host can pick up extra versions.
|
// user-supplied data folders so the host can pick up extra versions.
|
||||||
final Set<String> directoriesToScan = <String>{};
|
final Set<String> directoriesToScan = <String>{};
|
||||||
@@ -175,14 +142,4 @@ class Wolf3dFlutterEngine extends Wolf3dEngine {
|
|||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads an asset from the Flutter bundle, returning `null` when absent.
|
|
||||||
Future<ByteData?> _tryLoad(String path) async {
|
|
||||||
try {
|
|
||||||
return await rootBundle.load(path);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint("Asset not found: $path");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ dependencies:
|
|||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
audioplayers: ^6.6.0
|
audioplayers: ^6.6.0
|
||||||
file_selector: ^1.0.3
|
|
||||||
window_manager: ^0.5.1
|
window_manager: ^0.5.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
|||||||
@@ -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<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++;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void stopMusic() {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
disposeCallCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RecordingEngine extends Wolf3dFlutterEngine {
|
|
||||||
_RecordingEngine({required _NoopAudio audio}) : super(audioBackend: audio);
|
|
||||||
|
|
||||||
int initCallCount = 0;
|
|
||||||
String? lastDirectory;
|
|
||||||
List<String> lastAdditionalDirectories = <String>[];
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<Wolf3dFlutterEngine> init({
|
|
||||||
String? directory,
|
|
||||||
Iterable<String>? additionalDirectories,
|
|
||||||
}) async {
|
|
||||||
initCallCount++;
|
|
||||||
lastDirectory = directory;
|
|
||||||
lastAdditionalDirectories =
|
|
||||||
additionalDirectories?.toList(growable: false) ?? <String>[];
|
|
||||||
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>[
|
|
||||||
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, <String>['/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>[
|
|
||||||
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<void>(<Future<void>>[
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,83 +1,9 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.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_dart/wolf_3d_engine.dart';
|
||||||
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
|
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
|
||||||
|
|
||||||
class _NoopAudio implements EngineAudio {
|
|
||||||
@override
|
|
||||||
WolfensteinData? activeGame;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> debugSoundTest() async {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> init() async {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void playLevelMusic(Music music) {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void playMenuMusic() {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void playSoundEffect(SoundEffect effect) {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void playSoundEffectId(int sfxId) {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> stopAllAudio() async {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void stopMusic() {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
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++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('DefaultGameDataDirectoryPersistence', () {
|
group('DefaultGameDataDirectoryPersistence', () {
|
||||||
test('saves and loads configured directory', () async {
|
test('saves and loads configured directory', () async {
|
||||||
@@ -217,37 +143,4 @@ void main() {
|
|||||||
expect(await persistence.load(), isNull);
|
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -66,6 +67,33 @@ void main() {
|
|||||||
expect(wolf3d.isDebugEnabled, isTrue);
|
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', (
|
testWidgets('GameScreen hides debug FAB when debug mode is disabled', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
Reference in New Issue
Block a user