Compare commits

...

41 Commits

Author SHA1 Message Date
hans 6eb28ffcac feat: Add bloom shader variant and enhance shader architecture in README
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-04-21 18:02:10 +02:00
hans b8917272f7 docs: Update README with instructions for building and serving Flutter web release
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-25 16:41:16 +01:00
hans d63d742695 feat: Implement packaged games loading and update engine initialization to support seeded games
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-25 11:38:01 +01:00
hans d393ca98ec Refactor menu rendering and asset registry structure
- Updated SoftwareRenderer to incorporate MenuHeaderBand for handling spear variant menus and improved backdrop drawing.
- Refactored asset registry imports to organize menu-related assets under a dedicated menu structure.
- Enhanced game session snapshot tests to validate menu theme restoration for spear variant games.
- Added tests for classic menu presentation module to ensure palette consistency with canonical constants.
- Implemented tests for spear asset registry to verify correct menu VGA index resolutions.
- Created unit tests for MenuHeaderBand to validate functionality in rendering menu headers and sidebars.
- Adjusted HUD module imports to align with new menu structure.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-24 23:35:56 +01:00
hans 5c309c2240 Refactor menu structure and add Flutter-specific input and persistence layers
- Moved menu-related classes to a new structure under `src/menu/`.
- Introduced `WolfMenuPresentation` to handle menu art and mappings.
- Added `MenuManager` tests to ensure menu state reflects game status.
- Implemented `FlutterRendererSettingsPersistence` and `FlutterSaveGamePersistence` for managing settings and save files on desktop platforms.
- Created `Wolf3dFlutterInput` to handle keyboard and mouse input in a Flutter environment.
- Updated README to reflect new package structure and usage instructions.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-24 18:45:34 +01:00
hans 9f3651b122 feat: Update README to clarify wolf_3d_menu.dart functionality with color accessors
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-24 15:02:57 +01:00
hans ccc23e728c feat: Refactor color handling in NoGameDataScreen to utilize centralized color palette from WolfMenuPalette
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-24 15:01:16 +01:00
hans 62dca47d1d feat: Refactor color handling in NoGameDataScreen and ColorPalette for improved clarity and maintainability
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-24 14:58:47 +01:00
hans a08af99b6f feat: Update README files to provide detailed project structure and usage instructions for Wolfenstein 3D workspace
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-24 14:53:25 +01:00
hans 8a9be477e4 feat: Update GameVersion enum to include labels for game releases
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-24 14:53:05 +01:00
hans ce4dd8d61d 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>
2026-03-24 14:35:20 +01:00
hans b980174905 feat: Enhance DefaultRendererSettingsPersistence to support scoped settings for CLI and Flutter
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 19:54:22 +01:00
hans 3114700683 feat: Add arguments for data directory in CLI launch configuration
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 19:52:40 +01:00
hans 6784d2dd16 feat: Refactor game persistence and rendering management for CLI and Flutter hosts
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 19:48:02 +01:00
hans 6158a92fb0 feat: Enhance GameDataPickerManager and Wolf3dAppManager with improved directory and file picking capabilities
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 19:41:08 +01:00
hans 1394c20134 feat: Add GameDataPickerManager for managing game data selection flows
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 19:38:34 +01:00
hans 6441592534 feat: Introduce Wolf3dAppManager for managing audio shutdown and game data directory selection
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 19:37:32 +01:00
hans 88050dbc7d feat: Refactor Wolf3dApp to manage audio shutdown on dispose and add audio shutdown test
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 19:35:08 +01:00
hans 70b4fc3fe0 feat: Add file selector support and enhance game data directory management
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 19:30:50 +01:00
hans 569a3386a8 feat: Enhance CLI and GUI to support configurable game data directory persistence
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 19:19:50 +01:00
hans 5ef59d9980 feat: Refactor to use Wolf3dFlutterEngine across the application
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 18:56:51 +01:00
hans 5a2681e89b Moved all widgets and logic from gui app to Flutter package
- Implemented DebugToolsScreen for navigation to asset galleries.
- Created GameScreen to manage gameplay and renderer integrations.
- Added NoGameDataScreen to handle scenarios with missing game data.
- Developed SpriteGallery for visual browsing of sprite assets.
- Introduced VgaGallery for displaying VGA images from game data.
- Added GalleryGameSelector widget for selecting game variants in galleries.
- Created Wolf3dApp as the main application shell for managing game states.
- Implemented WolfMenuShell for consistent menu layouts across screens.
- Enhanced Wolf3d class to support debug mode and related functionalities.
- Updated pubspec.yaml to include window_manager dependency.
- Added tests for game screen lifecycle and debug mode functionalities.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 18:44:32 +01:00
hans cbe2633ceb feat: Remove triggerQuitOnInitForTest from GameScreen and add audio shutdown test
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 17:54:03 +01:00
hans 3a7ec50abf feat: Add triggerQuitOnInitForTest to GameScreen for deterministic testing
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 17:52:02 +01:00
hans ae3b0deb04 feat: Add app exit handler and corresponding tests for audio shutdown
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 17:48:59 +01:00
hans a7353e45b3 feat: Implement audio shutdown procedure for graceful app exit
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 17:38:18 +01:00
hans f4d6db2db0 feat: Enhance audio management by implementing shutdown procedures on exit
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 17:36:49 +01:00
hans fdf84b3a9d Refactor audio handling in Wolfenstein 3D
- Updated main.dart to use NativeSubprocessAudio instead of CliSubprocessAudio.
- Introduced DebugMusicPlayer interface for music playback.
- Implemented NativeSubprocessAudio for native audio handling with subprocesses.
- Added SilentAudio class as a no-op fallback for audio.
- Removed deprecated FlutterAudioAdapter and default audio backend implementations.
- Integrated Wolf3dPlatformAudio to manage audio across platforms, selecting between NativeSubprocessAudio and an embedded audio player.
- Updated wolf_3d_engine.dart to use SilentAudio as the default audio backend.
- Cleaned up audio-related files and ensured proper audio initialization and playback functionality.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 17:30:02 +01:00
hans ea6825341e feat: Add FPS toggle shortcut and corresponding input handling in CLI
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 17:12:27 +01:00
hans b88475882b feat: Implement audio backend with subprocess support and refactor audio handling
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 17:10:07 +01:00
hans 26c738b702 feat: Implement platform-specific persistence for renderer settings and save games
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 16:28:35 +01:00
hans dcfb2e8e02 refactor: Moved renderer package into Flutter package
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 16:12:03 +01:00
hans c4c8e4149a feat: Refactor MD5 hashing and update save game codec for compatibility with new payload format
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 16:03:12 +01:00
hans f05a861998 feat: Add exists method for checking save game file existence in CLI and Flutter persistence
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 15:39:02 +01:00
hans 3b1f8c80d1 Enhance save game codec tests for compatibility and add DOS-style file writing test
- Updated assertions in existing tests to allow for multiple valid values for `slot` and `createdAtMs` to accommodate legacy data.
- Added a new test to verify that the CompatibleSaveGameCodec correctly writes DOS-style description-prefixed files, ensuring proper encoding and structure.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 15:34:10 +01:00
hans 1ed63d5f9b feat: Implement CompatibleSaveGameCodec for block payload format and legacy support
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 15:18:49 +01:00
hans 85fddd3df5 feat: Add CompatibleSaveGameCodec for legacy W3DS support and enhance SaveGamePersistence with existence check
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 15:14:46 +01:00
hans de8bff1964 feat: Add original layout envelope save codec with encoding/decoding and tests
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 14:57:36 +01:00
hans db06f5f5cb feat: Implement save game functionality with encoding/decoding
- Added SaveGameCodec for encoding and decoding save game files.
- Introduced SaveGamePersistence interface for slot-based save game persistence.
- Implemented FlutterSaveGamePersistence for file-based save management on Flutter.
- Enhanced WolfEngine to support saving and loading game states.
- Updated menu manager to include save/load game options.
- Created tests for SaveGameCodec to ensure proper functionality.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 14:50:53 +01:00
hans 1a93b7d4a2 feat: Implement save and restore functionality for game session state, including player and entity states
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 14:37:58 +01:00
hans 7cb3f25c74 feat: Refactor rendering logic to use scheduled presentation and improve performance tracking
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 14:18:19 +01:00
155 changed files with 13469 additions and 3071 deletions
+4
View File
@@ -15,6 +15,10 @@
"request": "launch",
"type": "dart",
"program": "apps/wolf_3d_cli/bin/main.dart",
"args": [
"--data-directory",
"${workspaceFolder}/packages/wolf_3d_assets/assets"
],
"console": "externalTerminal"
},
{
+78 -4
View File
@@ -1,11 +1,85 @@
# wolf_dart
A new Flutter project.
Wolfenstein 3D workspace built with Dart + Flutter.
## Running
This repository is organized as a multi-package workspace:
### Linux requirements
- **Apps** in `apps/` (CLI host and Flutter GUI host)
- **Packages** in `packages/` (core engine, Flutter integration, and assets)
Linux (Debian/Ubuntu) requires the following packages to be installed:
The project expects you to provide legal Wolfenstein 3D game data files locally.
## Workspace Layout
### Apps
- [`apps/wolf_3d_gui/`](apps/wolf_3d_gui/README.md) — Flutter app host (desktop/web)
- [`apps/wolf_3d_cli/`](apps/wolf_3d_cli/README.md) — terminal CLI host
### Packages
- [`packages/wolf_3d_dart/`](packages/wolf_3d_dart/README.md) — core engine/runtime (non-Flutter)
- [`packages/wolf_3d_flutter/`](packages/wolf_3d_flutter/README.md) — Flutter host and UI integration
- [`packages/wolf_3d_assets/`](packages/wolf_3d_assets/README.md) — shared packaged asset trees
## Prerequisites
- Dart SDK `^3.11.1`
- Flutter SDK (for GUI app and Flutter package work)
### Linux native requirements
On Debian/Ubuntu, install:
`libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev lld`
## Quick Start
From workspace root:
```bash
dart pub get
```
Run GUI host:
```bash
cd apps/wolf_3d_gui
flutter run
```
Run CLI host:
```bash
cd apps/wolf_3d_cli
dart run bin/main.dart
```
If game data is not auto-discovered, pass a directory explicitly for CLI:
```bash
dart run bin/main.dart --data-directory /path/to/game-data
```
## Development Workflow
Typical contributor loop:
1. Update dependencies in relevant app/package (`dart pub get` or `flutter pub get`).
2. Run focused tests in the module you changed.
3. Run static analysis for the same module before submitting changes.
4. Keep docs in sync when command-line flags, platform support, or public APIs change.
## Testing
Run tests by module (examples):
```bash
cd packages/wolf_3d_dart && dart test
cd packages/wolf_3d_flutter && flutter test
cd apps/wolf_3d_gui && flutter test
```
## Related Docs
- App/package READMEs listed above for module-specific setup and architecture
+91
View File
@@ -0,0 +1,91 @@
# wolf_3d_cli
Terminal host application for running Wolfenstein 3D using the shared `wolf_3d_dart` engine.
## What This App Is
`wolf_3d_cli` is a pure Dart executable that:
- discovers Wolf3D game data on local disk,
- initializes `WolfEngine` with CLI input/audio backends,
- runs the terminal game loop.
Entrypoint: `bin/main.dart`
## Prerequisites
- Dart SDK `^3.11.1`
- A terminal with ANSI escape support
- Local Wolfenstein 3D game data files
## Setup
From this directory:
```bash
dart pub get
```
## Run
Default discovery:
```bash
dart run bin/main.dart
```
With an explicit game-data directory:
```bash
dart run bin/main.dart --data-directory /path/to/game-data
```
Short option form:
```bash
dart run bin/main.dart -d /path/to/game-data
```
Show CLI usage:
```bash
dart run bin/main.dart --help
```
## Runtime Architecture
At startup, `bin/main.dart` wires together:
- `WolfensteinLoader.discover(...)` for data discovery,
- `WolfEngine` for simulation/session state,
- `CliInput` for keyboard input,
- `NativeSubprocessAudio` for native-process audio playback,
- `CliGameLoop` for terminal rendering loop + renderer settings persistence.
The CLI host exits through engine callbacks (`onQuit`, `onGameWon`) after restoring terminal state.
## Development Commands
From this directory:
```bash
dart analyze
```
If module-level tests are added in this app later:
```bash
dart test
```
## Troubleshooting
- **No game data found**: pass `--data-directory` explicitly.
- **Terminal output artifacts**: resize terminal and rerun; ensure ANSI-capable terminal emulator.
- **No audio output**: verify host OS has required native audio command support.
## Related Modules
- Core runtime: [`../../packages/wolf_3d_dart/README.md`](../../packages/wolf_3d_dart/README.md)
- Flutter host alternative: [`../wolf_3d_gui/README.md`](../wolf_3d_gui/README.md)
- Workspace overview: [`../../README.md`](../../README.md)
+65 -19
View File
@@ -6,11 +6,12 @@ library;
import 'dart:io';
import 'package:wolf_3d_cli/cli_game_loop.dart';
import 'package:wolf_3d_cli/cli_renderer_settings_persistence.dart';
import 'package:args/args.dart';
import 'package:wolf_3d_dart/wolf_3d_audio.dart';
import 'package:wolf_3d_dart/wolf_3d_data.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_host.dart';
import 'package:wolf_3d_dart/wolf_3d_input.dart';
/// Restores terminal state before exiting the process with [code].
@@ -21,39 +22,79 @@ void exitCleanly(int code) {
exit(code);
}
/// Launches the CLI renderer against the bundled retail asset set.
void main() async {
stdout.write("Discovering game data...");
// Resolve the asset package relative to this executable so the CLI can run
// from the repo without additional configuration.
final scriptUri = Platform.script;
/// Launches the CLI renderer using discoverable or user-provided game data.
void main(List<String> arguments) async {
final argParser = ArgParser()
..addOption(
'data-directory',
abbr: 'd',
valueHelp: 'path',
help: 'Directory containing Wolf3D data files.',
)
..addFlag(
'help',
abbr: 'h',
negatable: false,
help: 'Show usage information.',
);
final targetUri = scriptUri.resolve(
'../../../packages/wolf_3d_assets/assets/retail',
);
final targetPath = targetUri.toFilePath();
late final ArgResults parsedArgs;
try {
parsedArgs = argParser.parse(arguments);
} on FormatException catch (e) {
stderr.writeln(e.message);
stderr.writeln('Usage: wolf_3d_cli [options]');
stderr.writeln(argParser.usage);
exitCleanly(64);
}
if (parsedArgs.flag('help')) {
stdout.writeln('Usage: wolf_3d_cli [options]');
stdout.writeln(argParser.usage);
exitCleanly(0);
}
final String? rawPath = parsedArgs.option('data-directory');
final String? dataDirectory = rawPath != null && rawPath.trim().isNotEmpty
? rawPath.trim()
: null;
if (dataDirectory != null) {
stdout.write('Discovering game data in "$dataDirectory"...');
} else {
stdout.write('Discovering game data...');
}
final availableGames = await WolfensteinLoader.discover(
directoryPath: targetPath,
directoryPath: dataDirectory,
recursive: true,
);
if (availableGames.isEmpty) {
stderr.writeln('\nNo Wolf3D game files were found at: $targetPath');
stderr.writeln(
'Please provide valid game data files before starting the CLI host.',
);
if (dataDirectory == null) {
stderr.writeln('\nNo Wolf3D game data was discovered.');
stderr.writeln('Provide a game-data directory with one of these flags:');
stderr.writeln(' --data-directory <path>');
stderr.writeln(' -d <path>');
} else {
stderr.writeln('\nNo Wolf3D game files were found at: $dataDirectory');
stderr.writeln(
'Please provide valid game data files before starting the CLI host.',
);
}
exitCleanly(1);
}
CliGameLoop? gameLoop;
late final WolfEngine engine;
void stopAndExit(int code) {
gameLoop?.stop();
engine.audio.dispose();
exitCleanly(code);
}
final engine = WolfEngine(
engine = WolfEngine(
availableGames: availableGames.values.toList(growable: false),
startingEpisode: 0,
frameBuffer: FrameBuffer(
@@ -61,13 +102,18 @@ void main() async {
stdout.terminalLines,
),
input: CliInput(),
engineAudio: NativeSubprocessAudio(),
onGameWon: () => stopAndExit(0),
onQuit: () => stopAndExit(0),
saveGamePersistence: DefaultSaveGamePersistence(),
);
await engine.audio.init();
engine.init();
final persistence = CliRendererSettingsPersistence();
final persistence = DefaultRendererSettingsPersistence(
hostKey: rendererSettingsHostCli,
);
final WolfRendererSettings? saved = await persistence.load();
gameLoop = CliGameLoop(
@@ -1,42 +0,0 @@
/// CLI host adapter for persisting renderer settings to a local file.
library;
import 'dart:io';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
/// Persists [WolfRendererSettings] as JSON to a local file.
///
/// The default path is `~/.wolf3d_cli_settings.json`.
/// An alternative [filePath] can be supplied at construction time.
class CliRendererSettingsPersistence extends RendererSettingsPersistence
with JsonRendererSettingsPersistence {
CliRendererSettingsPersistence({String? filePath})
: _filePath =
filePath ??
'${Platform.environment['HOME'] ?? '.'}/.wolf3d_cli_settings.json';
final String _filePath;
@override
Future<String?> readRaw() async {
try {
final File f = File(_filePath);
if (!f.existsSync()) {
return null;
}
return await f.readAsString();
} catch (_) {
return null;
}
}
@override
Future<void> writeRaw(String json) async {
try {
await File(_filePath).writeAsString(json, flush: true);
} catch (_) {
// Best-effort; never crash the loop on a write failure.
}
}
}
+1 -1
View File
@@ -8,5 +8,5 @@ environment:
resolution: workspace
dependencies:
args: ^2.7.0
wolf_3d_dart:
wolf_3d_assets:
+99
View File
@@ -0,0 +1,99 @@
# wolf_3d_gui
Flutter GUI host application for running Wolfenstein 3D with the shared engine and Flutter integration package.
## What This App Is
`wolf_3d_gui` is the primary Flutter app host in this workspace.
It:
- initializes `Wolf3dFlutterEngine`,
- discovers available game data directories,
- manages game-data selection and session flow,
- renders gameplay through Flutter-native UI and renderer widgets.
Entrypoint: `lib/main.dart`
## Prerequisites
- Flutter SDK
- Dart SDK `^3.11.1`
- Local Wolfenstein 3D game data files
### Linux native requirements
On Debian/Ubuntu, install:
`libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev lld`
## Setup
From this directory:
```bash
flutter pub get
```
## Run
Run on your default connected Flutter target:
```bash
flutter run
```
Examples by target:
```bash
flutter run -d linux
flutter run -d chrome
```
## Test and Analyze
From this directory:
```bash
flutter test
flutter analyze
```
## App Flow Overview
Startup sequence:
1. `lib/main.dart` creates and initializes `Wolf3dFlutterEngine`.
2. Engine init discovers configured game-data directories.
3. App shell (`Wolf3dGuiApp`) drives selection/navigation.
4. Gameplay screen builds engine session + renderer mode and runs loop.
Game data directory selection/persistence is managed by app managers and Flutter package persistence helpers.
## Troubleshooting
- **No game data discovered**: choose a valid directory in the picker and ensure files are present.
- **Linux build/runtime issues**: verify native dependency packages are installed.
- **Web target limitations**: use desktop target for full native-audio/path behavior.
- **Web release build wont load when opening `index.html` directly**: Flutter web output must be served over `http://` or `https://`, not opened with `file://`.
Build and serve locally:
```bash
flutter build web --release
python3 -m http.server 8080 -d build/web
```
Then open `http://localhost:8080` in your browser.
If deploying under a subpath, build with matching base href, for example:
```bash
flutter build web --release --base-href /wolf_dart/
```
## Related Modules
- Flutter integration package: [`../../packages/wolf_3d_flutter/README.md`](../../packages/wolf_3d_flutter/README.md)
- Core runtime package: [`../../packages/wolf_3d_dart/README.md`](../../packages/wolf_3d_dart/README.md)
- Workspace overview: [`../../README.md`](../../README.md)
@@ -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;
}
+12 -88
View File
@@ -1,103 +1,27 @@
/// Flutter entry point for the GUI host application.
///
/// The GUI bootstraps bundled and discoverable game data through [Wolf3d]
/// before presenting the game-selection flow.
library;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
import 'package:wolf_3d_gui/screens/game_screen.dart';
import 'packaged_games_loader.dart';
import 'wolf3d_gui_app.dart';
/// Creates the application shell after loading available Wolf3D data sets.
void main() async {
WidgetsFlutterBinding.ensureInitialized();
if (_supportsDesktopWindowing) {
await windowManager.ensureInitialized();
}
final seededGames = await loadPackagedGames();
final Wolf3d wolf3d = await Wolf3d().init();
final Wolf3dFlutterEngine wolf3d = await Wolf3dFlutterEngine(
debug: kDebugMode,
).init(seededGames: seededGames);
if (kDebugMode) {
wolf3d.enableMenuHeaderBandDebugLogging(prefix: '[wolf_3d_gui]');
}
runApp(
MaterialApp(
darkTheme: ThemeData.dark(useMaterial3: true),
theme: ThemeData.light(useMaterial3: true),
themeMode: ThemeMode.system,
home: wolf3d.availableGames.isEmpty
? const _NoGameDataScreen()
: GameScreen(wolf3d: wolf3d),
home: Wolf3dGuiApp(engine: wolf3d),
),
);
}
/// Whether desktop window-management APIs should be initialized for this host.
bool get _supportsDesktopWindowing {
if (kIsWeb) {
return false;
}
return switch (defaultTargetPlatform) {
TargetPlatform.linux ||
TargetPlatform.windows ||
TargetPlatform.macOS => true,
_ => false,
};
}
class _NoGameDataScreen extends StatelessWidget {
const _NoGameDataScreen();
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF140000),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640),
child: DecoratedBox(
decoration: BoxDecoration(
color: const Color(0xFF590002),
border: Border.all(color: const Color(0xFFB00000), width: 2),
),
child: const Padding(
padding: EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'WOLF3D DATA NOT FOUND',
style: TextStyle(
color: Color(0xFFFFF700),
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 16),
Text(
'No game files were discovered.\n\n'
'Add Wolfenstein 3D data files to one of these locations:\n'
'- packages/wolf_3d_assets/assets/retail\n'
'- packages/wolf_3d_assets/assets/shareware\n'
'- or a discoverable local game-data folder.\n\n'
'Restart the app after adding the files.',
style: TextStyle(
color: Colors.white,
fontSize: 15,
height: 1.4,
),
),
],
),
),
),
),
),
),
);
}
}
@@ -0,0 +1,464 @@
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 {
static const WolfMenuPresentation _menu = WolfMenuPresentation.classic();
/// 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 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 Color(_menu.emphasisColor);
case GameDataVersionState.checksumWarning:
return Color(_menu.warningColor);
case GameDataVersionState.incomplete:
return Color(_menu.mutedColor);
}
}
static Color _fileStateColor(GameDataFileState state) {
switch (state) {
case GameDataFileState.ready:
return Color(_menu.emphasisColor);
case GameDataFileState.warning:
return Color(_menu.warningColor);
case GameDataFileState.missing:
return Color(_menu.mutedColor);
}
}
@override
Widget build(BuildContext context) {
final List<GameDataVersionAnalysis> readyVersions =
scanResult?.readyVersions ?? <GameDataVersionAnalysis>[];
return Scaffold(
backgroundColor: Color(_menu.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: Color(_menu.panelColor),
border: Border.all(
color: Color(_menu.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: Color(_menu.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: Color(_menu.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: Color(_menu.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: Color(_menu.bodyColor),
fontSize: 13,
height: 1.3,
),
),
),
if (scanResult != null) ...[
const SizedBox(height: 18),
_ScanSummary(
bodyColor: Color(_menu.bodyColor),
mutedColor: Color(_menu.mutedColor),
scanResult: scanResult!,
),
const SizedBox(height: 12),
...scanResult!.versions.map(
(GameDataVersionAnalysis analysis) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _VersionCard(
panelColor: Color(_menu.backgroundColor),
borderColor: Color(_menu.borderColor),
titleColor: Color(_menu.titleColor),
bodyColor: Color(_menu.bodyColor),
mutedColor: Color(_menu.mutedColor),
analysis: analysis,
),
),
),
],
if (readyVersions.isNotEmpty) ...[
const SizedBox(height: 4),
DropdownButtonFormField<GameVersion>(
initialValue:
selectedReadyVersion ??
readyVersions.first.version,
dropdownColor: Color(_menu.panelColor),
style: TextStyle(
color: Color(_menu.bodyColor),
),
decoration: InputDecoration(
labelText: 'Complete version',
labelStyle: TextStyle(
color: Color(_menu.bodyColor),
),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: Color(_menu.borderColor),
),
),
),
items: readyVersions
.map(
(
GameDataVersionAnalysis analysis,
) => DropdownMenuItem<GameVersion>(
value: analysis.version,
child: Text(
'${analysis.version.label} (${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: Color(_menu.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: Color(_menu.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(
analysis.version.label,
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,54 @@
import 'package:flutter/foundation.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
typedef PackagedGameDataLoader =
Future<WolfensteinData> Function({
required GameVersion version,
required String assetDirectory,
});
Future<List<WolfensteinData>> loadPackagedGames({
PackagedGameDataLoader? loader,
}) async {
final PackagedGameDataLoader effectiveLoader =
loader ??
({required version, required assetDirectory}) =>
Wolf3dFlutterEngine.loadGameDataFromAssets(
version: version,
packageName: 'wolf_3d_assets',
assetDirectory: assetDirectory,
);
final List<WolfensteinData> games = [];
Future<void> tryLoad({
required GameVersion version,
required String assetDirectory,
}) async {
try {
games.add(
await effectiveLoader(
version: version,
assetDirectory: assetDirectory,
),
);
} catch (e) {
debugPrint(
'Packaged game load skipped for ${version.label} ($assetDirectory): $e',
);
}
}
await tryLoad(version: GameVersion.retail, assetDirectory: 'assets/retail');
await tryLoad(
version: GameVersion.shareware,
assetDirectory: 'assets/shareware',
);
await tryLoad(
version: GameVersion.spearOfDestinyDemo,
assetDirectory: 'assets/sod/shareware',
);
return games;
}
@@ -1,432 +0,0 @@
/// Active gameplay screen for the Flutter host.
library;
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:window_manager/window_manager.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_input.dart';
import 'package:wolf_3d_dart/wolf_3d_renderer.dart';
import 'package:wolf_3d_flutter/renderer_settings_persistence_flutter.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
import 'package:wolf_3d_flutter/wolf_3d_input_flutter.dart';
import 'package:wolf_3d_gui/screens/debug_tools_screen.dart';
import 'package:wolf_3d_renderer/wolf_3d_ascii_renderer.dart';
import 'package:wolf_3d_renderer/wolf_3d_flutter_renderer.dart';
import 'package:wolf_3d_renderer/wolf_3d_glsl_renderer.dart';
enum RendererMode {
/// Software pixel renderer presented via decoded framebuffer images.
software,
/// Text-mode renderer for debugging and retro terminal aesthetics.
ascii,
/// GLSL renderer with optional CRT-style post processing.
hardware,
}
/// Semantic actions that host-level shortcuts can trigger.
///
/// These intents are intentionally UI-host focused (windowing, app shell), not
/// engine gameplay actions. The engine continues to receive input through
/// [Wolf3dFlutterInput].
enum HostShortcutIntent {
/// Toggle desktop fullscreen on/off.
toggleFullscreen,
}
/// Declarative mapping from a key pattern to a host shortcut intent.
///
/// [matches] identifies whether a key event should trigger this binding.
/// [suppressedActions] are one-frame engine actions that should be blocked
/// when the binding is consumed (for example, blocking `interact` on Alt+Enter
/// so Enter does not activate menu selections).
class HostShortcutBinding {
/// Predicate that returns true when this shortcut should fire.
final bool Function(KeyEvent event) matches;
/// Host operation to perform when [matches] succeeds.
final HostShortcutIntent intent;
/// Engine actions to suppress for a single input update tick.
final Set<WolfInputAction> suppressedActions;
/// Creates a host shortcut binding with optional suppressed engine actions.
const HostShortcutBinding({
required this.matches,
required this.intent,
this.suppressedActions = const <WolfInputAction>{},
});
}
/// Ordered set of host shortcut bindings.
///
/// The first binding whose [HostShortcutBinding.matches] returns true wins.
/// This keeps behavior deterministic when multiple shortcuts could overlap.
class HostShortcutRegistry {
/// Ordered bindings consulted for each incoming key-down event.
final List<HostShortcutBinding> bindings;
/// Creates a registry with explicit [bindings].
const HostShortcutRegistry({
required this.bindings,
});
/// Returns the first binding that matches [event], or null when none do.
HostShortcutBinding? match(KeyEvent event) {
for (final HostShortcutBinding binding in bindings) {
if (binding.matches(event)) {
return binding;
}
}
return null;
}
/// Default host shortcuts used by [GameScreen].
///
/// Alt+Enter toggles fullscreen and suppresses the engine `interact` action
/// for one frame so Enter does not also activate menu/game interactions.
static const HostShortcutRegistry defaults = HostShortcutRegistry(
bindings: <HostShortcutBinding>[
HostShortcutBinding(
matches: _isAltEnterShortcut,
intent: HostShortcutIntent.toggleFullscreen,
suppressedActions: <WolfInputAction>{WolfInputAction.interact},
),
],
);
}
/// Optional imperative host shortcut override.
///
/// Return true when the event was fully handled. The handler receives the
/// shared [Wolf3dFlutterInput] so it can suppress engine actions as needed.
typedef HostShortcutHandler =
bool Function(
KeyEvent event,
Wolf3dFlutterInput input,
);
/// Launches a [WolfEngine] via [Wolf3d] and exposes renderer/input integrations.
class GameScreen extends StatefulWidget {
/// Shared application facade owning the engine, audio, and input.
final Wolf3d wolf3d;
/// Optional host-level shortcut override.
///
/// Return `true` when the event was consumed. Handlers may call
/// [Wolf3dFlutterInput.suppressActionOnce] to keep actions from reaching the
/// engine update loop.
final HostShortcutHandler? hostShortcutHandler;
/// Declarative host shortcut registry used when [hostShortcutHandler] is null.
final HostShortcutRegistry hostShortcutRegistry;
/// Creates a gameplay screen driven by [wolf3d].
const GameScreen({
required this.wolf3d,
this.hostShortcutHandler,
this.hostShortcutRegistry = HostShortcutRegistry.defaults,
super.key,
});
@override
State<GameScreen> createState() => _GameScreenState();
}
class _GameScreenState extends State<GameScreen> {
late final WolfEngine _engine;
final FlutterRendererSettingsPersistence _persistence =
FlutterRendererSettingsPersistence();
/// Mirrors [WolfRendererSettings.mode] into the Flutter renderer enum.
RendererMode _rendererMode = RendererMode.hardware;
@override
void initState() {
super.initState();
const Set<WolfRendererMode> supportedModes = <WolfRendererMode>{
WolfRendererMode.hardware,
WolfRendererMode.software,
WolfRendererMode.ascii,
};
_engine = widget.wolf3d.launchEngine(
rendererCapabilities: const WolfRendererCapabilities(
supportedModes: supportedModes,
supportsAsciiThemes: true,
supportsHardwareEffects: true,
supportsBloom: true,
supportsFpsCounter: true,
),
rendererSettings: const WolfRendererSettings(
mode: WolfRendererMode.hardware,
),
onRendererSettingsChanged: (settings) {
unawaited(_persistence.save(settings));
if (mounted) {
setState(() {
_syncRendererModeFrom(settings);
});
}
},
onGameWon: () {
_engine.difficulty = null;
widget.wolf3d.clearActiveDifficulty();
Navigator.of(context).pop();
},
onQuit: () {
SystemNavigator.pop();
},
);
_syncRendererModeFrom(_engine.rendererSettings);
_loadPersistedSettings();
}
Future<void> _loadPersistedSettings() async {
final WolfRendererSettings? saved = await _persistence.load();
if (saved != null && mounted) {
_engine.updateRendererSettings(saved);
}
}
void _syncRendererModeFrom(WolfRendererSettings settings) {
switch (settings.mode) {
case WolfRendererMode.hardware:
_rendererMode = RendererMode.hardware;
break;
case WolfRendererMode.software:
_rendererMode = RendererMode.software;
break;
case WolfRendererMode.ascii:
case WolfRendererMode.sixel:
_rendererMode = RendererMode.ascii;
break;
}
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: _engine.difficulty != null,
onPopInvokedWithResult: (didPop, _) {
if (!didPop && _engine.difficulty == null) {
widget.wolf3d.input.queueBackAction();
}
},
child: Scaffold(
floatingActionButton: kDebugMode && _engine.difficulty != null
? FloatingActionButton(
onPressed: _openDebugTools,
tooltip: 'Open Debug Tools',
child: const Icon(Icons.bug_report),
)
: null,
body: LayoutBuilder(
builder: (context, constraints) {
return Listener(
onPointerDown: (event) {
widget.wolf3d.input.onPointerDown(event);
},
onPointerUp: widget.wolf3d.input.onPointerUp,
onPointerMove: widget.wolf3d.input.onPointerMove,
onPointerHover: widget.wolf3d.input.onPointerMove,
child: Stack(
children: [
_buildRenderer(),
if (!_engine.isInitialized)
Container(
color: Colors.black,
child: const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(color: Colors.teal),
SizedBox(height: 20),
Text(
"GET PSYCHED!",
style: TextStyle(
color: Colors.teal,
fontFamily: 'monospace',
),
),
],
),
),
),
// A second full-screen overlay keeps the presentation simple while
// the engine is still warming up or decoding the first frame.
if (!_engine.isInitialized)
Container(
color: Colors.black,
child: const Center(
child: CircularProgressIndicator(color: Colors.teal),
),
),
],
),
);
},
),
),
);
}
Widget _buildRenderer() {
// Keep all renderers behind the same engine so mode switching does not
// reset level state or audio playback.
final WolfRendererSettings settings = _engine.rendererSettings;
switch (_rendererMode) {
case RendererMode.software:
return WolfFlutterRenderer(
engine: _engine,
onKeyEvent: _handleRendererKeyEvent,
);
case RendererMode.ascii:
final AsciiTheme theme =
settings.asciiThemeId == WolfRendererSettings.asciiThemeQuadrant
? AsciiThemes.quadrant
: AsciiThemes.blocks;
return WolfAsciiRenderer(
engine: _engine,
theme: theme,
onKeyEvent: _handleRendererKeyEvent,
);
case RendererMode.hardware:
return WolfGlslRenderer(
engine: _engine,
effectsEnabled: settings.hardwareEffectsEnabled,
bloomEnabled: settings.bloomEnabled,
onKeyEvent: _handleRendererKeyEvent,
onUnavailable: _onGlslUnavailable,
);
}
}
void _handleRendererKeyEvent(KeyEvent event) {
if (event is! KeyDownEvent) {
return;
}
// Host shortcuts must be processed before game actions so they can
// suppress overlapping keys (for example Alt+Enter consuming Enter).
if (_handleHostShortcut(event)) {
return;
}
if (event.logicalKey == widget.wolf3d.input.rendererToggleKey) {
_engine.cycleRendererMode();
return;
}
if (event.logicalKey == widget.wolf3d.input.fpsToggleKey) {
setState(() => _engine.toggleFpsCounter());
return;
}
if (event.logicalKey == widget.wolf3d.input.asciiThemeCycleKey) {
if (_rendererMode == RendererMode.ascii) {
_engine.cycleAsciiTheme();
} else if (_rendererMode == RendererMode.hardware) {
_engine.toggleHardwareEffects();
}
}
}
void _onGlslUnavailable() {
if (!mounted || _rendererMode != RendererMode.hardware) {
return;
}
_engine.updateRendererSettings(
_engine.rendererSettings.copyWith(mode: WolfRendererMode.software),
);
}
void _openDebugTools() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => DebugToolsScreen(wolf3d: widget.wolf3d),
),
);
}
bool _handleHostShortcut(KeyEvent event) {
final HostShortcutHandler? customHandler = widget.hostShortcutHandler;
if (customHandler != null) {
// Custom handlers take full precedence to support future menu-driven
// rebinding/override systems without modifying this screen.
return customHandler(event, widget.wolf3d.input);
}
final HostShortcutBinding? binding = widget.hostShortcutRegistry.match(
event,
);
if (binding == null) {
return false;
}
// Suppress conflicting gameplay/menu actions for one update frame.
for (final WolfInputAction action in binding.suppressedActions) {
widget.wolf3d.input.suppressActionOnce(action);
}
switch (binding.intent) {
case HostShortcutIntent.toggleFullscreen:
unawaited(_toggleFullscreen());
}
return true;
}
/// Toggles desktop fullscreen state when supported by the host platform.
///
/// This no-ops on unsupported targets and safely ignores missing plugin
/// hosts to keep gameplay input resilient in embedded/test environments.
Future<void> _toggleFullscreen() async {
if (!_supportsDesktopWindowing) {
return;
}
try {
final bool isFullScreen = await windowManager.isFullScreen();
await windowManager.setFullScreen(!isFullScreen);
} on MissingPluginException {
// No-op on hosts where the window manager plugin is unavailable.
}
}
/// Whether runtime desktop window management APIs are expected to work.
bool get _supportsDesktopWindowing {
if (kIsWeb) {
return false;
}
return switch (defaultTargetPlatform) {
TargetPlatform.linux ||
TargetPlatform.windows ||
TargetPlatform.macOS => true,
_ => false,
};
}
}
/// Returns true when [event] is Enter/NumpadEnter while Alt is pressed.
bool _isAltEnterShortcut(KeyEvent event) {
final bool isEnter =
event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey == LogicalKeyboardKey.numpadEnter;
if (!isEnter) {
return false;
}
final Set<LogicalKeyboardKey> pressedKeys =
HardwareKeyboard.instance.logicalKeysPressed;
return pressedKeys.contains(LogicalKeyboardKey.altLeft) ||
pressedKeys.contains(LogicalKeyboardKey.altRight) ||
pressedKeys.contains(LogicalKeyboardKey.alt);
}
+156
View File
@@ -0,0 +1,156 @@
library;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
import 'game_data_picker_manager.dart';
import 'no_game_data_screen.dart';
/// GUI-host application shell that owns setup/import UX.
class Wolf3dGuiApp extends StatefulWidget {
/// Creates the GUI host shell for a prepared engine facade.
const Wolf3dGuiApp({
super.key,
required this.engine,
this.pickerManager,
});
/// Shared initialized facade that owns game data, input, and audio services.
final Wolf3dFlutterEngine engine;
/// Optional injected picker manager used by tests.
final GameDataPickerManager? pickerManager;
@override
State<Wolf3dGuiApp> createState() => _Wolf3dGuiAppState();
}
class _Wolf3dGuiAppState extends State<Wolf3dGuiApp>
with WidgetsBindingObserver {
late final GameDataPickerManager _pickerManager;
Future<void>? _shutdownFuture;
@override
void initState() {
super.initState();
_pickerManager =
widget.pickerManager ?? GameDataPickerManager(engine: widget.engine);
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
unawaited(_ensureAudioShutdown());
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.detached) {
unawaited(_ensureAudioShutdown());
}
}
Future<void> _ensureAudioShutdown() {
final Future<void>? existing = _shutdownFuture;
if (existing != null) {
return existing;
}
final Future<void> shutdown = widget.engine.shutdownAudio();
_shutdownFuture = shutdown;
return shutdown;
}
Future<void> _pickGameDataDirectory() {
return _runPickerAction(
() => _pickerManager.pickGameDataDirectory(
notifyChanged: () {
if (mounted) {
setState(() {});
}
},
),
);
}
Future<void> _pickGameDataFiles() {
return _runPickerAction(
() => _pickerManager.pickGameDataFiles(
notifyChanged: () {
if (mounted) {
setState(() {});
}
},
),
);
}
Future<void> _runPickerAction(Future<void> Function() action) async {
await action();
final GameDataVersionAnalysis? soleReadyVersion =
_pickerManager.scanResult?.soleReadyVersion;
if (soleReadyVersion == null ||
!mounted ||
widget.engine.availableGames.isNotEmpty) {
return;
}
if (_pickerManager.selectedReadyVersion != soleReadyVersion.version) {
setState(() {
_pickerManager.selectReadyVersion(soleReadyVersion.version);
});
}
await _useSelectedData();
}
Future<void> _useSelectedData() {
return _pickerManager.useSelectedData(
notifyChanged: () {
if (mounted) {
setState(() {});
}
},
);
}
Future<void> _importSelectedData() {
return _pickerManager.importSelectedData(
notifyChanged: () {
if (mounted) {
setState(() {});
}
},
);
}
@override
Widget build(BuildContext context) {
return widget.engine.availableGames.isEmpty
? NoGameDataScreen(
configuredDataDirectory: widget.engine.configuredDataDirectory,
onPickGameDataDirectory: _pickGameDataDirectory,
onPickGameDataFiles: _pickGameDataFiles,
onUseSelectedData: _useSelectedData,
onImportSelectedData: _importSelectedData,
onSelectReadyVersion: (version) {
if (version == null) {
return;
}
setState(() {
_pickerManager.selectReadyVersion(version);
});
},
isLoadingGameData: _pickerManager.isLoadingGameData,
pickerError: _pickerManager.pickerError,
scanResult: _pickerManager.scanResult,
selectedReadyVersion: _pickerManager.selectedReadyVersion,
)
: GameScreen(wolf3d: widget.engine);
}
}
@@ -7,6 +7,7 @@
#include "generated_plugin_registrant.h"
#include <audioplayers_linux/audioplayers_linux_plugin.h>
#include <file_selector_linux/file_selector_plugin.h>
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
#include <window_manager/window_manager_plugin.h>
@@ -14,6 +15,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar);
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin");
screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar);
@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux
file_selector_linux
screen_retriever_linux
window_manager
)
+5 -3
View File
@@ -9,13 +9,15 @@ environment:
resolution: workspace
dependencies:
crypto: ^3.0.6
file_selector: ^1.0.3
wolf_3d_flutter:
wolf_3d_dart:
wolf_3d_renderer: any
wolf_3d_flutter: any
window_manager: ^0.5.1
wolf_3d_assets:
flutter:
sdk: flutter
path: ^1.9.1
dev_dependencies:
flutter_test:
@@ -0,0 +1,317 @@
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,
Iterable<WolfensteinData>? seededGames,
}) 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,91 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_gui/packaged_games_loader.dart';
void main() {
group('loadPackagedGames', () {
test('loads known packaged directories in order', () async {
final requested = <(GameVersion, String)>[];
final games = await loadPackagedGames(
loader: ({required version, required assetDirectory}) async {
requested.add((version, assetDirectory));
return _buildTestData(version);
},
);
expect(requested, <(GameVersion, String)>[
(GameVersion.retail, 'assets/retail'),
(GameVersion.shareware, 'assets/shareware'),
(GameVersion.spearOfDestinyDemo, 'assets/sod/shareware'),
]);
expect(games.map((g) => g.version), <GameVersion>[
GameVersion.retail,
GameVersion.shareware,
GameVersion.spearOfDestinyDemo,
]);
});
test('skips failing packaged loads and keeps successful ones', () async {
final games = await loadPackagedGames(
loader: ({required version, required assetDirectory}) async {
if (version == GameVersion.shareware) {
throw Exception('shareware unavailable');
}
return _buildTestData(version);
},
);
expect(games.map((g) => g.version), <GameVersion>[
GameVersion.retail,
GameVersion.spearOfDestinyDemo,
]);
});
});
}
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.shareware
? SharewareAssetRegistry()
: RetailAssetRegistry(),
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)));
@@ -0,0 +1,214 @@
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,
Iterable<WolfensteinData>? seededGames,
}) 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);
});
}
+52
View File
@@ -0,0 +1,52 @@
# wolf_3d_assets
Shared asset package that exposes Wolfenstein 3D game-data trees for workspace apps.
## What This Package Is
`wolf_3d_assets` is a content package used by Flutter hosts to bundle known asset directories.
This package does not expose runtime logic APIs; it exists to provide structured asset roots consumed by hosts and tooling.
## Current Asset Layout
Configured asset roots (see `pubspec.yaml`):
- `assets/retail/`
- `assets/shareware/`
- `assets/sod/shareware/`
## Setup
From this directory:
```bash
flutter pub get
```
## How Hosts Consume It
- Dependent Flutter apps/packages include `wolf_3d_assets` as a workspace dependency.
- Flutter build tooling includes directories listed in this package `pubspec.yaml`.
- Runtime data discovery/selection is handled by host and engine packages.
## Maintenance Guidelines
When updating assets:
1. Preserve directory structure conventions by game/version.
2. Update `pubspec.yaml` asset entries if new top-level roots are introduced.
3. Validate that host apps still discover and load the updated data sets.
## Verification
Because this is a content package, verification is integration-focused:
- run `apps/wolf_3d_gui` and confirm expected data appears,
- run `apps/wolf_3d_cli` with explicit `--data-directory` when testing local trees.
## Related Modules
- Core runtime: [`../wolf_3d_dart/README.md`](../wolf_3d_dart/README.md)
- Flutter integration package: [`../wolf_3d_flutter/README.md`](../wolf_3d_flutter/README.md)
- Workspace overview: [`../../README.md`](../../README.md)
+296
View File
@@ -0,0 +1,296 @@
# wolf_3d_dart
Core non-Flutter Wolfenstein 3D runtime package used by both CLI and Flutter hosts.
## What This Package Provides
`wolf_3d_dart` contains the platform-neutral simulation/runtime surface:
- engine and session lifecycle,
- game-data loading and data types,
- renderer backends and frame-buffer abstractions,
- menu state/navigation models,
- input/audio host abstractions,
- entity and gameplay logic.
## Public Library Surfaces
Primary entry libraries in `lib/`:
- `wolf_3d_engine.dart` — engine exports and runtime contracts.
- `wolf_3d_data.dart` / `wolf_3d_data_types.dart` — game-data discovery and DTOs.
- `wolf_3d_renderer.dart` — rendering/backends integration points.
- `wolf_3d_audio.dart` — audio interfaces and host backends.
- `wolf_3d_input.dart` — input abstractions.
- `wolf_3d_menu.dart` — menu models/managers and the registry-backed `WolfMenuPresentation` helpers for hosts.
- `wolf_3d_host.dart` — host-level glue contracts.
Implementation details live under `lib/src/`.
## Setup
From this directory:
```bash
dart pub get
```
## Development Commands
From this directory:
```bash
dart analyze
dart test
```
## Architecture Notes
- Hosts own platform concerns (windowing, lifecycle, platform input wiring).
- This package owns deterministic engine/frame progression and shared game logic.
- Frame-buffer sizing is controlled by hosts through engine APIs.
- Rendering code is maintained under `lib/src/rendering/`.
- Menu coordination is split under `lib/src/menu/manager/`; public consumers should prefer `lib/wolf_3d_menu.dart` or the internal barrel at `lib/src/menu/menu_manager.dart` instead of reaching into individual implementation files.
- Menu presentation is selected through `AssetRegistry.menuPresentation`, which keeps retail/shareware/Spear variants and user-defined menu overrides aligned with the rest of the registry system.
## Custom Menus
Custom menu support is split across two registry modules:
- `MenuPicModule` maps symbolic menu keys such as `MenuPicKey.title` or an episode selection entry to concrete VGA picture indices in `WolfensteinData.vgaImages`.
- `MenuPresentationModule` defines the palette indices and higher-level menu art lookups that renderers and hosts consume.
That split is intentional:
- `MenuPicModule` answers "which image index represents this menu asset for this game/mod?"
- `MenuPresentationModule` answers "which colors and optional art should the UI use?"
In practice, most custom variants will either:
- reuse an existing `MenuPicModule` and only change colors/presentation, or
- provide both a custom `MenuPicModule` and a matching `MenuPresentationModule` when the menu art layout itself changes.
### Using Menu Presentation From Loaded Data
Once game data has been loaded, bind menu presentation through the active registry:
```dart
final WolfMenuPresentation menu = WolfMenuPresentation(data);
final int panelColor = menu.panelColor;
final VgaImage? title = menu.title;
final VgaImage? episode1 = menu.episodeOption(0);
```
This is the normal path for renderers and any UI that should track the active game variant automatically.
### Fallback Presentation Before Data Loads
Host-owned screens that appear before game data discovery can still use menu-consistent colors:
```dart
const WolfMenuPresentation classicMenu = WolfMenuPresentation.classic();
const WolfMenuPresentation spearMenu = WolfMenuPresentation.spear();
```
Those fallback constructors expose colors without requiring a loaded `WolfensteinData` instance. Art getters return `null` until real data is attached.
### Implementing A Custom MenuPicModule
Use `MenuPicModule` when your mod changes which VGA pictures back the classic menu keys:
```dart
class ModMenuPics extends MenuPicModule {
const ModMenuPics();
@override
MenuPicRef? resolve(MenuPicKey key) {
switch (key) {
case MenuPicKey.title:
return const MenuPicRef(140);
case MenuPicKey.optionTitle:
return const MenuPicRef(141);
case MenuPicKey.customizeTitle:
return const MenuPicRef(142);
default:
return null;
}
}
@override
MenuPicKey episodeKey(int episodeIndex) {
switch (episodeIndex) {
case 0:
return MenuPicKey.episode1;
case 1:
return MenuPicKey.episode2;
case 2:
return MenuPicKey.episode3;
default:
return MenuPicKey.episode1;
}
}
@override
MenuPicKey difficultyKey(Difficulty difficulty) {
switch (difficulty) {
case Difficulty.easy:
return MenuPicKey.skill1;
case Difficulty.medium:
return MenuPicKey.skill2;
case Difficulty.hard:
return MenuPicKey.skill3;
case Difficulty.expert:
return MenuPicKey.skill4;
}
}
}
```
Returning `null` from `resolve` means that the key is not provided by that module.
### Implementing A Custom MenuPresentationModule
Use `MenuPresentationModule` when you want to change menu colors, point existing menu concepts at different art, or selectively omit optional art:
### Custom Menu Presentation Example
```dart
class ModMenuPresentation extends MenuPresentationModule {
const ModMenuPresentation();
@override
int get backgroundIndex => 111;
@override
int get panelIndex => 97;
@override
int get borderIndex => 87;
@override
int get emphasisIndex => 10;
@override
int get warningIndex => 14;
@override
int get mutedIndex => 8;
@override
int get selectedTextIndex => 19;
@override
int get unselectedTextIndex => 23;
@override
int get disabledTextIndex => 4;
@override
int get headerTextIndex => 15;
@override
VgaImage? controlBackground(WolfensteinData data) => null;
@override
VgaImage? title(WolfensteinData data) => null;
@override
VgaImage? heading(WolfensteinData data) => null;
@override
VgaImage? selectedMarker(WolfensteinData data) => null;
@override
VgaImage? unselectedMarker(WolfensteinData data) => null;
@override
VgaImage? optionsLabel(WolfensteinData data) => null;
@override
VgaImage? customizeLabel(WolfensteinData data) => null;
@override
VgaImage? credits(WolfensteinData data) => null;
@override
VgaImage? episodeOption(WolfensteinData data, int episodeIndex) => null;
@override
VgaImage? difficultyOption(WolfensteinData data, Difficulty difficulty) =>
null;
@override
VgaImage? mappedPic(WolfensteinData data, int index) => null;
}
final registry = AssetRegistry(
sfx: mySfxModule,
music: myMusicModule,
entities: myEntityModule,
hud: myHudModule,
menu: myMenuPicModule,
menuPresentation: const ModMenuPresentation(),
);
```
The presentation module should treat its image-returning methods as optional hooks:
- return a `VgaImage` when that surface has variant-specific art,
- return `null` when the presentation intentionally has no image for that concept,
### Palette Conversion Guardrail
When mapping target RGB menu tones to a VGA palette index (for example, preserving Wolf classic dark-red theme), resolve nearest colors from `ColorPalette.argbFromVgaIndex()` values.
Do not use `ColorPalette.findClosestPaletteIndex()` for this specific workflow, because its channel interpretation is legacy-oriented and can produce hue-swapped matches (for example, red targets resolving to blue-ish indices).
In short:
- For variant-defined menu colors: use explicit palette indices from `MenuPresentationModule`.
- For host-defined fallback RGB tones: find nearest VGA index by comparing RGB distance against `argbFromVgaIndex()` output.
- use `mappedPic(...)` only for legacy numeric menu art lookups that still matter for a renderer path.
### Wiring A Fully Custom Registry
To ship a complete custom menu variant, provide both modules through `AssetRegistry` when loading data:
```dart
final registry = AssetRegistry(
sfx: mySfxModule,
music: myMusicModule,
entities: myEntityModule,
hud: myHudModule,
menu: const ModMenuPics(),
menuPresentation: const ModMenuPresentation(),
);
```
If your menu art still follows the built-in retail/shareware layout, you may not need a custom `MenuPicModule`. In that case, keep the built-in module and only swap `menuPresentation`.
### Choosing The Right Extension Point
- Change colors only: implement `MenuPresentationModule`.
- Change symbolic menu art mapping: implement `MenuPicModule`.
- Change both colors and art layout: implement both modules.
- Build a host setup screen before data loads: use `WolfMenuPresentation.classic()` or `WolfMenuPresentation.spear()`.
For most host code, prefer the public `wolf_3d_menu.dart` surface instead of importing internal files directly.
## Non-Goals
- Flutter widgets/screens are not part of this package.
- Bundled app assets are handled by `wolf_3d_assets`.
## Troubleshooting
- **Parity regressions**: run targeted tests under `test/engine/` and `test/entities/`.
- **Host integration issues**: verify host packages/apps are using exported surfaces from `lib/` rather than private `src/` paths.
## Related Modules
- Flutter integration layer: [`../wolf_3d_flutter/README.md`](../wolf_3d_flutter/README.md)
- Shared packaged assets: [`../wolf_3d_assets/README.md`](../wolf_3d_assets/README.md)
- CLI host app: [`../../apps/wolf_3d_cli/README.md`](../../apps/wolf_3d_cli/README.md)
- GUI host app: [`../../apps/wolf_3d_gui/README.md`](../../apps/wolf_3d_gui/README.md)
- Workspace overview: [`../../README.md`](../../README.md)
@@ -2,7 +2,7 @@ import 'dart:developer';
import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:wolf_3d_dart/src/data/md5_hash.dart';
import 'package:wolf_3d_dart/src/data/wl_parser.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
@@ -73,7 +73,7 @@ Future<Map<GameVersion, WolfensteinData>> discoverInDirectory({
final vswapBytes = await vswapFile.readAsBytes();
// 2. Generate Checksum and Resolve Identity
final hash = md5.convert(vswapBytes).toString();
final hash = md5HexLower(vswapBytes);
final identity = DataVersion.fromChecksum(hash);
log('--- Found ${version.name} ---');
@@ -0,0 +1,227 @@
import 'dart:typed_data';
const int _mask32 = 0xFFFFFFFF;
const List<int> _s = <int>[
7,
12,
17,
22,
7,
12,
17,
22,
7,
12,
17,
22,
7,
12,
17,
22,
5,
9,
14,
20,
5,
9,
14,
20,
5,
9,
14,
20,
5,
9,
14,
20,
4,
11,
16,
23,
4,
11,
16,
23,
4,
11,
16,
23,
4,
11,
16,
23,
6,
10,
15,
21,
6,
10,
15,
21,
6,
10,
15,
21,
6,
10,
15,
21,
];
const List<int> _k = <int>[
0xd76aa478,
0xe8c7b756,
0x242070db,
0xc1bdceee,
0xf57c0faf,
0x4787c62a,
0xa8304613,
0xfd469501,
0x698098d8,
0x8b44f7af,
0xffff5bb1,
0x895cd7be,
0x6b901122,
0xfd987193,
0xa679438e,
0x49b40821,
0xf61e2562,
0xc040b340,
0x265e5a51,
0xe9b6c7aa,
0xd62f105d,
0x02441453,
0xd8a1e681,
0xe7d3fbc8,
0x21e1cde6,
0xc33707d6,
0xf4d50d87,
0x455a14ed,
0xa9e3e905,
0xfcefa3f8,
0x676f02d9,
0x8d2a4c8a,
0xfffa3942,
0x8771f681,
0x6d9d6122,
0xfde5380c,
0xa4beea44,
0x4bdecfa9,
0xf6bb4b60,
0xbebfbc70,
0x289b7ec6,
0xeaa127fa,
0xd4ef3085,
0x04881d05,
0xd9d4d039,
0xe6db99e5,
0x1fa27cf8,
0xc4ac5665,
0xf4292244,
0x432aff97,
0xab9423a7,
0xfc93a039,
0x655b59c3,
0x8f0ccc92,
0xffeff47d,
0x85845dd1,
0x6fa87e4f,
0xfe2ce6e0,
0xa3014314,
0x4e0811a1,
0xf7537e82,
0xbd3af235,
0x2ad7d2bb,
0xeb86d391,
];
String md5HexLower(List<int> input) {
final source = Uint8List.fromList(input);
final originalLength = source.length;
final bitLength = originalLength * 8;
final paddingLength = (56 - ((originalLength + 1) % 64) + 64) % 64;
final totalLength = originalLength + 1 + paddingLength + 8;
final bytes = Uint8List(totalLength);
bytes.setRange(0, originalLength, source);
bytes[originalLength] = 0x80;
for (var index = 0; index < 8; index++) {
bytes[totalLength - 8 + index] = (bitLength >> (8 * index)) & 0xFF;
}
var a0 = 0x67452301;
var b0 = 0xEFCDAB89;
var c0 = 0x98BADCFE;
var d0 = 0x10325476;
for (var chunkOffset = 0; chunkOffset < bytes.length; chunkOffset += 64) {
final m = Uint32List(16);
for (var j = 0; j < 16; j++) {
final base = chunkOffset + j * 4;
m[j] =
bytes[base] |
(bytes[base + 1] << 8) |
(bytes[base + 2] << 16) |
(bytes[base + 3] << 24);
}
var a = a0;
var b = b0;
var c = c0;
var d = d0;
for (var i = 0; i < 64; i++) {
late int f;
late int g;
if (i < 16) {
f = ((b & c) | ((~b) & d)) & _mask32;
g = i;
} else if (i < 32) {
f = ((d & b) | ((~d) & c)) & _mask32;
g = (5 * i + 1) % 16;
} else if (i < 48) {
f = (b ^ c ^ d) & _mask32;
g = (3 * i + 5) % 16;
} else {
f = (c ^ (b | (~d))) & _mask32;
g = (7 * i) % 16;
}
final temp = d;
d = c;
c = b;
final sum = (a + f + _k[i] + m[g]) & _mask32;
b = (b + _leftRotate(sum, _s[i])) & _mask32;
a = temp;
}
a0 = (a0 + a) & _mask32;
b0 = (b0 + b) & _mask32;
c0 = (c0 + c) & _mask32;
d0 = (d0 + d) & _mask32;
}
final output = StringBuffer();
_appendWordHex(output, a0);
_appendWordHex(output, b0);
_appendWordHex(output, c0);
_appendWordHex(output, d0);
return output.toString();
}
int _leftRotate(int value, int shift) {
return ((value << shift) | ((value & _mask32) >> (32 - shift))) & _mask32;
}
void _appendWordHex(StringBuffer output, int value) {
for (var offset = 0; offset < 32; offset += 8) {
final byte = (value >> offset) & 0xFF;
output.write(byte.toRadixString(16).padLeft(2, '0'));
}
}
@@ -1,9 +1,10 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart' show md5;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'md5_hash.dart';
/// The primary parser for Wolfenstein 3D data formats.
///
/// This abstract class serves as the extraction and decompression engine for
@@ -45,7 +46,7 @@ abstract class WLParser {
vswap.offsetInBytes,
vswap.lengthInBytes,
);
final vswapHash = md5.convert(vswapBytes).toString();
final vswapHash = md5HexLower(vswapBytes);
final dataIdentity = DataVersion.fromChecksum(vswapHash);
// 3. Load other required files
@@ -112,7 +113,7 @@ abstract class WLParser {
vswap.offsetInBytes,
vswap.lengthInBytes,
);
final vswapHash = md5.convert(vswapBytes).toString();
final vswapHash = md5HexLower(vswapBytes);
final dataIdentity = DataVersion.fromChecksum(vswapHash);
ByteData gameMapsData;
@@ -1,12 +1,11 @@
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'io/discovery_stub.dart'
if (dart.library.io) 'io/discovery_io.dart'
import 'package:wolf_3d_dart/src/data/io/discovery_stub.dart'
if (dart.library.io) 'package:wolf_3d_dart/src/data/io/discovery_io.dart'
as platform;
import 'wl_parser.dart';
import 'package:wolf_3d_dart/src/data/md5_hash.dart';
import 'package:wolf_3d_dart/src/data/wl_parser.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
/// The main entry point for loading Wolfenstein 3D data.
///
@@ -76,7 +75,7 @@ class WolfensteinLoader {
vswap.offsetInBytes,
vswap.lengthInBytes,
);
final hash = md5.convert(vswapBytes).toString();
final hash = md5HexLower(vswapBytes);
final dataIdentity = DataVersion.fromChecksum(hash);
// 3. Pass-through to parser with the detected identity and optional override.
@@ -262,6 +262,19 @@ abstract class ColorPalette {
0xFF890099,
]);
/// Converts a VGA palette index to a packed 32-bit ARGB color.
///
/// Internally, [vga32Bit] stores channel bytes in ABGR order for renderer
/// pipelines that write raw bytes directly. This helper returns standard
/// ARGB (`0xAARRGGBB`) for APIs that expect Flutter/Dart UI color packing.
static int argbFromVgaIndex(int index) {
final int packed = vga32Bit[index];
final int r = packed & 0xFF;
final int g = (packed >> 8) & 0xFF;
final int b = (packed >> 16) & 0xFF;
return (0xFF << 24) | (r << 16) | (g << 8) | b;
}
static int findClosestPaletteIndex(int argb) {
final int targetR = (argb >> 16) & 0xFF;
final int targetG = (argb >> 8) & 0xFF;
@@ -1,18 +1,19 @@
/// Supported game releases and their associated file extensions.
enum GameVersion {
/// Wolfenstein 3D Shareware (.WL1)
shareware("WL1"),
shareware("WL1", "Wolf3D Shareware"),
/// Wolfenstein 3D Full Retail (.WL6)
retail("WL6"),
retail("WL6", "Wolf3D Retail"),
/// Spear of Destiny Full Version (.SOD)
spearOfDestiny("SOD"),
spearOfDestiny("SOD", "Spear of Destiny"),
/// Spear of Destiny Demo (.SDM)
spearOfDestinyDemo("SDM")
spearOfDestinyDemo("SDM", "Spear of Destiny Demo")
;
final String fileExtension;
const GameVersion(this.fileExtension);
final String label;
const GameVersion(this.fileExtension, this.label);
}
@@ -0,0 +1,5 @@
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
abstract class DebugMusicPlayer {
Future<void> playMusic(ImfMusic track, {bool looping = true});
}
@@ -0,0 +1,451 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'dart:typed_data';
import 'package:wolf_3d_dart/src/engine/audio/debug_music_player.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_synth.dart';
class NativeSubprocessAudio implements EngineAudio, DebugMusicPlayer {
NativeSubprocessAudio({this.maxConcurrentSfx = 8});
static bool get supportsCurrentPlatform =>
Platform.isLinux || Platform.isMacOS || Platform.isWindows;
final int maxConcurrentSfx;
@override
WolfensteinData? activeGame;
bool _initialized = false;
bool _isSupported = false;
bool _disposed = false;
_AudioBackend _backend = _AudioBackend.none;
String _windowsShellCommand = 'powershell';
Process? _musicProcess;
int _musicLoopToken = 0;
String? _musicTempFilePath;
final List<Process> _sfxProcesses = <Process>[];
@override
Future<void> init() async {
if (_initialized || _disposed) {
return;
}
_backend = await _detectBackend();
_isSupported = _backend != _AudioBackend.none;
_initialized = true;
if (_isSupported) {
log('[NATIVE AUDIO] Subprocess backend enabled: ${_backend.name}');
} else {
log('[NATIVE AUDIO] No supported audio backend found; running silent.');
}
}
@override
void dispose() {
if (_disposed) {
return;
}
_disposed = true;
_musicLoopToken++;
_musicProcess?.kill();
_musicProcess = null;
for (final process in List<Process>.from(_sfxProcesses)) {
process.kill();
}
_sfxProcesses.clear();
final path = _musicTempFilePath;
_musicTempFilePath = null;
if (path != null) {
unawaited(_cleanupTempWav(path));
}
}
@override
Future<void> debugSoundTest() async {
for (int i = 0; i < 50; i++) {
await Future<void>.delayed(const Duration(milliseconds: 500));
await playSoundEffectId(i);
}
}
@override
Future<void> playMenuMusic() async {
final data = activeGame;
final trackIndex = data == null
? null
: Music.menuTheme.trackIndexFor(data.version);
if (data == null || trackIndex == null || trackIndex >= data.music.length) {
return;
}
await playMusic(data.music[trackIndex]);
}
@override
Future<void> playLevelMusic(Music music) async {
final data = activeGame;
if (data == null || data.music.isEmpty) {
return;
}
final index = music.trackIndexFor(data.version) ?? 0;
if (index < 0 || index >= data.music.length) {
return;
}
await playMusic(data.music[index]);
}
@override
Future<void> playMusic(ImfMusic track, {bool looping = true}) async {
if (!_isSupported) {
return;
}
final pcmSamples = ImfRenderer.render(track);
final wavBytes = ImfRenderer.createWavFile(pcmSamples);
if (looping) {
await _startLoopingMusic(wavBytes);
return;
}
await stopMusic();
final process = await _startPlaybackProcess(
wavBytes: wavBytes,
role: _PlaybackRole.music,
);
if (process != null) {
_musicProcess = process;
}
}
Future<void> _startLoopingMusic(Uint8List wavBytes) async {
await stopMusic();
final int token = ++_musicLoopToken;
while (_musicLoopToken == token) {
final process = await _startPlaybackProcess(
wavBytes: wavBytes,
role: _PlaybackRole.music,
);
if (process == null) {
break;
}
_musicProcess = process;
await process.exitCode;
if (_musicLoopToken != token) {
break;
}
}
}
@override
Future<void> stopMusic() async {
_musicLoopToken++;
_musicProcess?.kill();
_musicProcess = null;
final path = _musicTempFilePath;
_musicTempFilePath = null;
if (path != null) {
await _cleanupTempWav(path);
}
}
@override
Future<void> stopAllAudio() async {
await stopMusic();
for (final process in List<Process>.from(_sfxProcesses)) {
process.kill();
}
_sfxProcesses.clear();
}
@override
Future<void> playSoundEffect(SoundEffect effect) async {
final data = activeGame;
if (data == null) {
return;
}
final resolved = data.registry.sfx.resolve(effect);
if (resolved != null) {
await playSoundEffectId(resolved.slotIndex);
return;
}
if (data.version == GameVersion.spearOfDestinyDemo) {
return;
}
await playSoundEffectId(effect.idFor(data.version));
}
@override
Future<void> playSoundEffectId(int sfxId) async {
if (!_isSupported) {
return;
}
final data = activeGame;
if (data == null) {
return;
}
final soundsList = data.sounds;
if (sfxId < 0 || sfxId >= soundsList.length) {
return;
}
final raw8bitBytes = soundsList[sfxId].bytes;
if (raw8bitBytes.isEmpty) {
return;
}
final Int16List converted16bit = Int16List(raw8bitBytes.length);
for (int i = 0; i < raw8bitBytes.length; i++) {
converted16bit[i] = (raw8bitBytes[i] - 128) * 256;
}
final wavBytes = ImfRenderer.createWavFile(
converted16bit,
sampleRate: 7000,
);
if (_sfxProcesses.length >= maxConcurrentSfx) {
final oldest = _sfxProcesses.removeAt(0);
oldest.kill();
}
final process = await _startPlaybackProcess(
wavBytes: wavBytes,
role: _PlaybackRole.sfx,
);
if (process == null) {
return;
}
_sfxProcesses.add(process);
unawaited(
process.exitCode.then((_) {
_sfxProcesses.remove(process);
}),
);
}
Future<Process?> _startPlaybackProcess({
required Uint8List wavBytes,
required _PlaybackRole role,
}) async {
try {
switch (_backend) {
case _AudioBackend.linuxPipeWire:
return _startFilePlaybackProcess('pw-play', const [], wavBytes, role);
case _AudioBackend.linuxPulseAudio:
return _startFilePlaybackProcess('paplay', const [], wavBytes, role);
case _AudioBackend.linuxAplay:
return _startFilePlaybackProcess(
'aplay',
const ['-q'],
wavBytes,
role,
);
case _AudioBackend.macosAfplay:
return _startFilePlaybackProcess('afplay', const [], wavBytes, role);
case _AudioBackend.windowsPowerShell:
return _startWindowsPlaybackProcess(wavBytes, role: role);
case _AudioBackend.none:
return null;
}
} catch (error) {
log('[NATIVE AUDIO] Failed to start playback process: $error');
return null;
}
}
Future<Process> _startFilePlaybackProcess(
String executable,
List<String> baseArguments,
Uint8List wavBytes,
_PlaybackRole role,
) async {
final path = await _writeTempWav(wavBytes, prefix: 'wolf3d_native_audio_');
if (role == _PlaybackRole.music) {
final existing = _musicTempFilePath;
_musicTempFilePath = path;
if (existing != null && existing != path) {
await _cleanupTempWav(existing);
}
}
final process = await Process.start(executable, <String>[
...baseArguments,
path,
]);
unawaited(
process.exitCode.then((code) async {
if (code != 0) {
log('[NATIVE AUDIO] Player exited with code $code: $executable');
}
await _cleanupTempWav(path);
}),
);
return process;
}
Future<Process> _startWindowsPlaybackProcess(
Uint8List wavBytes, {
required _PlaybackRole role,
}) async {
final path = await _writeWindowsTempWav(wavBytes, role: role);
if (role == _PlaybackRole.music) {
final existing = _musicTempFilePath;
_musicTempFilePath = path;
if (existing != null && existing != path) {
await _cleanupTempWav(existing);
}
}
final escapedPath = path.replaceAll("'", "''");
final script = "(New-Object Media.SoundPlayer '$escapedPath').PlaySync()";
final process = await Process.start(_windowsShellCommand, <String>[
'-NoProfile',
'-NonInteractive',
'-Command',
script,
]);
unawaited(
process.exitCode.then((_) async {
await _cleanupTempWav(path);
}),
);
return process;
}
Future<String> _writeWindowsTempWav(
Uint8List wavBytes, {
required _PlaybackRole role,
}) async {
final tempDir = await Directory.systemTemp.createTemp(
'wolf3d_native_audio_',
);
final suffix = role == _PlaybackRole.music
? 'music_${DateTime.now().microsecondsSinceEpoch}.wav'
: 'sfx_${DateTime.now().microsecondsSinceEpoch}.wav';
final path = '${tempDir.path}${Platform.pathSeparator}$suffix';
await File(path).writeAsBytes(wavBytes, flush: true);
return path;
}
Future<String> _writeTempWav(
Uint8List wavBytes, {
required String prefix,
}) async {
final tempDir = await Directory.systemTemp.createTemp(prefix);
final path =
'${tempDir.path}${Platform.pathSeparator}audio_${DateTime.now().microsecondsSinceEpoch}.wav';
await File(path).writeAsBytes(wavBytes, flush: true);
return path;
}
Future<_AudioBackend> _detectBackend() async {
if (Platform.isLinux) {
final hasPwPlay = await _commandExists('pw-play');
if (hasPwPlay) {
return _AudioBackend.linuxPipeWire;
}
final hasPaplay = await _commandExists('paplay');
if (hasPaplay) {
return _AudioBackend.linuxPulseAudio;
}
final hasAplay = await _commandExists('aplay');
if (hasAplay) {
return _AudioBackend.linuxAplay;
}
}
if (Platform.isMacOS) {
final hasAfplay = await _commandExists('afplay');
if (hasAfplay) {
return _AudioBackend.macosAfplay;
}
}
if (Platform.isWindows) {
final hasPowerShell = await _commandExists('powershell');
if (hasPowerShell) {
_windowsShellCommand = 'powershell';
return _AudioBackend.windowsPowerShell;
}
final hasPwsh = await _commandExists('pwsh');
if (hasPwsh) {
_windowsShellCommand = 'pwsh';
return _AudioBackend.windowsPowerShell;
}
}
return _AudioBackend.none;
}
Future<bool> _commandExists(String command) async {
final probe = Platform.isWindows
? await Process.run('where', <String>[command])
: await Process.run('which', <String>[command]);
return probe.exitCode == 0;
}
Future<void> _cleanupTempWav(String path) async {
try {
final file = File(path);
if (await file.exists()) {
await file.delete();
}
final directory = file.parent;
if (await directory.exists()) {
await directory.delete();
}
} catch (error) {
log('[NATIVE AUDIO] Temp WAV cleanup failed: $error');
}
}
}
enum _AudioBackend {
none,
linuxPipeWire,
linuxPulseAudio,
linuxAplay,
macosAfplay,
windowsPowerShell,
}
enum _PlaybackRole { music, sfx }
@@ -0,0 +1,44 @@
import 'package:wolf_3d_dart/src/engine/audio/debug_music_player.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
class NativeSubprocessAudio implements EngineAudio, DebugMusicPlayer {
NativeSubprocessAudio({this.maxConcurrentSfx = 8});
static bool get supportsCurrentPlatform => false;
final int maxConcurrentSfx;
@override
WolfensteinData? activeGame;
@override
Future<void> debugSoundTest() async {}
@override
Future<void> init() async {}
@override
void dispose() {}
@override
Future<void> playMenuMusic() async {}
@override
Future<void> playLevelMusic(Music music) async {}
@override
Future<void> stopMusic() async {}
@override
Future<void> stopAllAudio() async {}
@override
Future<void> playSoundEffect(SoundEffect effect) async {}
@override
Future<void> playSoundEffectId(int sfxId) async {}
@override
Future<void> playMusic(ImfMusic track, {bool looping = true}) async {}
}
@@ -1,13 +1,13 @@
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
class CliSilentAudio implements EngineAudio {
class SilentAudio implements EngineAudio {
@override
WolfensteinData? activeGame;
@override
Future<void> init() async {
// No-op for CLI
// No-op fallback backend.
}
@override
@@ -23,18 +23,10 @@ class CliSilentAudio implements EngineAudio {
Future<void> stopAllAudio() async {}
@override
void playSoundEffect(SoundEffect effect) {
// Optional: You could use the terminal 'bell' character here
// to actually make a system beep when a sound plays!
// stdout.write('\x07');
}
void playSoundEffect(SoundEffect effect) {}
@override
void playSoundEffectId(int sfxId) {
// Optional: You could use the terminal 'bell' character here
// to actually make a system beep when a sound plays!
// stdout.write('\x07');
}
void playSoundEffectId(int sfxId) {}
@override
void dispose() {}
@@ -1,6 +1,7 @@
import 'dart:developer';
import 'dart:math' as math;
import 'package:wolf_3d_dart/src/engine/save/game_session_snapshot.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
@@ -36,6 +37,7 @@ class Player {
// Classic face animation (UpdateFace/FACETICS random glance frames)
math.Random _faceRng = math.Random(0);
int _faceSeed = 0;
int _faceFrame = 0;
double _faceCountTics = 0.0;
int _nextFaceChangeThreshold = 0;
@@ -81,6 +83,7 @@ class Player {
int get hudFaceFrame => _faceFrame;
void setHudFaceAnimationSeed(int seed) {
_faceSeed = seed;
_faceRng = math.Random(seed);
_faceFrame = 0;
_faceCountTics = 0.0;
@@ -93,6 +96,99 @@ class Player {
_godModeFaceEnabled = enabled;
}
PlayerSaveState toSaveState() {
final Map<WeaponType, WeaponSaveState> weaponStates =
<WeaponType, WeaponSaveState>{};
for (final MapEntry<WeaponType, Weapon?> entry in weapons.entries) {
final Weapon? weapon = entry.value;
if (weapon == null) {
continue;
}
weaponStates[entry.key] = weapon.toSaveState();
}
return PlayerSaveState(
x: x,
y: y,
angle: angle,
health: health,
ammo: ammo,
score: score,
lives: lives,
damageFlash: damageFlash,
bonusFlash: bonusFlash,
chaingunPickupFaceMsRemaining: _chaingunPickupFaceMsRemaining,
mutantDeathFaceActive: _mutantDeathFaceActive,
godModeFaceEnabled: _godModeFaceEnabled,
faceSeed: _faceSeed,
faceFrame: _faceFrame,
faceCountTics: _faceCountTics,
nextFaceChangeThreshold: _nextFaceChangeThreshold,
hasGoldKey: hasGoldKey,
hasSilverKey: hasSilverKey,
hasMachineGun: hasMachineGun,
hasChainGun: hasChainGun,
currentWeaponType: currentWeapon.type,
weaponStates: weaponStates,
switchStateIndex: switchState.index,
pendingWeaponType: pendingWeaponType,
weaponAnimOffset: weaponAnimOffset,
);
}
void restoreFromSaveState(PlayerSaveState saveState) {
x = saveState.x;
y = saveState.y;
angle = saveState.angle;
health = saveState.health;
ammo = saveState.ammo;
score = saveState.score;
lives = saveState.lives;
damageFlash = saveState.damageFlash;
bonusFlash = saveState.bonusFlash;
_chaingunPickupFaceMsRemaining = saveState.chaingunPickupFaceMsRemaining;
_mutantDeathFaceActive = saveState.mutantDeathFaceActive;
_godModeFaceEnabled = saveState.godModeFaceEnabled;
_faceSeed = saveState.faceSeed;
_faceRng = math.Random(_faceSeed);
_faceFrame = saveState.faceFrame;
_faceCountTics = saveState.faceCountTics;
_nextFaceChangeThreshold = saveState.nextFaceChangeThreshold;
hasGoldKey = saveState.hasGoldKey;
hasSilverKey = saveState.hasSilverKey;
hasMachineGun = saveState.hasMachineGun;
hasChainGun = saveState.hasChainGun;
switchState = WeaponSwitchState.values[saveState.switchStateIndex];
pendingWeaponType = saveState.pendingWeaponType;
weaponAnimOffset = saveState.weaponAnimOffset;
weapons.updateAll((_, _) => null);
for (final MapEntry<WeaponType, WeaponSaveState> entry
in saveState.weaponStates.entries) {
final weapon = _createWeapon(entry.key);
weapon.restoreFromSaveState(entry.value);
weapons[entry.key] = weapon;
}
currentWeapon =
weapons[saveState.currentWeaponType] ??
_createWeapon(saveState.currentWeaponType);
final WeaponSaveState? currentWeaponState =
saveState.weaponStates[saveState.currentWeaponType];
if (currentWeaponState != null) {
currentWeapon.restoreFromSaveState(currentWeaponState);
}
}
Weapon _createWeapon(WeaponType weaponType) {
return switch (weaponType) {
WeaponType.knife => Knife(),
WeaponType.pistol => Pistol(),
WeaponType.machineGun => MachineGun(),
WeaponType.chainGun => ChainGun(),
};
}
// --- General Update ---
void tick(Duration elapsed) {
@@ -0,0 +1,5 @@
/// Routes to the native or stub implementation based on platform.
library;
export 'default_renderer_settings_persistence_stub.dart'
if (dart.library.io) 'default_renderer_settings_persistence_io.dart';
@@ -0,0 +1,103 @@
/// Native (dart:io) renderer-settings persistence.
library;
import 'dart:convert';
import 'dart:io';
import 'package:wolf_3d_dart/src/engine/rendering/renderer_settings.dart';
import 'package:wolf_3d_dart/src/engine/rendering/renderer_settings_persistence.dart';
import 'package:wolf_3d_dart/src/platform/platform_config_dir.dart';
const String rendererSettingsHostCli = 'cli';
const String rendererSettingsHostFlutter = 'flutter';
/// Persists [WolfRendererSettings] as JSON to the platform config directory.
///
/// Pass an explicit [filePath] to override the default location (useful in tests).
class DefaultRendererSettingsPersistence extends RendererSettingsPersistence
with JsonRendererSettingsPersistence {
DefaultRendererSettingsPersistence({
String? filePath,
String hostKey = rendererSettingsHostFlutter,
}) : _filePath = filePath,
_hostKey = hostKey;
final String? _filePath;
final String _hostKey;
String? _resolvedPath;
Future<String> _getFilePath() async {
if (_resolvedPath != null) return _resolvedPath!;
_resolvedPath = _filePath ?? '${platformConfigDir()}/settings.json';
return _resolvedPath!;
}
@override
Future<String?> readRaw() async {
try {
final String path = await _getFilePath();
final File f = File(path);
if (!f.existsSync()) return null;
final String raw = await f.readAsString();
final Object? decoded = jsonDecode(raw);
if (decoded is! Map<String, Object?>) {
return null;
}
final Object? rendererSettings = decoded['rendererSettings'];
if (rendererSettings is! Map<String, Object?>) {
return null;
}
final Object? scoped = rendererSettings[_hostKey];
if (scoped is! Map<String, Object?>) {
return null;
}
return jsonEncode(scoped);
} catch (_) {
return null;
}
}
@override
Future<void> writeRaw(String json) async {
try {
final String path = await _getFilePath();
final Directory dir = File(path).parent;
if (!dir.existsSync()) await dir.create(recursive: true);
final File f = File(path);
Map<String, Object?> root = <String, Object?>{};
if (f.existsSync()) {
try {
final Object? existing = jsonDecode(await f.readAsString());
if (existing is Map<String, Object?>) {
root = Map<String, Object?>.from(existing);
}
} catch (_) {
root = <String, Object?>{};
}
}
final Object? scopedDecoded = jsonDecode(json);
if (scopedDecoded is! Map<String, Object?>) {
return;
}
final Map<String, Object?> rendererSettings =
root['rendererSettings'] is Map<String, Object?>
? Map<String, Object?>.from(
root['rendererSettings']! as Map<String, Object?>,
)
: <String, Object?>{};
rendererSettings[_hostKey] = Map<String, Object?>.from(scopedDecoded);
root['rendererSettings'] = rendererSettings;
await f.writeAsString(jsonEncode(root), flush: true);
} catch (_) {
// Best-effort.
}
}
}
@@ -0,0 +1,23 @@
/// Web stub for renderer-settings persistence: silently skips all I/O.
library;
import 'package:wolf_3d_dart/src/engine/rendering/renderer_settings.dart';
import 'package:wolf_3d_dart/src/engine/rendering/renderer_settings_persistence.dart';
const String rendererSettingsHostCli = 'cli';
const String rendererSettingsHostFlutter = 'flutter';
/// No-op implementation used on web, where dart:io is unavailable.
class DefaultRendererSettingsPersistence extends RendererSettingsPersistence {
// ignore: avoid_unused_constructor_parameters
DefaultRendererSettingsPersistence({
String? filePath,
String hostKey = 'flutter',
});
@override
Future<WolfRendererSettings?> load() async => null;
@override
Future<void> save(WolfRendererSettings settings) async {}
}
@@ -0,0 +1,5 @@
/// Routes to the native or stub implementation based on platform.
library;
export 'default_save_game_persistence_stub.dart'
if (dart.library.io) 'default_save_game_persistence_io.dart';
@@ -0,0 +1,65 @@
/// Native (dart:io) slot-based save-game persistence.
library;
import 'dart:io';
import 'dart:typed_data';
import 'package:wolf_3d_dart/src/engine/save/save_game_persistence.dart';
import 'package:wolf_3d_dart/src/platform/platform_config_dir.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
/// Persists save-game slots as raw bytes under the platform config directory.
///
/// Files are stored in `<configDir>/saves/` and named
/// `SAVEGAM{slot}.{ext}` where `{ext}` follows the active game version.
///
/// Pass an explicit [directoryPath] to override the default (useful in tests).
class DefaultSaveGamePersistence extends SaveGamePersistence {
DefaultSaveGamePersistence({String? directoryPath})
: _directoryPath = directoryPath ?? '${platformConfigDir()}/saves';
final String _directoryPath;
@override
Future<Uint8List?> load({
required int slot,
required GameVersion version,
}) async {
try {
final File file = File(_slotPath(slot, version));
if (!file.existsSync()) return null;
return await file.readAsBytes();
} catch (_) {
return null;
}
}
@override
Future<bool> exists({
required int slot,
required GameVersion version,
}) async {
try {
final File file = File(_slotPath(slot, version));
return file.existsSync() && file.lengthSync() > 0;
} catch (_) {
return false;
}
}
@override
Future<void> save({
required int slot,
required GameVersion version,
required Uint8List bytes,
}) async {
final Directory dir = Directory(_directoryPath);
if (!dir.existsSync()) await dir.create(recursive: true);
await File(_slotPath(slot, version)).writeAsBytes(bytes, flush: true);
}
String _slotPath(int slot, GameVersion version) {
final String normalizedSlot = slot.clamp(0, 9).toString();
return '$_directoryPath/SAVEGAM$normalizedSlot.${version.fileExtension}';
}
}
@@ -0,0 +1,32 @@
/// Web stub for save-game persistence: silently skips all I/O.
library;
import 'dart:typed_data';
import 'package:wolf_3d_dart/src/engine/save/save_game_persistence.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
/// No-op implementation used on web, where dart:io is unavailable.
class DefaultSaveGamePersistence extends SaveGamePersistence {
// ignore: avoid_unused_constructor_parameters
DefaultSaveGamePersistence({String? directoryPath});
@override
Future<Uint8List?> load({
required int slot,
required GameVersion version,
}) async => null;
@override
Future<bool> exists({
required int slot,
required GameVersion version,
}) async => false;
@override
Future<void> save({
required int slot,
required GameVersion version,
required Uint8List bytes,
}) async {}
}
@@ -0,0 +1,180 @@
library;
import 'package:wolf_3d_dart/src/entities/entities/door.dart';
import 'package:wolf_3d_dart/src/entities/entities/weapon/weapon.dart';
import 'package:wolf_3d_dart/src/entities/entity.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
class WeaponSaveState {
const WeaponSaveState({
required this.type,
required this.state,
required this.frameIndex,
required this.lastFrameTime,
required this.triggerReleased,
});
final WeaponType type;
final WeaponState state;
final int frameIndex;
final int lastFrameTime;
final bool triggerReleased;
}
class PlayerSaveState {
const PlayerSaveState({
required this.x,
required this.y,
required this.angle,
required this.health,
required this.ammo,
required this.score,
required this.lives,
required this.damageFlash,
required this.bonusFlash,
required this.chaingunPickupFaceMsRemaining,
required this.mutantDeathFaceActive,
required this.godModeFaceEnabled,
required this.faceSeed,
required this.faceFrame,
required this.faceCountTics,
required this.nextFaceChangeThreshold,
required this.hasGoldKey,
required this.hasSilverKey,
required this.hasMachineGun,
required this.hasChainGun,
required this.currentWeaponType,
required this.weaponStates,
required this.switchStateIndex,
required this.pendingWeaponType,
required this.weaponAnimOffset,
});
final double x;
final double y;
final double angle;
final int health;
final int ammo;
final int score;
final int lives;
final double damageFlash;
final double bonusFlash;
final int chaingunPickupFaceMsRemaining;
final bool mutantDeathFaceActive;
final bool godModeFaceEnabled;
final int faceSeed;
final int faceFrame;
final double faceCountTics;
final int nextFaceChangeThreshold;
final bool hasGoldKey;
final bool hasSilverKey;
final bool hasMachineGun;
final bool hasChainGun;
final WeaponType currentWeaponType;
final Map<WeaponType, WeaponSaveState> weaponStates;
final int switchStateIndex;
final WeaponType? pendingWeaponType;
final double weaponAnimOffset;
}
class EntitySaveState {
const EntitySaveState({
required this.kind,
required this.x,
required this.y,
required this.spriteIndex,
required this.angle,
required this.state,
required this.mapId,
required this.lastActionTime,
this.extraData = const <String, Object?>{},
});
final String kind;
final double x;
final double y;
final int spriteIndex;
final double angle;
final EntityState state;
final int mapId;
final int lastActionTime;
final Map<String, Object?> extraData;
}
class DoorSaveState {
const DoorSaveState({
required this.x,
required this.y,
required this.mapId,
required this.state,
required this.offset,
required this.openTime,
});
final int x;
final int y;
final int mapId;
final DoorState state;
final double offset;
final int openTime;
}
class PushwallSaveState {
const PushwallSaveState({
required this.x,
required this.y,
required this.mapId,
required this.dirX,
required this.dirY,
required this.offset,
required this.tilesMoved,
required this.isActive,
});
final int x;
final int y;
final int mapId;
final int dirX;
final int dirY;
final double offset;
final int tilesMoved;
final bool isActive;
}
class GameSessionSnapshot {
const GameSessionSnapshot({
required this.currentGameIndex,
required this.currentEpisodeIndex,
required this.currentLevelIndex,
required this.returnLevelIndex,
required this.difficulty,
required this.timeAliveMs,
required this.lastAcousticAlertTime,
required this.isMapOverlayVisible,
required this.isMenuOverlayVisible,
required this.player,
required this.currentLevel,
required this.areaGrid,
required this.areasByPlayer,
required this.entities,
required this.doors,
required this.pushwalls,
});
final int currentGameIndex;
final int currentEpisodeIndex;
final int currentLevelIndex;
final int? returnLevelIndex;
final Difficulty difficulty;
final int timeAliveMs;
final int lastAcousticAlertTime;
final bool isMapOverlayVisible;
final bool isMenuOverlayVisible;
final PlayerSaveState player;
final List<List<int>> currentLevel;
final List<List<int>> areaGrid;
final List<bool> areasByPlayer;
final List<EntitySaveState> entities;
final List<DoorSaveState> doors;
final List<PushwallSaveState> pushwalls;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
library;
import 'dart:typed_data';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
/// Host adapter contract for slot-based save-game persistence.
abstract class SaveGamePersistence {
/// Loads raw bytes for [slot] and [version], or `null` when no save exists.
Future<Uint8List?> load({required int slot, required GameVersion version});
/// Returns whether a non-empty save exists for [slot] and [version].
Future<bool> exists({required int slot, required GameVersion version}) async {
final Uint8List? bytes = await load(slot: slot, version: version);
return bytes != null && bytes.isNotEmpty;
}
/// Persists [bytes] for [slot] and [version].
Future<void> save({
required int slot,
required GameVersion version,
required Uint8List bytes,
});
}
@@ -0,0 +1,247 @@
import 'package:wolf_3d_dart/src/rendering/menu_header_band.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_input.dart';
/// Platform-agnostic gameplay session facade for Wolf3D hosts.
///
/// This class owns shared engine state and host-facing session configuration
/// while remaining independent of Flutter and other UI frameworks.
class Wolf3dEngine {
/// Creates a session facade backed by [audio] and [input].
Wolf3dEngine({required this.audio, required this.input});
/// All successfully discovered or supplied game data sets.
final List<WolfensteinData> availableGames = [];
WolfensteinData? _activeGame;
/// Shared engine audio backend used by menus and gameplay sessions.
final EngineAudio audio;
/// Shared input adapter reused by hosts and gameplay screens.
final Wolf3dInput input;
Future<void>? _audioShutdownFuture;
/// Engine menu background color as 24-bit RGB.
int menuBackgroundRgb = 0x890000;
/// Engine menu panel color as 24-bit RGB.
int menuPanelRgb = 0x590002;
bool _debugEnabled = false;
/// Whether host-level debug affordances should be visible.
bool get isDebugEnabled => _debugEnabled;
/// Enables host-level debug affordances such as debug navigation UI.
Wolf3dEngine enableDebug() {
_debugEnabled = true;
enableMenuHeaderBandDebugLogging();
return this;
}
/// Routes shared menu header band diagnostics to [logger].
///
/// Pass `null` to disable menu header band diagnostics.
Wolf3dEngine setMenuHeaderBandDebugLogger(
void Function(String message)? logger,
) {
MenuHeaderBand.debugLogger = logger;
return this;
}
/// Enables menu header band diagnostics with an optional [prefix].
Wolf3dEngine enableMenuHeaderBandDebugLogging({
String prefix = '[MENU_HEADER_BAND]',
}) {
MenuHeaderBand.debugLogger = (String message) {
print('$prefix $message');
};
return this;
}
/// Disables menu header band diagnostics.
Wolf3dEngine disableMenuHeaderBandDebugLogging() {
MenuHeaderBand.debugLogger = null;
return this;
}
/// The currently selected game data set.
///
/// Throws a [StateError] until [setActiveGame] has been called.
WolfensteinData get activeGame {
if (_activeGame == null) {
throw StateError('No active game selected. Call setActiveGame() first.');
}
return _activeGame!;
}
/// Nullable access to the selected game, useful during menu bootstrap.
WolfensteinData? get maybeActiveGame => _activeGame;
// Episode selection lives on the facade so menus can configure gameplay
// before constructing a new engine instance.
int? _activeEpisode;
/// Index of the episode currently selected in the UI flow.
int? get activeEpisode => _activeEpisode;
Difficulty? _activeDifficulty;
/// The difficulty applied when [launchEngine] creates a new session.
Difficulty? get activeDifficulty => _activeDifficulty;
/// Stores [difficulty] so the next [launchEngine] call uses it.
void setActiveDifficulty(Difficulty difficulty) {
_activeDifficulty = difficulty;
}
/// Clears any previously selected difficulty so the engine can prompt for one.
void clearActiveDifficulty() {
_activeDifficulty = null;
}
WolfEngine? _engine;
/// The most recently launched engine.
///
/// Throws a [StateError] until [launchEngine] has been called.
WolfEngine get engine {
if (_engine == null) {
throw StateError('No engine launched. Call launchEngine() first.');
}
return _engine!;
}
/// Creates and initializes a [WolfEngine] for the current session config.
///
/// Uses [activeGame], [activeEpisode], and [activeDifficulty]. Stores the
/// engine so it can be retrieved via [engine]. [onGameWon] is invoked when
/// the player completes the final level of the episode.
WolfEngine launchEngine({
required void Function() onGameWon,
void Function()? onQuit,
SaveGamePersistence? saveGamePersistence,
WolfRendererCapabilities? rendererCapabilities,
WolfRendererSettings? rendererSettings,
void Function(WolfRendererSettings settings)? onRendererSettingsChanged,
}) {
if (availableGames.isEmpty) {
throw StateError(
'No game data was discovered. Add game files before launching the engine.',
);
}
_engine = WolfEngine(
availableGames: availableGames,
difficulty: _activeDifficulty,
startingEpisode: _activeEpisode,
frameBuffer: FrameBuffer(320, 200),
menuBackgroundRgb: menuBackgroundRgb,
menuPanelRgb: menuPanelRgb,
engineAudio: audio,
input: input,
onGameWon: onGameWon,
// In Flutter we keep the renderer screen active while browsing menus,
// so backing out of the top-level menu should not pop the route.
onMenuExit: () {},
onQuit: onQuit,
saveGamePersistence: saveGamePersistence,
rendererCapabilities: rendererCapabilities,
rendererSettings: rendererSettings,
onRendererSettingsChanged: onRendererSettingsChanged,
onGameSelected: (game) {
_activeGame = game;
audio.activeGame = game;
},
onEpisodeSelected: (episodeIndex) {
_activeEpisode = episodeIndex;
},
);
_engine!.init();
return _engine!;
}
/// Sets the active episode for the current [activeGame].
void setActiveEpisode(int episodeIndex) {
if (_activeGame == null) {
throw StateError('No active game selected. Call setActiveGame() first.');
}
if (episodeIndex < 0 || episodeIndex >= _activeGame!.episodes.length) {
throw RangeError('Episode index out of range for the active game.');
}
_activeEpisode = episodeIndex;
}
/// Clears any selected episode so menu flow starts fresh.
void clearActiveEpisode() {
_activeEpisode = null;
}
/// Convenience access to the active episode's level list.
List<WolfLevel> get levels {
if (_activeEpisode == null) {
throw StateError('No active episode selected.');
}
return activeGame.episodes[_activeEpisode!].levels;
}
/// Convenience access to the active game's wall textures.
List<Sprite> get walls => activeGame.walls;
/// Convenience access to the active game's sprite set.
List<Sprite> get sprites => activeGame.sprites;
/// Convenience access to digitized PCM effects.
List<PcmSound> get sounds => activeGame.sounds;
/// Convenience access to AdLib/OPL effect assets.
List<PcmSound> get adLibSounds => activeGame.adLibSounds;
/// Convenience access to level music tracks.
List<ImfMusic> get music => activeGame.music;
/// Convenience access to VGA UI and splash images.
List<VgaImage> get vgaImages => activeGame.vgaImages;
/// Makes [game] the active data set and points shared services at it.
void setActiveGame(WolfensteinData game) {
if (!availableGames.contains(game)) {
throw ArgumentError(
'The provided game data is not in the list of available games.',
);
}
if (_activeGame == game) {
return;
}
_activeGame = game;
_activeEpisode = null;
audio.activeGame = game;
}
/// Stops and disposes shared audio exactly once for app shutdown.
///
/// Repeated calls return the same in-flight/completed future so hosts can
/// safely invoke shutdown from multiple lifecycle paths.
Future<void> shutdownAudio() {
final existing = _audioShutdownFuture;
if (existing != null) {
return existing;
}
final shutdown = () async {
await audio.stopAllAudio();
audio.dispose();
}();
_audioShutdownFuture = shutdown;
return shutdown;
}
}
/// Backward-compatible alias for the previous class name.
typedef Wolf3dSession = Wolf3dEngine;
@@ -1,11 +1,14 @@
import 'dart:async';
import 'dart:developer';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:wolf_3d_dart/src/menu/menu_manager.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_entities.dart';
import 'package:wolf_3d_dart/wolf_3d_input.dart';
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
/// The core orchestration class for the Wolfenstein 3D engine.
///
@@ -28,6 +31,9 @@ class WolfEngine {
this.onGameSelected,
this.onEpisodeSelected,
this.onRendererSettingsChanged,
this.saveGamePersistence,
SaveGameCodec? saveGameCodec,
this.defaultSaveSlot = 0,
WolfRendererCapabilities? rendererCapabilities,
WolfRendererSettings? rendererSettings,
EngineAudio? engineAudio,
@@ -45,7 +51,8 @@ class WolfEngine {
'Provide either data or a non-empty availableGames list.',
),
_availableGames = availableGames ?? <WolfensteinData>[data!],
audio = engineAudio ?? CliSilentAudio(),
saveGameCodec = saveGameCodec ?? CompatibleSaveGameCodec(),
audio = engineAudio ?? SilentAudio(),
doorManager = DoorManager(
onPlaySound: (effect) => engineAudio?.playSoundEffect(effect),
),
@@ -55,7 +62,7 @@ class WolfEngine {
if (_availableGames.isEmpty) {
throw StateError('WolfEngine requires at least one game data set.');
}
menuManager.menuBackgroundRgb = menuBackgroundRgb;
_applyMenuPresentationTheme();
_normalizeRendererSettings();
_syncRendererMenuModel();
}
@@ -70,16 +77,22 @@ class WolfEngine {
List<WolfensteinData> get availableGames =>
List.unmodifiable(_availableGames);
int get currentGameIndex => _currentGameIndex;
int get currentEpisodeIndex => _currentEpisodeIndex;
int get currentLevelIndex => _currentLevelIndex;
int _currentGameIndex = 0;
/// The currently active game data set.
WolfensteinData get data => _availableGames[_currentGameIndex];
/// Desired menu background color in 24-bit RGB.
final int menuBackgroundRgb;
int menuBackgroundRgb;
/// Desired menu panel color in 24-bit RGB.
final int menuPanelRgb;
int menuPanelRgb;
/// The active difficulty level, affecting enemy spawning and behavior.
Difficulty? difficulty;
@@ -126,6 +139,25 @@ class WolfEngine {
/// Callback triggered whenever renderer settings are updated by the engine.
final void Function(WolfRendererSettings settings)? onRendererSettingsChanged;
/// Optional host adapter that persists save-game bytes by slot.
final SaveGamePersistence? saveGamePersistence;
/// Binary codec used to encode/decode persisted save files.
final SaveGameCodec saveGameCodec;
/// Menu quick slot used for LOAD/SAVE GAME until slot UI is implemented.
final int defaultSaveSlot;
bool _isSaveLoadBusy = false;
String? _lastSaveLoadError;
bool _hasLoadableSave = false;
bool get isSaveLoadBusy => _isSaveLoadBusy;
String? get lastSaveLoadError => _lastSaveLoadError;
bool get hasLoadableSave => _hasLoadableSave;
/// Host-reported mode/effect capabilities that drive menu visibility.
WolfRendererCapabilities rendererCapabilities;
@@ -200,6 +232,7 @@ class WolfEngine {
void init() {
_currentGameIndex = 0;
audio.activeGame = data;
_applyMenuPresentationTheme();
onGameSelected?.call(data);
_currentEpisodeIndex = startingEpisode ?? 0;
@@ -211,7 +244,9 @@ class WolfEngine {
initialEpisodeIndex: _currentEpisodeIndex,
initialDifficulty: difficulty,
hasResumableGame: false,
hasLoadableSave: _hasLoadableSave,
initialGameIsRetail: data.version == GameVersion.retail,
initialGameIsSpear: _isSpearVariant(data.version),
);
if (_availableGames.length == 1) {
@@ -225,6 +260,10 @@ class WolfEngine {
}
isInitialized = true;
if (saveGamePersistence != null) {
unawaited(_refreshLoadGameAvailability());
}
}
/// Whether a menu overlay is currently blocking gameplay updates.
@@ -233,6 +272,272 @@ class WolfEngine {
/// Whether the current gameplay session can be resumed from the main menu.
bool get canResumeGame => _hasActiveSession;
Future<bool> saveToSlot(int slot, {String description = ''}) async {
if (_isSaveLoadBusy || saveGamePersistence == null) {
return false;
}
if (!_hasActiveSession || difficulty == null) {
_lastSaveLoadError = 'No active session to save.';
return false;
}
_isSaveLoadBusy = true;
_lastSaveLoadError = null;
try {
final snapshot = captureSaveState();
final file = SaveGameFile(
slot: slot,
gameVersion: data.version,
dataVersionName: data.dataVersion.name,
description: description,
createdAtMs: DateTime.now().millisecondsSinceEpoch,
snapshot: snapshot,
checksum: 0,
);
final bytes = saveGameCodec.encode(file);
await saveGamePersistence!.save(
slot: slot,
version: data.version,
bytes: bytes,
);
_setLoadGameAvailability(true);
return true;
} catch (e) {
_lastSaveLoadError = e.toString();
return false;
} finally {
_isSaveLoadBusy = false;
}
}
Future<bool> loadFromSlot(int slot) async {
if (_isSaveLoadBusy || saveGamePersistence == null) {
return false;
}
_isSaveLoadBusy = true;
_lastSaveLoadError = null;
try {
final Uint8List? bytes = await saveGamePersistence!.load(
slot: slot,
version: data.version,
);
if (bytes == null || bytes.isEmpty) {
_lastSaveLoadError = 'No save found in slot $slot.';
unawaited(_refreshLoadGameAvailability());
return false;
}
final SaveGameFile file = saveGameCodec.decode(bytes);
GameSessionSnapshot snapshot = file.snapshot;
int gameIndex = _availableGames.indexWhere(
(game) =>
game.version == file.gameVersion &&
game.dataVersion.name == file.dataVersionName,
);
if (gameIndex < 0) {
gameIndex = _availableGames.indexWhere(
(game) => game.version == file.gameVersion,
);
}
if (gameIndex < 0) {
final int snapshotGameIndex = snapshot.currentGameIndex;
if (snapshotGameIndex >= 0 &&
snapshotGameIndex < _availableGames.length) {
gameIndex = snapshotGameIndex;
}
}
if (gameIndex < 0 || gameIndex >= _availableGames.length) {
_lastSaveLoadError =
'Save targets an unavailable game data set (${file.gameVersion.name}).';
return false;
}
if (snapshot.currentGameIndex != gameIndex) {
snapshot = _snapshotWithGameIndex(snapshot, gameIndex);
}
restoreSaveState(snapshot);
_setLoadGameAvailability(true);
return true;
} catch (e) {
_lastSaveLoadError = e.toString();
return false;
} finally {
_isSaveLoadBusy = false;
}
}
GameSessionSnapshot captureSaveState() {
if (!_hasActiveSession || difficulty == null) {
throw StateError('Cannot capture save state without an active session.');
}
return GameSessionSnapshot(
currentGameIndex: _currentGameIndex,
currentEpisodeIndex: _currentEpisodeIndex,
currentLevelIndex: _currentLevelIndex,
returnLevelIndex: _returnLevelIndex,
difficulty: difficulty!,
timeAliveMs: _timeAliveMs,
lastAcousticAlertTime: _lastAcousticAlertTime,
isMapOverlayVisible: isMapOverlayVisible,
isMenuOverlayVisible: _isMenuOverlayVisible,
player: player.toSaveState(),
currentLevel: _cloneGrid(currentLevel),
areaGrid: _cloneGrid(_areaGrid),
areasByPlayer: List<bool>.from(_areasByPlayer),
entities: entities.map(_captureEntityState).toList(growable: false),
doors: doorManager.doors.values
.map(
(door) => DoorSaveState(
x: door.x,
y: door.y,
mapId: door.mapId,
state: door.state,
offset: door.offset,
openTime: door.openTime,
),
)
.toList(growable: false),
pushwalls: pushwallManager.pushwalls.values
.map(
(pushwall) => PushwallSaveState(
x: pushwall.x,
y: pushwall.y,
mapId: pushwall.mapId,
dirX: pushwall.dirX,
dirY: pushwall.dirY,
offset: pushwall.offset,
tilesMoved: pushwall.tilesMoved,
isActive: identical(pushwallManager.activePushwall, pushwall),
),
)
.toList(growable: false),
);
}
void restoreSaveState(GameSessionSnapshot snapshot) {
if (snapshot.currentGameIndex < 0 ||
snapshot.currentGameIndex >= _availableGames.length) {
throw RangeError(
'Snapshot game index ${snapshot.currentGameIndex} is out of range.',
);
}
_currentGameIndex = snapshot.currentGameIndex;
audio.activeGame = data;
_applyMenuPresentationTheme();
menuManager.setCurrentGameVersion(data.version);
onGameSelected?.call(data);
_currentEpisodeIndex = snapshot.currentEpisodeIndex;
_currentLevelIndex = snapshot.currentLevelIndex;
_returnLevelIndex = snapshot.returnLevelIndex;
difficulty = snapshot.difficulty;
_timeAliveMs = snapshot.timeAliveMs;
_lastAcousticAlertTime = snapshot.lastAcousticAlertTime;
isMapOverlayVisible = snapshot.isMapOverlayVisible;
_isMenuOverlayVisible = snapshot.isMenuOverlayVisible;
_hasActiveSession = true;
_loadLevel(preservePlayerState: false);
currentLevel = _cloneGrid(snapshot.currentLevel);
_areaGrid = _cloneGrid(snapshot.areaGrid);
_areaCount = snapshot.areasByPlayer.length;
_areasByPlayer = List<bool>.from(snapshot.areasByPlayer);
_lastPatrolTileByEnemy.clear();
player.restoreFromSaveState(snapshot.player);
doorManager.doors.clear();
for (final doorState in snapshot.doors) {
final door = Door(x: doorState.x, y: doorState.y, mapId: doorState.mapId)
..state = doorState.state
..offset = doorState.offset
..openTime = doorState.openTime;
doorManager.doors[((door.y & 0xFFFF) << 16) | (door.x & 0xFFFF)] = door;
}
pushwallManager.pushwalls.clear();
pushwallManager.activePushwall = null;
for (final pushwallState in snapshot.pushwalls) {
final pushwall =
Pushwall(
pushwallState.x,
pushwallState.y,
pushwallState.mapId,
)
..dirX = pushwallState.dirX
..dirY = pushwallState.dirY
..offset = pushwallState.offset
..tilesMoved = pushwallState.tilesMoved;
pushwallManager.pushwalls['${pushwall.x},${pushwall.y}'] = pushwall;
if (pushwallState.isActive) {
pushwallManager.activePushwall = pushwall;
}
}
entities = snapshot.entities
.map(_restoreEntityState)
.whereType<Entity>()
.toList(growable: true);
if (_isMenuOverlayVisible) {
menuManager.showMainMenu(
hasResumableGame: true,
hasLoadableSave: _hasLoadableSave,
);
}
}
void _setLoadGameAvailability(bool isAvailable) {
_hasLoadableSave = isAvailable;
menuManager.setLoadGameAvailable(isAvailable);
}
Future<void> _refreshLoadGameAvailability() async {
if (saveGamePersistence == null) {
_setLoadGameAvailability(false);
return;
}
final bool hasSave = await saveGamePersistence!.exists(
slot: defaultSaveSlot,
version: data.version,
);
_setLoadGameAvailability(hasSave);
}
GameSessionSnapshot _snapshotWithGameIndex(
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,
);
}
/// Replaces the shared framebuffer when dimensions change.
void setFrameBuffer(int width, int height) {
if (width <= 0 || height <= 0) {
@@ -444,6 +749,72 @@ class WolfEngine {
);
}
void _applyMenuPresentationTheme() {
if (!_isSpearVariant(data.version)) {
menuBackgroundRgb = _paletteMappedRgb24(0x890000);
menuPanelRgb = _paletteMappedRgb24(0x590002);
menuManager.menuBackgroundRgb = menuBackgroundRgb;
return;
}
final presentation = WolfMenuPresentation(data);
final int resolvedBackgroundIndex = _resolvedMenuColorIndex(
presentation.backgroundIndex,
);
menuBackgroundRgb = _rgb24FromVgaIndex(resolvedBackgroundIndex);
menuPanelRgb = 0x000359;
menuManager.menuBackgroundRgb = menuBackgroundRgb;
}
int _paletteMappedRgb24(int rgb) {
final int index = _closestVgaIndexForRgb24(rgb);
return _rgb24FromVgaIndex(index);
}
int _closestVgaIndexForRgb24(int rgb24) {
final int targetR = (rgb24 >> 16) & 0xFF;
final int targetG = (rgb24 >> 8) & 0xFF;
final int targetB = rgb24 & 0xFF;
int bestIndex = 0;
int bestDistance = 0x7FFFFFFF;
for (int index = 0; index < 256; index++) {
final int argb = ColorPalette.argbFromVgaIndex(index);
final int r = (argb >> 16) & 0xFF;
final int g = (argb >> 8) & 0xFF;
final int b = argb & 0xFF;
final int dr = targetR - r;
final int dg = targetG - g;
final int db = targetB - b;
final int distance = (dr * dr) + (dg * dg) + (db * db);
if (distance < bestDistance) {
bestDistance = distance;
bestIndex = index;
}
}
return bestIndex;
}
int _resolvedMenuColorIndex(int paletteIndex) {
if (_isSpearVariant(data.version)) {
return paletteIndex;
}
if (paletteIndex >= 0x20 && paletteIndex <= 0x2F) {
return paletteIndex + 0x70;
}
return paletteIndex;
}
int _rgb24FromVgaIndex(int paletteIndex) {
final int argb = ColorPalette.argbFromVgaIndex(paletteIndex);
final int r = (argb >> 16) & 0xFF;
final int g = (argb >> 8) & 0xFF;
final int b = argb & 0xFF;
return (r << 16) | (g << 8) | b;
}
/// The primary heartbeat of the engine.
///
/// Updates all world subsystems based on the [elapsed] time.
@@ -584,10 +955,14 @@ class WolfEngine {
_syncRendererMenuModel();
menuManager.showChangeViewMenu();
break;
case WolfMenuMainAction.loadGame:
unawaited(_loadGameFromMenu());
break;
case WolfMenuMainAction.saveGame:
unawaited(_saveGameFromMenu());
break;
case WolfMenuMainAction.sound:
case WolfMenuMainAction.control:
case WolfMenuMainAction.loadGame:
case WolfMenuMainAction.saveGame:
case WolfMenuMainAction.readThis:
case WolfMenuMainAction.viewScores:
case null:
@@ -609,10 +984,15 @@ class WolfEngine {
if (menuResult.selectedIndex != null) {
_currentGameIndex = menuResult.selectedIndex!;
audio.activeGame = data;
_applyMenuPresentationTheme();
menuManager.setCurrentGameVersion(data.version);
onGameSelected?.call(data);
_currentEpisodeIndex = 0;
onEpisodeSelected?.call(null);
menuManager.clearEpisodeSelection();
if (saveGamePersistence != null) {
unawaited(_refreshLoadGameAvailability());
}
menuManager.beginIntroSplash(
includeRetailWarning: data.version == GameVersion.retail,
);
@@ -642,7 +1022,11 @@ class WolfEngine {
void _tickDifficultySelectionMenu(EngineInput input) {
final menuResult = menuManager.updateDifficultySelection(input);
if (menuResult.goBack) {
menuManager.startTransition(WolfMenuScreen.episodeSelect);
if (_isSingleEpisodeFlowForCurrentGame) {
menuManager.startTransition(WolfMenuScreen.mainMenu);
} else {
menuManager.startTransition(WolfMenuScreen.episodeSelect);
}
return;
}
@@ -654,7 +1038,10 @@ class WolfEngine {
void _tickChangeViewMenu(EngineInput input) {
final menuResult = menuManager.updateChangeViewMenu(input);
if (menuResult.goBack) {
menuManager.showMainMenu(hasResumableGame: _hasActiveSession);
menuManager.showMainMenu(
hasResumableGame: _hasActiveSession,
hasLoadableSave: _hasLoadableSave,
);
return;
}
@@ -711,16 +1098,31 @@ class WolfEngine {
void _beginNewGameMenuFlow() {
onEpisodeSelected?.call(null);
menuManager.clearEpisodeSelection();
if (_isSingleEpisodeFlowForCurrentGame) {
menuManager.startTransition(WolfMenuScreen.difficultySelect);
return;
}
menuManager.startTransition(WolfMenuScreen.episodeSelect);
}
bool get _isSingleEpisodeFlowForCurrentGame =>
_isSpearVariant(data.version) || data.episodes.length <= 1;
bool _isSpearVariant(GameVersion version) {
return version == GameVersion.spearOfDestiny ||
version == GameVersion.spearOfDestinyDemo;
}
void _openPauseMenu() {
if (!_hasActiveSession) {
return;
}
isMapOverlayVisible = false;
_isMenuOverlayVisible = true;
menuManager.showMainMenu(hasResumableGame: true);
menuManager.showMainMenu(
hasResumableGame: true,
hasLoadableSave: _hasLoadableSave,
);
}
void _resumeGame() {
@@ -745,7 +1147,10 @@ class WolfEngine {
_hasActiveSession = false;
_returnLevelIndex = null;
onEpisodeSelected?.call(null);
menuManager.showMainMenu(hasResumableGame: false);
menuManager.showMainMenu(
hasResumableGame: false,
hasLoadableSave: _hasLoadableSave,
);
}
void _exitTopLevelMenu() {
@@ -764,6 +1169,35 @@ class WolfEngine {
_exitTopLevelMenu();
}
Future<void> _saveGameFromMenu() async {
if (!_hasActiveSession || difficulty == null) {
_lastSaveLoadError = 'No active session to save.';
return;
}
if (saveGamePersistence == null || _isSaveLoadBusy) {
return;
}
await saveToSlot(
defaultSaveSlot,
description:
'E${_currentEpisodeIndex + 1}L${_currentLevelIndex + 1} ${difficulty!.name.toUpperCase()}',
);
}
Future<void> _loadGameFromMenu() async {
if (saveGamePersistence == null || _isSaveLoadBusy) {
return;
}
final bool loaded = await loadFromSlot(defaultSaveSlot);
if (!loaded) {
return;
}
_resumeGame();
}
/// Wipes the current world state and builds a new floor from map data.
void _loadLevel({required bool preservePlayerState}) {
isMapOverlayVisible = false;
@@ -1332,6 +1766,79 @@ class WolfEngine {
}
}
static SpriteMap _cloneGrid(SpriteMap grid) {
return List<List<int>>.generate(
grid.length,
(int y) => List<int>.from(grid[y]),
growable: false,
);
}
EntitySaveState _captureEntityState(Entity entity) {
if (entity is Enemy) {
return entity.toSaveState();
}
final Map<String, Object?> extraData = <String, Object?>{};
if (entity is AmmoCollectible) {
extraData['ammoAmount'] = entity.ammoAmount;
}
return EntitySaveState(
kind: entity.runtimeType.toString(),
x: entity.x,
y: entity.y,
spriteIndex: entity.spriteIndex,
angle: entity.angle,
state: entity.state,
mapId: entity.mapId,
lastActionTime: entity.lastActionTime,
extraData: extraData,
);
}
Entity? _restoreEntityState(EntitySaveState entityState) {
final Entity? entity = switch (entityState.kind) {
'SmallAmmoCollectible' => SmallAmmoCollectible(
x: entityState.x,
y: entityState.y,
),
'AmmoCollectible' => AmmoCollectible(
x: entityState.x,
y: entityState.y,
ammoAmount: (entityState.extraData['ammoAmount'] as num?)?.toInt() ?? 8,
),
_ => EntityRegistry.spawn(
entityState.mapId,
entityState.x,
entityState.y,
difficulty!,
data.sprites.length,
isSharewareMode: data.version == GameVersion.shareware,
registry: data.registry,
),
};
if (entity == null) {
return null;
}
if (entity is Enemy) {
entity.restoreFromSaveState(entityState);
return entity;
}
entity
..x = entityState.x
..y = entityState.y
..spriteIndex = entityState.spriteIndex
..angle = entityState.angle
..state = entityState.state
..mapId = entityState.mapId
..lastActionTime = entityState.lastActionTime;
return entity;
}
/// Returns true if a tile is empty or contains a door that is sufficiently open.
bool isWalkable(int x, int y) {
// 1. Boundary Guard: Prevent range errors by checking if coordinates are on the map
@@ -34,6 +34,25 @@ class Dog extends Enemy {
health = type.hitPointsFor(difficulty);
}
@override
Map<String, Object?> exportExtraSaveState() {
return <String, Object?>{
'dodgeAngleOffset': _dodgeAngleOffset,
'dodgeTicTimer': _dodgeTicTimer,
'stuckFrames': _stuckFrames,
'wasMoving': _wasMoving,
};
}
@override
void importExtraSaveState(Map<String, Object?> saveState) {
_dodgeAngleOffset =
(saveState['dodgeAngleOffset'] as num?)?.toDouble() ?? 0.0;
_dodgeTicTimer = (saveState['dodgeTicTimer'] as num?)?.toInt() ?? 0;
_stuckFrames = (saveState['stuckFrames'] as num?)?.toInt() ?? 0;
_wasMoving = saveState['wasMoving'] as bool? ?? false;
}
@override
EnemyAnimation animationForState(EntityState state) {
return switch (state) {
@@ -1,6 +1,7 @@
import 'dart:developer';
import 'dart:math' as math;
import 'package:wolf_3d_dart/src/engine/save/game_session_snapshot.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/dog.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_type.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/guard.dart';
@@ -146,6 +147,78 @@ abstract class Enemy extends Entity {
int _patrolDirX = 0;
int _patrolDirY = 0;
EntitySaveState toSaveState() {
return EntitySaveState(
kind: runtimeType.toString(),
x: x,
y: y,
spriteIndex: spriteIndex,
angle: angle,
state: state,
mapId: mapId,
lastActionTime: lastActionTime,
extraData: <String, Object?>{
'ticCount': _ticCount,
'ticAccumulator': _ticAccumulator,
'currentFrame': currentFrame,
'health': health,
'damage': damage,
'isDying': isDying,
'hasDroppedItem': hasDroppedItem,
'hasPlayedDeathSound': hasPlayedDeathSound,
'isAlerted': isAlerted,
'reactionTimeMs': reactionTimeMs,
'patrolTargetTileX': _patrolTargetTile?.x,
'patrolTargetTileY': _patrolTargetTile?.y,
'patrolDirX': _patrolDirX,
'patrolDirY': _patrolDirY,
...exportExtraSaveState(),
},
);
}
void restoreFromSaveState(EntitySaveState saveState) {
x = saveState.x;
y = saveState.y;
spriteIndex = saveState.spriteIndex;
angle = saveState.angle;
state = saveState.state;
mapId = saveState.mapId;
lastActionTime = saveState.lastActionTime;
_ticCount = (saveState.extraData['ticCount'] as num?)?.toInt() ?? 0;
_ticAccumulator =
(saveState.extraData['ticAccumulator'] as num?)?.toDouble() ?? 0.0;
currentFrame = (saveState.extraData['currentFrame'] as num?)?.toInt() ?? 0;
health = (saveState.extraData['health'] as num?)?.toInt() ?? health;
damage = (saveState.extraData['damage'] as num?)?.toInt() ?? damage;
isDying = saveState.extraData['isDying'] as bool? ?? false;
hasDroppedItem = saveState.extraData['hasDroppedItem'] as bool? ?? false;
hasPlayedDeathSound =
saveState.extraData['hasPlayedDeathSound'] as bool? ?? false;
isAlerted = saveState.extraData['isAlerted'] as bool? ?? false;
reactionTimeMs =
(saveState.extraData['reactionTimeMs'] as num?)?.toInt() ?? 0;
final int? patrolTargetTileX =
(saveState.extraData['patrolTargetTileX'] as num?)?.toInt();
final int? patrolTargetTileY =
(saveState.extraData['patrolTargetTileY'] as num?)?.toInt();
if (patrolTargetTileX != null && patrolTargetTileY != null) {
_patrolTargetTile = (x: patrolTargetTileX, y: patrolTargetTileY);
} else {
_patrolTargetTile = null;
}
_patrolDirX = (saveState.extraData['patrolDirX'] as num?)?.toInt() ?? 0;
_patrolDirY = (saveState.extraData['patrolDirY'] as num?)?.toInt() ?? 0;
importExtraSaveState(saveState.extraData);
}
Map<String, Object?> exportExtraSaveState() => const <String, Object?>{};
void importExtraSaveState(Map<String, Object?> saveState) {}
/// Processes elapsed time and returns true if the enemy's animation frame has completed.
///
/// Movement is applied continuously during the frame, but state changes (like deciding
@@ -1,6 +1,7 @@
import 'dart:developer';
import 'dart:math' as math;
import 'package:wolf_3d_dart/src/engine/save/game_session_snapshot.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy.dart';
import 'package:wolf_3d_dart/src/entities/entity.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
@@ -54,6 +55,23 @@ abstract class Weapon {
_triggerReleased = true;
}
WeaponSaveState toSaveState() {
return WeaponSaveState(
type: type,
state: state,
frameIndex: frameIndex,
lastFrameTime: lastFrameTime,
triggerReleased: _triggerReleased,
);
}
void restoreFromSaveState(WeaponSaveState saveState) {
state = saveState.state;
frameIndex = saveState.frameIndex;
lastFrameTime = saveState.lastFrameTime;
_triggerReleased = saveState.triggerReleased;
}
bool fire(int currentTime, {required int currentAmmo}) {
if (state == WeaponState.idle && currentAmmo > 0) {
if (!isAutomatic && !_triggerReleased) {
@@ -0,0 +1,3 @@
library;
export 'cli_game_loop_stub.dart' if (dart.library.io) 'cli_game_loop_io.dart';
@@ -26,7 +26,6 @@ class CliGameLoop {
'engine.input',
'CliGameLoop requires a CliInput instance.',
),
primaryRenderer = SixelRenderer(),
secondaryRenderer = AsciiRenderer(
mode: AsciiRendererMode.terminalAnsi,
@@ -162,6 +161,13 @@ class CliGameLoop {
return;
}
if (input.matchesFpsToggleShortcut(bytes)) {
engine.toggleFpsCounter();
_syncRendererFromEngine();
unawaited(persistence?.save(engine.rendererSettings));
return;
}
input.handleKey(bytes);
}
@@ -0,0 +1,37 @@
/// Web-safe stub for CLI game loop APIs.
library;
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_input.dart';
import 'package:wolf_3d_dart/wolf_3d_renderer.dart';
class CliGameLoop {
CliGameLoop({
required this.engine,
required this.onExit,
this.persistence,
this.initialSettings,
}) : input = engine.input is CliInput
? engine.input as CliInput
: throw ArgumentError.value(
engine.input,
'engine.input',
'CliGameLoop requires a CliInput instance.',
),
primaryRenderer = SixelRenderer(),
secondaryRenderer = AsciiRenderer(mode: AsciiRendererMode.terminalAnsi);
final WolfEngine engine;
final CliRendererBackend primaryRenderer;
final CliRendererBackend secondaryRenderer;
final CliInput input;
final void Function(int code) onExit;
final RendererSettingsPersistence? persistence;
final WolfRendererSettings? initialSettings;
Future<void> start() {
throw UnsupportedError('CliGameLoop is only available on dart:io hosts.');
}
void stop() {}
}
@@ -0,0 +1,45 @@
library;
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
/// Coordinates gameplay persistence concerns for host applications.
class GamePersistenceManager {
/// Creates persistence manager dependencies with overridable adapters.
GamePersistenceManager({
RendererSettingsPersistence? rendererSettingsPersistence,
SaveGamePersistence? saveGamePersistence,
}) : rendererSettingsPersistence =
rendererSettingsPersistence ??
DefaultRendererSettingsPersistence(
hostKey: rendererSettingsHostFlutter,
),
saveGamePersistence =
saveGamePersistence ?? DefaultSaveGamePersistence();
/// Persists and restores runtime renderer settings.
final RendererSettingsPersistence rendererSettingsPersistence;
/// Persists slot-based save game snapshots.
final SaveGamePersistence saveGamePersistence;
/// Loads previously persisted renderer settings.
Future<WolfRendererSettings?> loadRendererSettings() {
return rendererSettingsPersistence.load();
}
/// Loads persisted renderer settings and applies them to [engine].
Future<WolfRendererSettings?> restoreRendererSettings(
WolfEngine engine,
) async {
final WolfRendererSettings? saved = await loadRendererSettings();
if (saved != null) {
engine.updateRendererSettings(saved);
}
return saved;
}
/// Saves current renderer settings.
Future<void> saveRendererSettings(WolfRendererSettings settings) {
return rendererSettingsPersistence.save(settings);
}
}
@@ -0,0 +1,44 @@
library;
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
/// Renderer presentation mode used by host widgets.
enum GameRendererMode {
/// Software pixel renderer presented via decoded framebuffer images.
software,
/// Text-mode renderer for debugging and retro terminal aesthetics.
ascii,
/// GLSL renderer with optional CRT-style post processing.
hardware,
}
/// Maps engine renderer settings to host renderer presentation mode.
GameRendererMode gameRendererModeFromSettings(WolfRendererSettings settings) {
return switch (settings.mode) {
WolfRendererMode.hardware => GameRendererMode.hardware,
WolfRendererMode.software => GameRendererMode.software,
WolfRendererMode.ascii || WolfRendererMode.sixel => GameRendererMode.ascii,
};
}
/// Falls back to software mode when GLSL rendering is unavailable at runtime.
void handleGlslUnavailable({
required bool isMounted,
required GameRendererMode rendererMode,
required WolfEngine? engine,
}) {
if (!isMounted || rendererMode != GameRendererMode.hardware) {
return;
}
final WolfEngine? activeEngine = engine;
if (activeEngine == null) {
return;
}
activeEngine.updateRendererSettings(
activeEngine.rendererSettings.copyWith(mode: WolfRendererMode.software),
);
}
@@ -12,6 +12,9 @@ class CliInput extends Wolf3dInput {
/// Keyboard shortcut used by the CLI host to cycle ASCII themes.
String asciiThemeCycleKey = 't';
/// Keyboard shortcut used by the CLI host to toggle the FPS counter.
String fpsToggleKey = '`';
/// Human-friendly label for [rendererToggleKey] shown in CLI hints.
String get rendererToggleKeyLabel => _formatShortcutLabel(rendererToggleKey);
@@ -19,6 +22,9 @@ class CliInput extends Wolf3dInput {
String get asciiThemeCycleKeyLabel =>
_formatShortcutLabel(asciiThemeCycleKey);
/// Human-friendly label for [fpsToggleKey] shown in CLI hints.
String get fpsToggleKeyLabel => _formatShortcutLabel(fpsToggleKey);
/// Returns true when [bytes] triggers the renderer-toggle shortcut.
bool matchesRendererToggleShortcut(List<int> bytes) =>
_matchesShortcut(bytes, rendererToggleKey);
@@ -27,6 +33,10 @@ class CliInput extends Wolf3dInput {
bool matchesAsciiThemeCycleShortcut(List<int> bytes) =>
_matchesShortcut(bytes, asciiThemeCycleKey);
/// Returns true when [bytes] triggers the FPS-toggle shortcut.
bool matchesFpsToggleShortcut(List<int> bytes) =>
_matchesShortcut(bytes, fpsToggleKey);
String _formatShortcutLabel(String key) {
final String trimmed = key.trim();
if (trimmed.isEmpty) {
@@ -0,0 +1,167 @@
library;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'menu_manager_entries.dart';
import 'menu_manager_enums.dart';
part 'menu_manager_intro_mixin.dart';
part 'menu_manager_navigation_mixin.dart';
part 'menu_manager_selection_mixin.dart';
enum _WolfIntroPhase { fadeIn, hold, fadeOut }
abstract class _MenuManagerBase {
static const int transitionDurationMs = 280;
static const int introFadeDurationMs = 280;
static const int introRetailBackgroundRgb = 0xA00000;
static const int introPg13BackgroundRgb = 0x33A2E8;
static const int introTitleBackgroundRgb = 0x000000;
WolfMenuScreen _activeMenu = WolfMenuScreen.difficultySelect;
WolfMenuScreen? _transitionTarget;
WolfTransitionEffect _transitionEffect = WolfTransitionEffect.normalFade;
int _transitionElapsedMs = 0;
bool _transitionSwappedMenu = false;
WolfMenuScreen _introLandingMenu = WolfMenuScreen.mainMenu;
WolfTransitionEffect _introEffect = WolfTransitionEffect.normalFade;
int _introSlideIndex = 0;
int _introElapsedMs = 0;
_WolfIntroPhase _introPhase = _WolfIntroPhase.fadeIn;
bool _introAdvanceRequested = false;
List<WolfIntroSlide> _introSlides = <WolfIntroSlide>[
WolfIntroSlide.pg13,
WolfIntroSlide.title,
];
int _selectedMainIndex = 0;
int _selectedGameIndex = 0;
int _selectedEpisodeIndex = 0;
int _selectedDifficultyIndex = 0;
int _selectedChangeViewIndex = 0;
int _selectedRendererOptionIndex = 0;
String _rendererOptionsTitle = 'CUSTOMIZE';
List<WolfMenuRendererEntry> _changeViewEntries =
const <WolfMenuRendererEntry>[];
List<WolfMenuRendererOptionEntry> _rendererOptionEntries =
const <WolfMenuRendererOptionEntry>[];
bool _showResumeOption = false;
bool _hasLoadableSave = false;
int _gameCount = 1;
bool _isSpearVariant = false;
bool _prevUp = false;
bool _prevDown = false;
bool _prevConfirm = false;
bool _prevBack = false;
int _menuBackgroundRgb = 0x890000;
bool get isTransitioning => _transitionTarget != null;
bool get isIntroSplashActive => _activeMenu == WolfMenuScreen.introSplash;
int get changeViewItemCount =>
_changeViewEntries.length + _rendererOptionEntries.length;
void consumeEdgeState(EngineInput input) {
_prevUp = input.isMovingForward;
_prevDown = input.isMovingBackward;
_prevConfirm = input.isInteracting || input.isFiring;
_prevBack = input.isBack;
}
void resetEdgeState() {
_prevUp = false;
_prevDown = false;
_prevConfirm = false;
_prevBack = false;
}
int findSelectableIndex(
int startIndex,
int itemCount,
bool Function(int index) selectable,
) {
if (itemCount <= 0) {
return 0;
}
for (int offset = 0; offset < itemCount; offset++) {
final int index = (startIndex + offset) % itemCount;
if (selectable(index)) {
return index;
}
}
return clampIndex(startIndex, itemCount);
}
int moveSelectableIndex(
int currentIndex,
int itemCount,
int delta,
bool Function(int index) selectable,
) {
if (itemCount <= 0) {
return 0;
}
int index = currentIndex;
for (int step = 0; step < itemCount; step++) {
index = (index + delta + itemCount) % itemCount;
if (selectable(index)) {
return index;
}
}
return currentIndex;
}
int clampIndex(int index, int itemCount) {
if (itemCount <= 0) {
return 0;
}
return index.clamp(0, itemCount - 1);
}
void beginSelectionFlow({
required int gameCount,
int initialGameIndex = 0,
int initialEpisodeIndex = 0,
Difficulty? initialDifficulty,
bool hasResumableGame = false,
bool hasLoadableSave = false,
bool initialGameIsRetail = false,
bool initialGameIsSpear = false,
WolfTransitionEffect introEffect = WolfTransitionEffect.normalFade,
});
int _defaultMainMenuIndex();
}
/// Coordinates menu state, splash sequencing, and selection updates.
///
/// Hosts and renderers interact with this type through the stable
/// `src/menu/menu_manager.dart` barrel, while the implementation remains split
/// across focused files under `src/menu/manager/`.
class MenuManager extends _MenuManagerBase
with
_MenuManagerIntroMixin,
_MenuManagerSelectionMixin,
_MenuManagerNavigationMixin {
static const int transitionDurationMs = _MenuManagerBase.transitionDurationMs;
static const int introFadeDurationMs = _MenuManagerBase.introFadeDurationMs;
/// Whether to show the alternate cursor frame at [elapsedMs].
bool isCursorAltFrame(int elapsedMs) => ((elapsedMs ~/ 220) % 2) == 1;
}
class _MenuAction {
const _MenuAction({
required this.index,
required this.confirmed,
required this.goBack,
});
final int index;
final bool confirmed;
final bool goBack;
}
@@ -0,0 +1,62 @@
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
/// Logical actions exposed by the main menu.
enum WolfMenuMainAction {
newGame,
sound,
control,
loadGame,
saveGame,
changeView,
readThis,
viewScores,
endGame,
backToGame,
backToDemo,
quit,
}
/// Immutable description of a main-menu row.
class WolfMenuMainEntry {
const WolfMenuMainEntry({
required this.action,
required this.label,
this.isEnabled = true,
});
final WolfMenuMainAction action;
final String label;
final bool isEnabled;
}
/// Immutable description of a renderer row in the change-view menu.
class WolfMenuRendererEntry {
const WolfMenuRendererEntry({
required this.mode,
required this.label,
required this.hasOptions,
this.isEnabled = true,
this.isChecked = false,
});
final WolfRendererMode mode;
final String label;
final bool hasOptions;
final bool isEnabled;
final bool isChecked;
}
/// Immutable description of a renderer-specific option row.
class WolfMenuRendererOptionEntry {
const WolfMenuRendererOptionEntry({
required this.id,
required this.label,
this.isEnabled = true,
this.isChecked = false,
});
final WolfRendererOptionId id;
final String label;
final bool isEnabled;
final bool isChecked;
}
@@ -0,0 +1,19 @@
/// Menu screens handled by [MenuManager].
enum WolfMenuScreen {
introSplash,
mainMenu,
gameSelect,
episodeSelect,
difficultySelect,
changeView,
rendererOptions,
}
/// Splash slides shown before the control-panel menu.
enum WolfIntroSlide { retailWarning, pg13, title }
/// Visual effect used when entering or leaving a menu surface.
enum WolfTransitionEffect { none, normalFade, fizzleFade }
/// Phase of a two-stage menu transition effect.
enum WolfTransitionPhase { idle, covering, revealing }
@@ -0,0 +1,326 @@
part of 'menu_manager.dart';
mixin _MenuManagerIntroMixin on _MenuManagerBase {
/// The currently visible intro slide.
WolfIntroSlide get currentIntroSlide {
if (_introSlides.isEmpty) {
return WolfIntroSlide.title;
}
final int index = _introSlideIndex.clamp(0, _introSlides.length - 1);
return _introSlides[index];
}
/// Whether the retail warning card is currently visible.
bool get isIntroRetailWarningSlide =>
currentIntroSlide == WolfIntroSlide.retailWarning;
/// Whether the PG-13 splash is currently visible.
bool get isIntroPg13Slide => currentIntroSlide == WolfIntroSlide.pg13;
/// Whether the title splash is currently visible.
bool get isIntroTitleSlide => currentIntroSlide == WolfIntroSlide.title;
/// Background RGB used for the active intro slide.
int get introBackgroundRgb {
switch (currentIntroSlide) {
case WolfIntroSlide.retailWarning:
return _MenuManagerBase.introRetailBackgroundRgb;
case WolfIntroSlide.pg13:
return _MenuManagerBase.introPg13BackgroundRgb;
case WolfIntroSlide.title:
return _MenuManagerBase.introTitleBackgroundRgb;
}
}
/// Overlay alpha for the current intro transition.
double get introOverlayAlpha {
if (!isIntroSplashActive) {
return 0.0;
}
switch (_introPhase) {
case _WolfIntroPhase.fadeIn:
return (1.0 - (_introElapsedMs / _MenuManagerBase.introFadeDurationMs))
.clamp(0.0, 1.0);
case _WolfIntroPhase.hold:
return 0.0;
case _WolfIntroPhase.fadeOut:
return (_introElapsedMs / _MenuManagerBase.introFadeDurationMs).clamp(
0.0,
1.0,
);
}
}
/// Effect currently applied to the intro overlay.
WolfTransitionEffect get introOverlayEffect {
if (!isIntroSplashActive || _introPhase == _WolfIntroPhase.hold) {
return WolfTransitionEffect.none;
}
return _introEffect;
}
/// Phase currently applied to the intro overlay.
WolfTransitionPhase get introOverlayPhase {
if (!isIntroSplashActive) {
return WolfTransitionPhase.idle;
}
switch (_introPhase) {
case _WolfIntroPhase.fadeIn:
return WolfTransitionPhase.revealing;
case _WolfIntroPhase.hold:
return WolfTransitionPhase.idle;
case _WolfIntroPhase.fadeOut:
return WolfTransitionPhase.covering;
}
}
/// Normalized progress for the current intro overlay phase.
double get introOverlayPhaseProgress {
if (!isIntroSplashActive || _introPhase == _WolfIntroPhase.hold) {
return 0.0;
}
return (_introElapsedMs / _MenuManagerBase.introFadeDurationMs).clamp(
0.0,
1.0,
);
}
/// Fade alpha for active menu transitions, in the range `0.0..1.0`.
double get transitionAlpha {
if (!isTransitioning) {
return 0.0;
}
final int half = _MenuManagerBase.transitionDurationMs ~/ 2;
if (_transitionElapsedMs <= half) {
return (_transitionElapsedMs / half).clamp(0.0, 1.0);
}
final int fadeInElapsed = _transitionElapsedMs - half;
return (1.0 - (fadeInElapsed / half)).clamp(0.0, 1.0);
}
/// Effect applied to the active transition.
WolfTransitionEffect get transitionEffect {
if (!isTransitioning) {
return WolfTransitionEffect.none;
}
return _transitionEffect;
}
/// Phase of the current menu transition.
WolfTransitionPhase get transitionPhase {
if (!isTransitioning) {
return WolfTransitionPhase.idle;
}
final int half = _MenuManagerBase.transitionDurationMs ~/ 2;
if (_transitionElapsedMs < half) {
return WolfTransitionPhase.covering;
}
return WolfTransitionPhase.revealing;
}
/// Normalized progress for the current menu transition phase.
double get transitionPhaseProgress {
if (!isTransitioning) {
return 0.0;
}
final int half = _MenuManagerBase.transitionDurationMs ~/ 2;
if (_transitionElapsedMs < half) {
return (_transitionElapsedMs / half).clamp(0.0, 1.0);
}
return ((_transitionElapsedMs - half) / half).clamp(0.0, 1.0);
}
/// Resets menu state for startup and optionally begins the intro sequence.
@override
void beginSelectionFlow({
required int gameCount,
int initialGameIndex = 0,
int initialEpisodeIndex = 0,
Difficulty? initialDifficulty,
bool hasResumableGame = false,
bool hasLoadableSave = false,
bool initialGameIsRetail = false,
bool initialGameIsSpear = false,
WolfTransitionEffect introEffect = WolfTransitionEffect.normalFade,
}) {
_gameCount = gameCount;
_showResumeOption = hasResumableGame;
_hasLoadableSave = hasLoadableSave;
_selectedMainIndex = _defaultMainMenuIndex();
_selectedGameIndex = clampIndex(initialGameIndex, gameCount);
_selectedEpisodeIndex = initialEpisodeIndex < 0 ? 0 : initialEpisodeIndex;
_selectedDifficultyIndex = initialDifficulty == null
? 0
: Difficulty.values
.indexOf(initialDifficulty)
.clamp(0, Difficulty.values.length - 1);
_isSpearVariant = initialGameIsSpear;
_introLandingMenu = WolfMenuScreen.mainMenu;
if (gameCount > 1) {
_activeMenu = WolfMenuScreen.gameSelect;
_introEffect = introEffect;
_introElapsedMs = 0;
_introPhase = _WolfIntroPhase.fadeIn;
_introSlideIndex = 0;
_introSlides = <WolfIntroSlide>[
WolfIntroSlide.pg13,
WolfIntroSlide.title,
];
} else {
_startIntroSequence(
includeRetailWarning: initialGameIsRetail,
effect: introEffect,
);
}
_transitionTarget = null;
_transitionEffect = WolfTransitionEffect.normalFade;
_transitionElapsedMs = 0;
_transitionSwappedMenu = false;
resetEdgeState();
}
/// Starts the intro splash flow and lands on [landingMenu] when complete.
void beginIntroSplash({
WolfMenuScreen landingMenu = WolfMenuScreen.mainMenu,
bool includeRetailWarning = false,
WolfTransitionEffect effect = WolfTransitionEffect.normalFade,
}) {
_introLandingMenu = landingMenu;
_transitionTarget = null;
_transitionEffect = WolfTransitionEffect.normalFade;
_transitionElapsedMs = 0;
_transitionSwappedMenu = false;
_startIntroSequence(
includeRetailWarning: includeRetailWarning,
effect: effect,
);
resetEdgeState();
}
/// Starts a transition from the current menu to [target].
void startTransition(
WolfMenuScreen target, {
WolfTransitionEffect effect = WolfTransitionEffect.normalFade,
}) {
if (_activeMenu == target) {
return;
}
_transitionTarget = target;
_transitionEffect = effect;
_transitionElapsedMs = 0;
_transitionSwappedMenu = false;
resetEdgeState();
}
/// Advances active splash or menu transitions by [deltaMs].
void tickTransition(int deltaMs) {
if (isIntroSplashActive) {
_tickIntro(deltaMs);
return;
}
if (!isTransitioning) {
return;
}
_transitionElapsedMs += deltaMs;
final int half = _MenuManagerBase.transitionDurationMs ~/ 2;
if (!_transitionSwappedMenu && _transitionElapsedMs >= half) {
_activeMenu = _transitionTarget!;
_transitionSwappedMenu = true;
}
if (_transitionElapsedMs >= _MenuManagerBase.transitionDurationMs) {
_transitionTarget = null;
_transitionEffect = WolfTransitionEffect.normalFade;
_transitionElapsedMs = 0;
_transitionSwappedMenu = false;
}
}
/// Consumes input for the intro splash screen.
void updateIntroSplash(EngineInput input) {
if (!isIntroSplashActive) {
return;
}
final bool confirmNow = input.isInteracting;
if (confirmNow && !_prevConfirm) {
if (_introPhase == _WolfIntroPhase.fadeOut) {
} else if (_introPhase == _WolfIntroPhase.hold) {
_introPhase = _WolfIntroPhase.fadeOut;
_introElapsedMs = 0;
} else {
_introAdvanceRequested = true;
}
}
consumeEdgeState(input);
}
void _startIntroSequence({
required bool includeRetailWarning,
required WolfTransitionEffect effect,
}) {
_activeMenu = WolfMenuScreen.introSplash;
_introEffect = effect;
_introSlides = includeRetailWarning
? <WolfIntroSlide>[
WolfIntroSlide.retailWarning,
WolfIntroSlide.pg13,
WolfIntroSlide.title,
]
: <WolfIntroSlide>[WolfIntroSlide.pg13, WolfIntroSlide.title];
_introSlideIndex = 0;
_introElapsedMs = 0;
_introPhase = _WolfIntroPhase.fadeIn;
_introAdvanceRequested = false;
}
void _tickIntro(int deltaMs) {
if (!isIntroSplashActive) {
return;
}
_introElapsedMs += deltaMs;
switch (_introPhase) {
case _WolfIntroPhase.fadeIn:
if (_introElapsedMs >= _MenuManagerBase.introFadeDurationMs) {
_introElapsedMs = 0;
if (_introAdvanceRequested) {
_introPhase = _WolfIntroPhase.fadeOut;
_introAdvanceRequested = false;
} else {
_introPhase = _WolfIntroPhase.hold;
}
}
break;
case _WolfIntroPhase.hold:
_introElapsedMs = 0;
if (_introAdvanceRequested) {
_introPhase = _WolfIntroPhase.fadeOut;
_introAdvanceRequested = false;
}
break;
case _WolfIntroPhase.fadeOut:
if (_introElapsedMs >= _MenuManagerBase.introFadeDurationMs) {
_advanceIntroSlide();
}
break;
}
}
void _advanceIntroSlide() {
if (_introSlideIndex < _introSlides.length - 1) {
_introSlideIndex += 1;
_introElapsedMs = 0;
_introPhase = _WolfIntroPhase.fadeIn;
_introAdvanceRequested = false;
return;
}
_activeMenu = _introLandingMenu;
_introElapsedMs = 0;
_introPhase = _WolfIntroPhase.fadeIn;
_introAdvanceRequested = false;
}
}
@@ -0,0 +1,233 @@
part of 'menu_manager.dart';
mixin _MenuManagerNavigationMixin on _MenuManagerSelectionMixin {
/// Updates main-menu navigation and returns the selected action, if any.
({WolfMenuMainAction? selected, bool goBack}) updateMainMenu(
EngineInput input,
) {
if (isTransitioning) {
consumeEdgeState(input);
return (selected: null, goBack: false);
}
final _MenuAction action = _updateLinearSelection(
input,
currentIndex: selectedMainIndex,
itemCount: mainMenuEntries.length,
isSelectableIndex: _isSelectableMainIndex,
);
_selectedMainIndex = action.index;
return (
selected: action.confirmed
? mainMenuEntries[_selectedMainIndex].action
: null,
goBack: action.goBack,
);
}
/// Updates change-view navigation and returns either a mode or option choice.
({
WolfRendererMode? selectedMode,
WolfRendererOptionId? selectedOption,
bool goBack,
})
updateChangeViewMenu(EngineInput input) {
if (isTransitioning) {
consumeEdgeState(input);
return (
selectedMode: null,
selectedOption: null,
goBack: false,
);
}
final _MenuAction action = _updateLinearSelection(
input,
currentIndex: selectedChangeViewIndex,
itemCount: changeViewItemCount,
isSelectableIndex: _isSelectableChangeViewIndex,
);
_selectedChangeViewIndex = action.index;
if (!action.confirmed) {
return (
selectedMode: null,
selectedOption: null,
goBack: action.goBack,
);
}
if (_selectedChangeViewIndex < _changeViewEntries.length) {
final WolfMenuRendererEntry entry =
_changeViewEntries[_selectedChangeViewIndex];
return (
selectedMode: entry.mode,
selectedOption: null,
goBack: action.goBack,
);
}
final int optionIndex =
_selectedChangeViewIndex - _changeViewEntries.length;
if (optionIndex < 0 || optionIndex >= _rendererOptionEntries.length) {
return (
selectedMode: null,
selectedOption: null,
goBack: action.goBack,
);
}
_selectedRendererOptionIndex = optionIndex;
return (
selectedMode: null,
selectedOption: _rendererOptionEntries[optionIndex].id,
goBack: action.goBack,
);
}
/// Updates renderer-option navigation and returns the selected option, if any.
({WolfRendererOptionId? selectedOption, bool goBack})
updateRendererOptionsMenu(EngineInput input) {
if (isTransitioning) {
consumeEdgeState(input);
return (selectedOption: null, goBack: false);
}
final _MenuAction action = _updateLinearSelection(
input,
currentIndex: selectedRendererOptionIndex,
itemCount: _rendererOptionEntries.length,
isSelectableIndex: _isSelectableRendererOptionIndex,
);
_selectedRendererOptionIndex = action.index;
return (
selectedOption: action.confirmed && _rendererOptionEntries.isNotEmpty
? _rendererOptionEntries[_selectedRendererOptionIndex].id
: null,
goBack: action.goBack,
);
}
/// Updates difficulty selection and returns the selected difficulty, if any.
({Difficulty? selected, bool goBack}) updateDifficultySelection(
EngineInput input,
) {
if (isTransitioning) {
consumeEdgeState(input);
return (selected: null, goBack: false);
}
final bool upNow = input.isMovingForward;
final bool downNow = input.isMovingBackward;
final bool confirmNow = input.isInteracting || input.isFiring;
final bool backNow = input.isBack;
if (upNow && !_prevUp) {
_selectedDifficultyIndex =
(_selectedDifficultyIndex - 1 + Difficulty.values.length) %
Difficulty.values.length;
}
if (downNow && !_prevDown) {
_selectedDifficultyIndex =
(_selectedDifficultyIndex + 1) % Difficulty.values.length;
}
Difficulty? selected;
if (confirmNow && !_prevConfirm) {
selected = Difficulty.values[_selectedDifficultyIndex];
}
final bool goBack = backNow && !_prevBack;
_prevUp = upNow;
_prevDown = downNow;
_prevConfirm = confirmNow;
_prevBack = backNow;
return (selected: selected, goBack: goBack);
}
/// Updates game selection and returns the selected row index, if any.
({int? selectedIndex, bool goBack}) updateGameSelection(
EngineInput input, {
required int gameCount,
}) {
if (isTransitioning) {
consumeEdgeState(input);
return (selectedIndex: null, goBack: false);
}
final _MenuAction action = _updateLinearSelection(
input,
currentIndex: selectedGameIndex,
itemCount: gameCount,
);
_selectedGameIndex = action.index;
return (
selectedIndex: action.confirmed ? _selectedGameIndex : null,
goBack: action.goBack,
);
}
/// Updates episode selection and returns the selected row index, if any.
({int? selectedIndex, bool goBack}) updateEpisodeSelection(
EngineInput input, {
required int episodeCount,
}) {
if (isTransitioning) {
consumeEdgeState(input);
return (selectedIndex: null, goBack: false);
}
final _MenuAction action = _updateLinearSelection(
input,
currentIndex: selectedEpisodeIndex,
itemCount: episodeCount,
);
_selectedEpisodeIndex = action.index;
return (
selectedIndex: action.confirmed ? _selectedEpisodeIndex : null,
goBack: action.goBack,
);
}
_MenuAction _updateLinearSelection(
EngineInput input, {
required int currentIndex,
required int itemCount,
bool Function(int index)? isSelectableIndex,
}) {
final bool upNow = input.isMovingForward;
final bool downNow = input.isMovingBackward;
final bool confirmNow = input.isInteracting || input.isFiring;
final bool backNow = input.isBack;
int nextIndex = clampIndex(currentIndex, itemCount);
final bool Function(int index) selectable =
isSelectableIndex ?? ((_) => true);
if (itemCount > 0 && !selectable(nextIndex)) {
nextIndex = findSelectableIndex(nextIndex, itemCount, selectable);
}
if (itemCount > 0) {
if (upNow && !_prevUp) {
nextIndex = moveSelectableIndex(nextIndex, itemCount, -1, selectable);
}
if (downNow && !_prevDown) {
nextIndex = moveSelectableIndex(nextIndex, itemCount, 1, selectable);
}
}
final bool confirmed = confirmNow && !_prevConfirm && selectable(nextIndex);
final bool goBack = backNow && !_prevBack;
_prevUp = upNow;
_prevDown = downNow;
_prevConfirm = confirmNow;
_prevBack = backNow;
return _MenuAction(index: nextIndex, confirmed: confirmed, goBack: goBack);
}
}
@@ -0,0 +1,404 @@
part of 'menu_manager.dart';
mixin _MenuManagerSelectionMixin on _MenuManagerBase {
/// Index of the selected main-menu row.
int get selectedMainIndex => _selectedMainIndex;
/// Index of the selected game row.
int get selectedGameIndex => _selectedGameIndex;
/// Index of the selected episode row.
int get selectedEpisodeIndex => _selectedEpisodeIndex;
/// Index of the selected row within the change-view menu.
int get selectedChangeViewIndex => _selectedChangeViewIndex;
/// Index of the selected renderer-options row.
int get selectedRendererOptionIndex => _selectedRendererOptionIndex;
/// Index of the selected difficulty row.
int get selectedDifficultyIndex => _selectedDifficultyIndex;
/// Title shown above renderer-specific options.
String get rendererOptionsTitle => _rendererOptionsTitle;
/// Renderer entries shown in the change-view menu.
List<WolfMenuRendererEntry> get changeViewEntries =>
List<WolfMenuRendererEntry>.unmodifiable(_changeViewEntries);
/// Renderer option entries shown in the customize menu.
List<WolfMenuRendererOptionEntry> get rendererOptionEntries =>
List<WolfMenuRendererOptionEntry>.unmodifiable(_rendererOptionEntries);
/// The currently active menu screen.
WolfMenuScreen get activeMenu => _activeMenu;
/// Background RGB used by menu renderers.
int get menuBackgroundRgb => _menuBackgroundRgb;
set menuBackgroundRgb(int value) {
_menuBackgroundRgb = value;
}
/// Immutable snapshot of the current main-menu rows.
List<WolfMenuMainEntry> get mainMenuEntries {
final List<WolfMenuMainEntry> entries = <WolfMenuMainEntry>[
_mainMenuEntry(
action: WolfMenuMainAction.newGame,
label: 'NEW GAME',
),
_mainMenuEntry(
action: WolfMenuMainAction.sound,
label: 'SOUND',
),
_mainMenuEntry(
action: WolfMenuMainAction.control,
label: 'CONTROL',
),
_mainMenuEntry(
action: WolfMenuMainAction.loadGame,
label: 'LOAD GAME',
),
_mainMenuEntry(
action: WolfMenuMainAction.saveGame,
label: 'SAVE GAME',
),
_mainMenuEntry(
action: WolfMenuMainAction.changeView,
label: 'CHANGE VIEW',
),
];
if (!_isSpearVariant) {
entries.add(
_mainMenuEntry(
action: WolfMenuMainAction.readThis,
label: 'READ THIS!',
),
);
}
entries.add(
_mainMenuEntry(
action: _showResumeOption
? WolfMenuMainAction.endGame
: WolfMenuMainAction.viewScores,
label: _showResumeOption ? 'END GAME' : 'VIEW SCORES',
),
);
entries.add(
_mainMenuEntry(
action: _showResumeOption
? WolfMenuMainAction.backToGame
: WolfMenuMainAction.backToDemo,
label: _showResumeOption ? 'BACK TO GAME' : 'BACK TO DEMO',
),
);
entries.add(_mainMenuEntry(action: WolfMenuMainAction.quit, label: 'QUIT'));
return List<WolfMenuMainEntry>.unmodifiable(entries);
}
/// Updates menu variant flags from the selected game version.
void setCurrentGameVersion(GameVersion version) {
_isSpearVariant =
version == GameVersion.spearOfDestiny ||
version == GameVersion.spearOfDestinyDemo;
_selectedMainIndex = clampIndex(_selectedMainIndex, mainMenuEntries.length);
if (!_isSelectableMainIndex(_selectedMainIndex)) {
_selectedMainIndex = findSelectableIndex(
_selectedMainIndex,
mainMenuEntries.length,
_isSelectableMainIndex,
);
}
}
/// Whether the main menu can return to the game-selection step.
bool get canGoBackToGameSelection => !_showResumeOption && _gameCount > 1;
/// Resets state for a fresh difficulty-only selection flow.
void beginDifficultySelection({Difficulty? initialDifficulty}) {
beginSelectionFlow(
gameCount: 1,
initialGameIndex: 0,
initialEpisodeIndex: 0,
initialDifficulty: initialDifficulty,
);
_activeMenu = WolfMenuScreen.difficultySelect;
}
/// Rebuilds the main menu for the current runtime state.
void showMainMenu({
required bool hasResumableGame,
bool? hasLoadableSave,
}) {
_showResumeOption = hasResumableGame;
if (hasLoadableSave != null) {
_hasLoadableSave = hasLoadableSave;
}
final int itemCount = mainMenuEntries.length;
if (itemCount == 0) {
_selectedMainIndex = 0;
} else {
_selectedMainIndex = _defaultMainMenuIndex();
}
_activeMenu = WolfMenuScreen.mainMenu;
_transitionTarget = null;
_transitionEffect = WolfTransitionEffect.normalFade;
_transitionElapsedMs = 0;
_transitionSwappedMenu = false;
_introEffect = WolfTransitionEffect.normalFade;
_introElapsedMs = 0;
resetEdgeState();
}
/// Updates whether the LOAD GAME row is selectable.
void setLoadGameAvailable(bool isAvailable) {
if (_hasLoadableSave == isAvailable) {
return;
}
_hasLoadableSave = isAvailable;
final int itemCount = mainMenuEntries.length;
if (itemCount <= 0 || !_isSelectableMainIndex(_selectedMainIndex)) {
_selectedMainIndex = findSelectableIndex(
clampIndex(_selectedMainIndex, itemCount),
itemCount,
_isSelectableMainIndex,
);
}
}
/// Replaces the renderer rows displayed in the change-view menu.
void setChangeViewEntries(List<WolfMenuRendererEntry> entries) {
final WolfRendererMode? previouslySelectedMode =
(_selectedChangeViewIndex >= 0 &&
_selectedChangeViewIndex < _changeViewEntries.length)
? _changeViewEntries[_selectedChangeViewIndex].mode
: null;
_changeViewEntries = List<WolfMenuRendererEntry>.unmodifiable(entries);
final int itemCount = changeViewItemCount;
if (itemCount == 0) {
_selectedChangeViewIndex = 0;
return;
}
if (previouslySelectedMode != null) {
final int modeIndex = _changeViewEntries.indexWhere(
(entry) => entry.mode == previouslySelectedMode,
);
if (modeIndex >= 0 && _isSelectableChangeViewIndex(modeIndex)) {
_selectedChangeViewIndex = modeIndex;
return;
}
}
_selectedChangeViewIndex = findSelectableIndex(
clampIndex(_selectedChangeViewIndex, itemCount),
itemCount,
_isSelectableChangeViewIndex,
);
}
/// Replaces the renderer-specific option rows displayed in the customize menu.
void setRendererOptionEntries({
required String title,
required List<WolfMenuRendererOptionEntry> entries,
}) {
final bool wasSelectingOption =
_selectedChangeViewIndex >= _changeViewEntries.length;
final WolfRendererOptionId? previousOption =
(_selectedRendererOptionIndex >= 0 &&
_selectedRendererOptionIndex < _rendererOptionEntries.length)
? _rendererOptionEntries[_selectedRendererOptionIndex].id
: null;
_rendererOptionsTitle = title;
_rendererOptionEntries = List<WolfMenuRendererOptionEntry>.unmodifiable(
entries,
);
final int totalCount = changeViewItemCount;
if (_rendererOptionEntries.isEmpty || totalCount == 0) {
_selectedRendererOptionIndex = 0;
if (_changeViewEntries.isNotEmpty) {
_selectedChangeViewIndex = findSelectableIndex(
0,
_changeViewEntries.length,
_isSelectableChangeViewIndex,
);
}
return;
}
if (previousOption != null) {
final int previousIndex = _rendererOptionEntries.indexWhere(
(entry) => entry.id == previousOption,
);
if (previousIndex >= 0 &&
_isSelectableRendererOptionIndex(previousIndex)) {
_selectedRendererOptionIndex = previousIndex;
if (wasSelectingOption) {
_selectedChangeViewIndex = _changeViewEntries.length + previousIndex;
}
return;
}
}
_selectedRendererOptionIndex = findSelectableIndex(
clampIndex(_selectedRendererOptionIndex, _rendererOptionEntries.length),
_rendererOptionEntries.length,
_isSelectableRendererOptionIndex,
);
if (wasSelectingOption) {
_selectedChangeViewIndex =
_changeViewEntries.length + _selectedRendererOptionIndex;
} else {
_selectedChangeViewIndex = findSelectableIndex(
clampIndex(_selectedChangeViewIndex, totalCount),
totalCount,
_isSelectableChangeViewIndex,
);
}
}
/// Switches the active menu to the renderer-selection screen.
void showChangeViewMenu() {
_activeMenu = WolfMenuScreen.changeView;
_selectedChangeViewIndex = changeViewItemCount == 0
? 0
: findSelectableIndex(
0,
changeViewItemCount,
_isSelectableChangeViewIndex,
);
_transitionTarget = null;
_transitionEffect = WolfTransitionEffect.normalFade;
_transitionElapsedMs = 0;
_transitionSwappedMenu = false;
resetEdgeState();
}
/// Switches the active menu to the renderer-options screen.
void showRendererOptionsMenu() {
_activeMenu = WolfMenuScreen.rendererOptions;
_selectedRendererOptionIndex = _rendererOptionEntries.isEmpty
? 0
: findSelectableIndex(
0,
_rendererOptionEntries.length,
_isSelectableRendererOptionIndex,
);
_transitionTarget = null;
_transitionEffect = WolfTransitionEffect.normalFade;
_transitionElapsedMs = 0;
_transitionSwappedMenu = false;
resetEdgeState();
}
/// Clears the selected episode back to the first row.
void clearEpisodeSelection() {
_selectedEpisodeIndex = 0;
}
/// Consumes the current input snapshot as the new edge baseline.
void absorbInputState(EngineInput input) {
consumeEdgeState(input);
}
/// Stores the current episode selection, clamped to the available row count.
void setSelectedEpisodeIndex(int index, int episodeCount) {
_selectedEpisodeIndex = clampIndex(index, episodeCount);
}
/// Stores the current game selection, clamped to the available row count.
void setSelectedGameIndex(int index, int gameCount) {
_selectedGameIndex = clampIndex(index, gameCount);
}
@override
int _defaultMainMenuIndex() {
final WolfMenuMainAction target = _showResumeOption
? WolfMenuMainAction.backToGame
: WolfMenuMainAction.newGame;
final int found = mainMenuEntries.indexWhere(
(entry) => entry.action == target,
);
return found >= 0
? found
: findSelectableIndex(
0,
mainMenuEntries.length,
_isSelectableMainIndex,
);
}
bool _isSelectableMainIndex(int index) {
if (index < 0 || index >= mainMenuEntries.length) {
return false;
}
return mainMenuEntries[index].isEnabled;
}
bool _isSelectableChangeViewIndex(int index) {
if (index < 0) {
return false;
}
if (index < _changeViewEntries.length) {
return _changeViewEntries[index].isEnabled;
}
final int optionIndex = index - _changeViewEntries.length;
if (optionIndex >= 0 && optionIndex < _rendererOptionEntries.length) {
return _rendererOptionEntries[optionIndex].isEnabled;
}
return false;
}
bool _isSelectableRendererOptionIndex(int index) {
if (index < 0 || index >= _rendererOptionEntries.length) {
return false;
}
return _rendererOptionEntries[index].isEnabled;
}
bool _isWiredMainMenuAction(WolfMenuMainAction action) {
switch (action) {
case WolfMenuMainAction.newGame:
case WolfMenuMainAction.loadGame:
case WolfMenuMainAction.saveGame:
case WolfMenuMainAction.endGame:
case WolfMenuMainAction.backToGame:
case WolfMenuMainAction.backToDemo:
case WolfMenuMainAction.quit:
case WolfMenuMainAction.changeView:
return true;
case WolfMenuMainAction.sound:
case WolfMenuMainAction.control:
case WolfMenuMainAction.readThis:
case WolfMenuMainAction.viewScores:
return false;
}
}
WolfMenuMainEntry _mainMenuEntry({
required WolfMenuMainAction action,
required String label,
}) {
bool isEnabled = _isWiredMainMenuAction(action);
if (action == WolfMenuMainAction.loadGame) {
isEnabled = isEnabled && _hasLoadableSave;
}
if (action == WolfMenuMainAction.saveGame) {
isEnabled = isEnabled && _showResumeOption;
}
return WolfMenuMainEntry(
action: action,
label: label,
isEnabled: isEnabled,
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
/// Known VGA picture indexes used by the original Wolf3D control-panel menus.
///
/// Values below are picture-table indexes (not raw chunk ids).
/// For example, `C_CONTROLPIC` is chunk 26 in `GFXV_WL6.H`, so its picture
/// index is `26 - STARTPICS(3) = 23`.
abstract class WolfMenuPic {
static const int hBj = 0; // H_BJPIC
static const int hTopWindow = 3; // H_TOPWINDOWPIC
static const int cOptions = 7; // C_OPTIONSPIC
static const int cCursor1 = 8; // C_CURSOR1PIC
static const int cCursor2 = 9; // C_CURSOR2PIC
static const int cNotSelected = 10; // C_NOTSELECTEDPIC
static const int cSelected = 11; // C_SELECTEDPIC
static const int cBabyMode = 16; // C_BABYMODEPIC
static const int cEasy = 17; // C_EASYPIC
static const int cNormal = 18; // C_NORMALPIC
static const int cHard = 19; // C_HARDPIC
static const int cControl = 23; // C_CONTROLPIC
static const int cCustomize = 24; // C_CUSTOMIZEPIC
static const int cEpisode1 = 27; // C_EPISODE1PIC
static const int cEpisode2 = 28; // C_EPISODE2PIC
static const int cEpisode3 = 29; // C_EPISODE3PIC
static const int cEpisode4 = 30; // C_EPISODE4PIC
static const int cEpisode5 = 31; // C_EPISODE5PIC
static const int cEpisode6 = 32; // C_EPISODE6PIC
static const int statusBar = 83; // STATUSBARPIC
static const int title = 84; // TITLEPIC
static const int pg13 = 85; // PG13PIC
static const int credits = 86; // CREDITSPIC
static const int highScores = 87; // HIGHSCORESPIC
static const List<int> episodePics = [
cEpisode1,
cEpisode2,
cEpisode3,
cEpisode4,
cEpisode5,
cEpisode6,
];
}
@@ -0,0 +1,153 @@
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_menu_presentation_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/classic_menu_presentation_module.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
/// Bound access to the active menu presentation for a loaded data set.
///
/// Renderers should construct this from [WolfensteinData] and consume its
/// color and art accessors instead of hard-coding variant-specific menu rules.
class WolfMenuPresentation {
/// Loaded game data used to resolve art assets.
///
/// This is `null` for host-owned fallback presentations such as setup
/// screens that need menu colors before any game data has been loaded.
final WolfensteinData? data;
/// Presentation module that supplies colors and symbolic art lookups.
final MenuPresentationModule _module;
/// Binds the active menu presentation from [data.registry].
factory WolfMenuPresentation(WolfensteinData data) {
return WolfMenuPresentation.module(
data.registry.menuPresentation,
data: data,
);
}
/// Creates a presentation from an explicit module without loaded game data.
///
/// This is useful for host UI that wants menu-consistent colors before any
/// game assets have been discovered.
const WolfMenuPresentation.module(this._module, {this.data});
/// Classic fallback presentation for host-owned UI outside a loaded game.
const WolfMenuPresentation.classic()
: this.module(const ClassicMenuPresentationModule());
/// Spear fallback presentation for host-owned UI outside a loaded game.
const WolfMenuPresentation.spear()
: this.module(const SpearMenuPresentationModule());
/// VGA palette index used for menu background fills and header band accents.
int get backgroundIndex => _module.backgroundIndex;
/// VGA palette index used for menu panel fills.
int get panelIndex => _module.panelIndex;
/// VGA palette index used for menu panel borders and separators.
int get borderIndex => _module.borderIndex;
/// VGA palette index used for emphasized or affirmative UI text.
int get emphasisIndex => _module.emphasisIndex;
/// VGA palette index used for warnings and cautionary text.
int get warningIndex => _module.warningIndex;
/// VGA palette index used for subdued or de-emphasized text.
int get mutedIndex => _module.mutedIndex;
/// VGA palette index used for the selected menu row text.
int get selectedTextIndex => _module.selectedTextIndex;
/// VGA palette index used for normal menu row text.
int get unselectedTextIndex => _module.unselectedTextIndex;
/// VGA palette index used for disabled menu row text.
int get disabledTextIndex => _module.disabledTextIndex;
/// VGA palette index used for headings.
int get headerTextIndex => _module.headerTextIndex;
/// Background color resolved to `0xAARRGGBB`.
int get backgroundColor => ColorPalette.argbFromVgaIndex(backgroundIndex);
/// Panel fill color resolved to `0xAARRGGBB`.
int get panelColor => ColorPalette.argbFromVgaIndex(panelIndex);
/// Border color resolved to `0xAARRGGBB`.
int get borderColor => ColorPalette.argbFromVgaIndex(borderIndex);
/// Heading color resolved to `0xAARRGGBB`.
int get titleColor => ColorPalette.argbFromVgaIndex(headerTextIndex);
/// Standard body text color resolved to `0xAARRGGBB`.
int get bodyColor => ColorPalette.argbFromVgaIndex(unselectedTextIndex);
/// Emphasis color resolved to `0xAARRGGBB`.
int get emphasisColor => ColorPalette.argbFromVgaIndex(emphasisIndex);
/// Warning color resolved to `0xAARRGGBB`.
int get warningColor => ColorPalette.argbFromVgaIndex(warningIndex);
/// Muted color resolved to `0xAARRGGBB`.
int get mutedColor => ColorPalette.argbFromVgaIndex(mutedIndex);
/// Selected text color resolved through the VGA 32-bit table.
int get selectedTextColor => ColorPalette.vga32Bit[selectedTextIndex];
/// Normal text color resolved through the VGA 32-bit table.
int get unselectedTextColor => ColorPalette.vga32Bit[unselectedTextIndex];
/// Disabled text color resolved through the VGA 32-bit table.
int get disabledTextColor => ColorPalette.vga32Bit[disabledTextIndex];
/// Heading text color resolved through the VGA 32-bit table.
int get headerTextColor => ColorPalette.vga32Bit[headerTextIndex];
/// Background image used by the controls/customize panel, if available.
VgaImage? get controlBackground =>
data == null ? null : _module.controlBackground(data!);
/// Title splash image, if this presentation exposes one.
VgaImage? get title => data == null ? null : _module.title(data!);
/// Main menu heading art, if available.
VgaImage? get heading => data == null ? null : _module.heading(data!);
/// Selected checkbox or marker image, if available.
VgaImage? get selectedMarker =>
data == null ? null : _module.selectedMarker(data!);
/// Unselected checkbox or marker image, if available.
VgaImage? get unselectedMarker =>
data == null ? null : _module.unselectedMarker(data!);
/// Main menu options banner image, if available.
VgaImage? get optionsLabel =>
data == null ? null : _module.optionsLabel(data!);
/// Customize/options heading image, if available.
VgaImage? get customizeLabel =>
data == null ? null : _module.customizeLabel(data!);
/// Credits image, if available.
VgaImage? get credits => data == null ? null : _module.credits(data!);
/// Episode selection art for the zero-based [episodeIndex], if available.
VgaImage? episodeOption(int episodeIndex) {
return data == null ? null : _module.episodeOption(data!, episodeIndex);
}
/// Difficulty selection art for [difficulty], if available.
VgaImage? difficultyOption(Difficulty difficulty) {
return data == null ? null : _module.difficultyOption(data!, difficulty);
}
/// Legacy numeric art lookup for classic renderer code paths.
///
/// Returns `null` when no loaded data is attached or when the requested art
/// does not exist in the active presentation.
VgaImage? mappedPic(int index) {
return data == null ? null : _module.mappedPic(data!, index);
}
}
@@ -0,0 +1,31 @@
/// Returns the platform-appropriate Wolf3D config directory path.
///
/// This file is only ever imported by native (dart:io) code paths and must
/// never be loaded on web.
library;
import 'dart:io';
/// Returns the Wolf3D config directory for the current platform.
///
/// - Linux: `$XDG_CONFIG_HOME/wolf3d` (defaults to `~/.config/wolf3d`)
/// - macOS: `~/Library/Application Support/wolf3d`
/// - Windows: `%APPDATA%/wolf3d`
/// - Other: `~/.config/wolf3d`
String platformConfigDir() {
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';
}
@@ -1,6 +1,7 @@
import 'package:wolf_3d_dart/src/registry/modules/entity_asset_module.dart';
import 'package:wolf_3d_dart/src/registry/modules/hud_module.dart';
import 'package:wolf_3d_dart/src/registry/modules/menu_pic_module.dart';
import 'package:wolf_3d_dart/src/registry/modules/menu_presentation_module.dart';
import 'package:wolf_3d_dart/src/registry/modules/music_module.dart';
import 'package:wolf_3d_dart/src/registry/modules/sfx_module.dart';
@@ -14,9 +15,10 @@ import 'package:wolf_3d_dart/src/registry/modules/sfx_module.dart';
/// data.registry.entities.resolve(EntityKey.guard)
/// data.registry.hud.faceForHealth(player.health)
/// data.registry.menu.resolve(MenuPicKey.title)
/// data.registry.menuPresentation.headerTextIndex
/// ```
///
/// To provide a fully custom asset layout, implement all five module
/// To provide a fully custom asset layout, implement all six module
/// interfaces and pass them to this constructor, then supply the resulting
/// [AssetRegistry] to [WolfensteinLoader.loadFromBytes].
class AssetRegistry {
@@ -26,6 +28,7 @@ class AssetRegistry {
required this.entities,
required this.hud,
required this.menu,
required this.menuPresentation,
});
/// Sound-effect slot resolution.
@@ -42,4 +45,7 @@ class AssetRegistry {
/// Menu VGA picture index resolution.
final MenuPicModule menu;
/// Menu presentation and color routing.
final MenuPresentationModule menuPresentation;
}
@@ -0,0 +1,21 @@
import 'package:wolf_3d_dart/src/data_types/game_version.dart';
import 'package:wolf_3d_dart/src/registry/asset_registry.dart';
import 'package:wolf_3d_dart/src/registry/built_in/built_in_music_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_demo_entity_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_demo_hud_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_demo_sfx_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_menu_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_menu_presentation_module.dart';
/// Built-in [AssetRegistry] for full Spear of Destiny (`.SOD`).
class SpearAssetRegistry extends AssetRegistry {
SpearAssetRegistry()
: super(
sfx: const SpearDemoSfxModule(),
music: const BuiltInMusicModule(GameVersion.spearOfDestiny),
entities: const SpearDemoEntityModule(),
hud: const SpearDemoHudModule(),
menu: const SpearMenuPicModule(),
menuPresentation: const SpearMenuPresentationModule(),
);
}
@@ -1,10 +1,11 @@
import 'package:wolf_3d_dart/src/data_types/game_version.dart';
import 'package:wolf_3d_dart/src/registry/asset_registry.dart';
import 'package:wolf_3d_dart/src/registry/built_in/built_in_music_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/spear_demo_entity_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/spear_demo_hud_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/spear_demo_menu_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/spear_demo_sfx_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_demo_entity_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_demo_hud_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_demo_menu_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_demo_sfx_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_menu_presentation_module.dart';
/// Built-in [AssetRegistry] for Spear of Destiny demo/shareware (`.SDM`).
class SpearDemoAssetRegistry extends AssetRegistry {
@@ -15,5 +16,6 @@ class SpearDemoAssetRegistry extends AssetRegistry {
entities: const SpearDemoEntityModule(),
hud: const SpearDemoHudModule(),
menu: const SpearDemoMenuPicModule(),
menuPresentation: const SpearMenuPresentationModule(),
);
}
@@ -0,0 +1,53 @@
import 'package:wolf_3d_dart/src/data_types/difficulty.dart';
import 'package:wolf_3d_dart/src/registry/keys/menu_pic_key.dart';
import 'package:wolf_3d_dart/src/registry/modules/menu_pic_module.dart';
/// Built-in menu-picture module for full Spear of Destiny releases (`.SOD`).
///
/// Picture indices are derived from `GFXV_SOD.H` (`chunkId - STARTPICS`).
class SpearMenuPicModule extends MenuPicModule {
const SpearMenuPicModule();
static final Map<MenuPicKey, int> _indices = {
MenuPicKey.title: 76, // TITLE1PIC
MenuPicKey.credits: 89, // CREDITSPIC
MenuPicKey.pg13: 88, // PG13PIC
MenuPicKey.controlBackground: 12, // C_CONTROLPIC
MenuPicKey.footer: 1, // C_MOUSELBACKPIC
MenuPicKey.heading: 0, // C_BACKDROPPIC
MenuPicKey.optionsLabel: 13, // C_OPTIONSPIC
MenuPicKey.customizeLabel: 6, // C_CUSTOMIZEPIC
MenuPicKey.cursorActive: 2, // C_CURSOR1PIC
MenuPicKey.cursorInactive: 3, // C_CURSOR2PIC
MenuPicKey.markerSelected: 5, // C_SELECTEDPIC
MenuPicKey.markerUnselected: 4, // C_NOTSELECTEDPIC
MenuPicKey.difficultyBaby: 18, // C_BABYMODEPIC
MenuPicKey.difficultyEasy: 19, // C_EASYPIC
MenuPicKey.difficultyNormal: 20, // C_NORMALPIC
MenuPicKey.difficultyHard: 21, // C_HARDPIC
};
@override
MenuPicRef? resolve(MenuPicKey key) {
final int? index = _indices[key];
return index != null ? MenuPicRef(index) : null;
}
@override
MenuPicKey episodeKey(int episodeIndex) {
return MenuPicKey.episode1;
}
@override
MenuPicKey difficultyKey(Difficulty difficulty) {
return switch (difficulty) {
Difficulty.baby => MenuPicKey.difficultyBaby,
Difficulty.easy => MenuPicKey.difficultyEasy,
Difficulty.medium => MenuPicKey.difficultyNormal,
Difficulty.hard => MenuPicKey.difficultyHard,
};
}
}
@@ -0,0 +1,28 @@
import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/classic_menu_presentation_module.dart';
/// Built-in menu presentation for Spear variants.
///
/// Spear currently reuses the classic control-panel palette and layout rules,
/// but keeping it as a distinct concrete type gives Spear-specific releases and
/// user mods a stable place to diverge without changing retail/shareware
/// defaults.
class SpearMenuPresentationModule extends ClassicMenuPresentationModule {
/// Creates the default Spear menu presentation.
const SpearMenuPresentationModule();
/// Spear VGA background color (`BKGDCOLOR` in `WL_MENU.H`).
@override
int get backgroundIndex => 0x9D;
/// Spear panel fill color (`BORD2COLOR` in `WL_MENU.H`).
@override
int get panelIndex => 0x93;
/// Spear panel border color (`BORDCOLOR` in `WL_MENU.H`).
@override
int get borderIndex => 0x99;
/// Spear disabled/deactivated text color (`DEACTIVE` in `WL_MENU.H`).
@override
int get disabledTextIndex => 0x9B;
}
@@ -0,0 +1,192 @@
import 'package:wolf_3d_dart/src/data_types/difficulty.dart';
import 'package:wolf_3d_dart/src/data_types/image.dart';
import 'package:wolf_3d_dart/src/data_types/wolfenstein_data.dart';
import 'package:wolf_3d_dart/src/menu/wolf_menu_pic.dart';
import 'package:wolf_3d_dart/src/registry/keys/menu_pic_key.dart';
import 'package:wolf_3d_dart/src/registry/modules/menu_presentation_module.dart';
/// Built-in menu presentation that mirrors the classic Wolf3D UI.
class ClassicMenuPresentationModule extends MenuPresentationModule {
/// Creates the classic Wolf3D menu presentation.
const ClassicMenuPresentationModule();
/// Classic menu background (`BKGDCOLOR` in `WL_MENU.H`).
@override
int get backgroundIndex => 0x2D;
/// Classic panel fill (`BORD2COLOR` in `WL_MENU.H`).
@override
int get panelIndex => 0x23;
/// Classic panel border (`BORDCOLOR` in `WL_MENU.H`).
@override
int get borderIndex => 0x29;
/// Highlight text (`HIGHLIGHT` in `WL_MENU.H`).
@override
int get emphasisIndex => 0x13;
/// Read-screen highlight (`READHCOLOR` in `WL_MENU.H`).
@override
int get warningIndex => 0x47;
/// Read-screen body text (`READCOLOR` in `WL_MENU.H`).
@override
int get mutedIndex => 0x4A;
/// Selected menu text (`HIGHLIGHT` in `WL_MENU.H`).
@override
int get selectedTextIndex => 0x13;
/// Unselected menu text (`TEXTCOLOR` in `WL_MENU.H`).
@override
int get unselectedTextIndex => 0x17;
/// Disabled menu text (`DEACTIVE` in `WL_MENU.H`).
@override
int get disabledTextIndex => 0x2B;
/// Header/read highlight (`READHCOLOR` in `WL_MENU.H`).
@override
int get headerTextIndex => 0x47;
/// Controls/customize panel background art.
@override
VgaImage? controlBackground(WolfensteinData data) =>
_imageForKey(data, MenuPicKey.controlBackground);
/// Title splash art.
@override
VgaImage? title(WolfensteinData data) => _imageForKey(data, MenuPicKey.title);
/// Main menu heading art.
@override
VgaImage? heading(WolfensteinData data) =>
_imageForKey(data, MenuPicKey.heading);
/// Selected marker art.
@override
VgaImage? selectedMarker(WolfensteinData data) =>
_imageForKey(data, MenuPicKey.markerSelected);
/// Unselected marker art.
@override
VgaImage? unselectedMarker(WolfensteinData data) =>
_imageForKey(data, MenuPicKey.markerUnselected);
/// Main options banner art.
@override
VgaImage? optionsLabel(WolfensteinData data) =>
_imageForKey(data, MenuPicKey.optionsLabel);
/// Customize heading art.
@override
VgaImage? customizeLabel(WolfensteinData data) =>
_imageForKey(data, MenuPicKey.customizeLabel);
/// Credits art.
@override
VgaImage? credits(WolfensteinData data) =>
_imageForKey(data, MenuPicKey.credits);
/// Episode selection art resolved through the active registry mapping.
@override
VgaImage? episodeOption(WolfensteinData data, int episodeIndex) {
if (episodeIndex < 0) {
return null;
}
return _imageForKey(data, data.registry.menu.episodeKey(episodeIndex));
}
/// Difficulty art resolved through the active registry mapping.
@override
VgaImage? difficultyOption(WolfensteinData data, Difficulty difficulty) {
return _imageForKey(data, data.registry.menu.difficultyKey(difficulty));
}
/// Resolves legacy numeric IDs through symbolic keys first.
@override
VgaImage? mappedPic(WolfensteinData data, int index) {
final key = _legacyKeyForIndex(index);
if (key != null) {
return _imageForKey(data, key);
}
return _pic(data, index);
}
/// Loads a symbolic menu picture through the active registry.
VgaImage? _imageForKey(WolfensteinData data, MenuPicKey key) {
final ref = data.registry.menu.resolve(key);
if (ref == null) {
return null;
}
return _pic(data, ref.pictureIndex);
}
/// Safely returns the VGA image at [index] when it contains usable pixels.
VgaImage? _pic(WolfensteinData data, int index) {
if (index < 0 || index >= data.vgaImages.length) {
return null;
}
final image = data.vgaImages[index];
if (image.width <= 0 || image.height <= 0) {
return null;
}
return image;
}
/// Maps classic numeric menu picture IDs to symbolic menu keys.
///
/// This preserves the old renderer-facing numbering scheme while routing the
/// actual picture resolution through the registry layer.
MenuPicKey? _legacyKeyForIndex(int index) {
switch (index) {
case WolfMenuPic.hTopWindow:
return MenuPicKey.heading;
case WolfMenuPic.cOptions:
return MenuPicKey.optionsLabel;
case WolfMenuPic.cCursor1:
return MenuPicKey.cursorActive;
case WolfMenuPic.cCursor2:
return MenuPicKey.cursorInactive;
case WolfMenuPic.cNotSelected:
return MenuPicKey.markerUnselected;
case WolfMenuPic.cSelected:
return MenuPicKey.markerSelected;
case 15:
return MenuPicKey.footer;
case WolfMenuPic.cBabyMode:
return MenuPicKey.difficultyBaby;
case WolfMenuPic.cEasy:
return MenuPicKey.difficultyEasy;
case WolfMenuPic.cNormal:
return MenuPicKey.difficultyNormal;
case WolfMenuPic.cHard:
return MenuPicKey.difficultyHard;
case WolfMenuPic.cControl:
return MenuPicKey.controlBackground;
case WolfMenuPic.cCustomize:
return MenuPicKey.customizeLabel;
case WolfMenuPic.cEpisode1:
return MenuPicKey.episode1;
case WolfMenuPic.cEpisode2:
return MenuPicKey.episode2;
case WolfMenuPic.cEpisode3:
return MenuPicKey.episode3;
case WolfMenuPic.cEpisode4:
return MenuPicKey.episode4;
case WolfMenuPic.cEpisode5:
return MenuPicKey.episode5;
case WolfMenuPic.cEpisode6:
return MenuPicKey.episode6;
case WolfMenuPic.title:
return MenuPicKey.title;
case WolfMenuPic.pg13:
return MenuPicKey.pg13;
case WolfMenuPic.credits:
return MenuPicKey.credits;
default:
return null;
}
}
}
@@ -2,9 +2,10 @@ import 'package:wolf_3d_dart/src/data_types/game_version.dart';
import 'package:wolf_3d_dart/src/registry/asset_registry.dart';
import 'package:wolf_3d_dart/src/registry/built_in/built_in_music_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/built_in_sfx_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/retail_entity_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/retail_hud_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/retail_menu_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/classic_menu_presentation_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/retail_entity_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/retail_hud_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/retail_menu_module.dart';
/// The canonical [AssetRegistry] for all retail Wolf3D releases.
///
@@ -19,5 +20,6 @@ class RetailAssetRegistry extends AssetRegistry {
entities: const RetailEntityModule(),
hud: const RetailHudModule(),
menu: const RetailMenuPicModule(),
menuPresentation: const ClassicMenuPresentationModule(),
);
}
@@ -2,9 +2,10 @@ import 'package:wolf_3d_dart/src/data_types/game_version.dart';
import 'package:wolf_3d_dart/src/registry/asset_registry.dart';
import 'package:wolf_3d_dart/src/registry/built_in/built_in_music_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/built_in_sfx_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/shareware_entity_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/shareware_hud_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/shareware_menu_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/classic_menu_presentation_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/shareware_entity_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/shareware_hud_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/shareware_menu_module.dart';
/// The [AssetRegistry] for the Wolfenstein 3D v1.4 Shareware release.
///
@@ -27,6 +28,7 @@ class SharewareAssetRegistry extends AssetRegistry {
menu: SharewareMenuPicModule(
useOriginalWl1Map: strictOriginalShareware,
),
menuPresentation: const ClassicMenuPresentationModule(),
);
/// Convenience accessor to the menu module for post-load initialisation.
@@ -6,9 +6,9 @@ import 'package:wolf_3d_dart/src/registry/modules/menu_pic_module.dart';
///
/// Shareware VGAGRAPH contains fewer pictures than the retail version, so
/// the episode/difficulty/control-panel art sits at a shifted position in
/// the VGA image list. The exact shift is computed at resolve time by
/// the VGA image list. The exact shift is computed at resolve time by
/// scanning the loaded image list for the landmark STATUSBARPIC, mirroring
/// the runtime heuristic in the original [WolfClassicMenuArt._indexOffset].
/// the same runtime heuristic used by the built-in classic menu presentation.
///
/// Offset determination is deferred until the first [resolve] call and
/// cached for subsequent lookups. If the landmark cannot be found the
@@ -0,0 +1,78 @@
import 'package:wolf_3d_dart/src/data_types/difficulty.dart';
import 'package:wolf_3d_dart/src/data_types/image.dart';
import 'package:wolf_3d_dart/src/data_types/wolfenstein_data.dart';
/// Provides the visual presentation for Wolf3D menus.
///
/// A presentation module owns both menu text colors and the symbolic art
/// lookups needed by renderers. Pair it with a [MenuPicModule] inside an
/// [AssetRegistry] to support built-in variants or fully custom user-defined
/// menus.
abstract class MenuPresentationModule {
/// Creates a menu presentation module.
const MenuPresentationModule();
/// VGA palette index used for menu background fills and header band accents.
int get backgroundIndex;
/// VGA palette index used for menu panel fills.
int get panelIndex;
/// VGA palette index used for panel borders and separators.
int get borderIndex;
/// VGA palette index used for emphasized UI text.
int get emphasisIndex;
/// VGA palette index used for warnings and cautionary text.
int get warningIndex;
/// VGA palette index used for subdued UI text.
int get mutedIndex;
/// VGA palette index used for the selected menu row text.
int get selectedTextIndex;
/// VGA palette index used for normal menu row text.
int get unselectedTextIndex;
/// VGA palette index used for disabled menu row text.
int get disabledTextIndex;
/// VGA palette index used for headings and title text.
int get headerTextIndex;
/// Returns the controls/customize panel background image, if supported.
VgaImage? controlBackground(WolfensteinData data);
/// Returns the title splash image, if supported.
VgaImage? title(WolfensteinData data);
/// Returns the primary heading art for the main menu, if supported.
VgaImage? heading(WolfensteinData data);
/// Returns the selected marker image, if supported.
VgaImage? selectedMarker(WolfensteinData data);
/// Returns the unselected marker image, if supported.
VgaImage? unselectedMarker(WolfensteinData data);
/// Returns the main options banner image, if supported.
VgaImage? optionsLabel(WolfensteinData data);
/// Returns the customize/options heading image, if supported.
VgaImage? customizeLabel(WolfensteinData data);
/// Returns the credits image, if supported.
VgaImage? credits(WolfensteinData data);
/// Returns episode selection art for zero-based [episodeIndex], if supported.
VgaImage? episodeOption(WolfensteinData data, int episodeIndex);
/// Returns difficulty selection art for [difficulty], if supported.
VgaImage? difficultyOption(WolfensteinData data, Difficulty difficulty);
/// Legacy numeric lookup retained for renderer code that still reasons in
/// original VGA picture IDs.
VgaImage? mappedPic(WolfensteinData data, int index);
}
@@ -1,9 +1,10 @@
import 'package:wolf_3d_dart/src/data/data_version.dart';
import 'package:wolf_3d_dart/src/data_types/game_version.dart';
import 'package:wolf_3d_dart/src/registry/asset_registry.dart';
import 'package:wolf_3d_dart/src/registry/built_in/retail_asset_registry.dart';
import 'package:wolf_3d_dart/src/registry/built_in/shareware_asset_registry.dart';
import 'package:wolf_3d_dart/src/registry/built_in/spear_demo_asset_registry.dart';
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_asset_registry.dart';
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_demo_asset_registry.dart';
import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/retail_asset_registry.dart';
import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/shareware_asset_registry.dart';
/// The input used by [AssetRegistryResolver] to select or build a registry.
class RegistrySelectionContext {
@@ -62,6 +63,7 @@ class BuiltInAssetRegistryResolver implements AssetRegistryResolver {
case GameVersion.shareware:
return SharewareAssetRegistry();
case GameVersion.spearOfDestiny:
return SpearAssetRegistry();
case GameVersion.spearOfDestinyDemo:
return SpearDemoAssetRegistry();
}
@@ -4,6 +4,7 @@ import 'dart:math' as math;
import 'package:arcane_helper_utils/arcane_helper_utils.dart';
import 'package:wolf_3d_dart/src/menu/menu_manager.dart';
import 'package:wolf_3d_dart/src/rendering/fizzle_fade.dart';
import 'package:wolf_3d_dart/src/rendering/menu_header_band.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_entities.dart';
@@ -546,36 +547,45 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
engine.menuManager.menuBackgroundRgb,
);
final int panelColor = _rgbToPaletteColor(engine.menuPanelRgb);
final int headingColor = WolfMenuPalette.headerTextColor;
final int selectedTextColor = WolfMenuPalette.selectedTextColor;
final int unselectedTextColor = WolfMenuPalette.unselectedTextColor;
final int disabledTextColor = WolfMenuPalette.disabledTextColor;
final menu = WolfMenuPresentation(engine.data);
final bool isSpearVariant = MenuHeaderBand.isSpearVariant(
engine.data.version,
);
final int headingColor = menu.headerTextColor;
final int selectedTextColor = menu.selectedTextColor;
final int unselectedTextColor = menu.unselectedTextColor;
final int disabledTextColor = menu.disabledTextColor;
final _AsciiMenuTypography menuTypography = _resolveMenuTypography();
if (_usesTerminalLayout) {
if (isSpearVariant && menu.heading != null) {
_drawTiledMenuBackdrop(menu.heading!, bgColor);
} else if (_usesTerminalLayout) {
_fillTerminalRect(0, 0, width, _terminalPixelHeight, bgColor);
} else {
_fillRect(0, 0, width, height, activeTheme.solid, bgColor);
}
final art = WolfClassicMenuArt(engine.data);
final optionsLabel = art.optionsLabel;
final optionsLabel = menu.optionsLabel;
if (optionsLabel != null) {
_mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
_mainMenuBandFirstColumn = MenuHeaderBand.firstColumn(optionsLabel);
}
if (engine.menuManager.activeMenu == WolfMenuScreen.introSplash) {
_drawIntroSplash(engine, art, menuTypography);
_drawIntroSplash(engine, menu, menuTypography);
return;
}
if (engine.menuManager.activeMenu == WolfMenuScreen.mainMenu) {
_fillRect320(68, 52, 178, 136, panelColor);
final optionsLabel = art.optionsLabel;
final optionsLabel = menu.optionsLabel;
if (optionsLabel != null) {
final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319);
_drawMainMenuOptionsSideBars(optionsLabel, optionsX);
_drawMainMenuOptionsSideBars(
optionsLabel,
optionsX,
debugContext: 'ascii/mainMenu',
);
_blitVgaImageAscii(optionsLabel, optionsX, 0);
} else {
_drawHeaderBarStack(
@@ -591,7 +601,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
);
}
final cursor = art.mappedPic(
final cursor = menu.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const int rowYStart = 55;
@@ -630,37 +640,47 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
}
if (engine.menuManager.activeMenu == WolfMenuScreen.gameSelect) {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: bgColor,
barColor: ColorPalette.vga32Bit[0],
);
_fillRect320(28, 58, 264, 104, panelColor);
final optionsLabel = menu.optionsLabel;
if (optionsLabel != null) {
final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319);
_drawMainMenuOptionsSideBars(
optionsLabel,
optionsX,
debugContext: 'ascii/gameSelect',
);
_blitVgaImageAscii(optionsLabel, optionsX, 0);
} else {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: bgColor,
barColor: ColorPalette.vga32Bit[0],
);
_drawMenuTextCentered(
'SELECT GAME',
_headerHeadingY,
headingColor,
scale: menuTypography.headingScale,
);
}
_fillRect320(68, 52, 178, 136, panelColor);
final cursor = art.mappedPic(
final cursor = menu.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const int rowYStart = 84;
const int rowStep = 18;
const int rowYStart = 55;
const int rowStep = 13;
final List<String> rows = engine.availableGames
.map((game) => _gameTitle(game.version))
.map((game) => MenuHeaderBand.gameTitle(game.version))
.toList(growable: false);
_drawMenuTextCentered(
'SELECT GAME',
_headerHeadingY,
headingColor,
scale: menuTypography.headingScale,
);
_drawSelectableMenuRows(
typography: menuTypography,
rows: rows,
selectedIndex: engine.menuManager.selectedGameIndex,
rowYStart200: rowYStart,
rowStep200: rowStep,
textX320: 70,
panelX320: 28,
panelW320: 264,
textX320: 100,
panelX320: 68,
panelW320: 178,
colorForRow: (int _, bool isSelected) {
return isSelected ? selectedTextColor : unselectedTextColor;
},
@@ -669,7 +689,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
if (cursor != null) {
_blitVgaImageAscii(
cursor,
38,
72,
(rowYStart + (engine.menuManager.selectedGameIndex * rowStep)) - 2,
);
}
@@ -687,7 +707,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
);
_fillRect320(12, 18, 296, 168, panelColor);
final cursor = art.mappedPic(
final cursor = menu.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const int rowYStart = 24;
@@ -720,7 +740,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
// Keep episode icons visible in compact ASCII layouts so this screen
// still communicates the same visual affordances as full-size menus.
for (int i = 0; i < engine.data.episodes.length; i++) {
final image = art.episodeOption(i);
final image = menu.episodeOption(i);
if (image != null) {
_blitVgaImageAscii(image, 40, rowYStart + (i * rowStep));
}
@@ -751,7 +771,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
if (isSelected && cursor != null) {
_blitVgaImageAscii(cursor, 16, y + 2);
}
final image = art.episodeOption(i);
final image = menu.episodeOption(i);
if (image != null) {
_blitVgaImageAscii(image, 40, y);
}
@@ -780,12 +800,16 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
}
if (engine.menuManager.activeMenu == WolfMenuScreen.changeView) {
_drawCustomizeMenuHeader(art, headingColor, bgColor);
final cursor = art.mappedPic(
_drawCustomizeMenuHeader(
menu,
headingColor,
bgColor,
);
final cursor = menu.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
final selectedMarker = art.selectedMarker;
final unselectedMarker = art.unselectedMarker;
final selectedMarker = menu.selectedMarker;
final unselectedMarker = menu.unselectedMarker;
const int rowYStart = 64;
const int rowStep = 16;
const int cursorX = 62;
@@ -884,7 +908,11 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
}
if (engine.menuManager.activeMenu == WolfMenuScreen.rendererOptions) {
_drawCustomizeMenuHeader(art, headingColor, bgColor);
_drawCustomizeMenuHeader(
menu,
headingColor,
bgColor,
);
_fillRect320(56, 52, 208, 120, panelColor);
_drawMenuTextCentered(
engine.menuManager.rendererOptionsTitle,
@@ -893,11 +921,11 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
scale: 1,
);
final cursor = art.mappedPic(
final cursor = menu.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
final selectedMarker = art.selectedMarker;
final unselectedMarker = art.unselectedMarker;
final selectedMarker = menu.selectedMarker;
final unselectedMarker = menu.unselectedMarker;
const int rowYStart = 68;
const int rowStep = 20;
const int cursorX = 62;
@@ -947,14 +975,14 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
_fillRect320(28, 70, 264, 82, panelColor);
final face = art.difficultyOption(
final face = menu.difficultyOption(
Difficulty.values[selectedDifficultyIndex],
);
if (face != null) {
_blitVgaImageAscii(face, 28 + 264 - face.width - 18, 92);
}
final cursor = art.mappedPic(
final cursor = menu.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const rowYStart = 86;
@@ -1018,28 +1046,15 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
_applyMenuTransition(engine.menuManager, bgColor);
}
String _gameTitle(GameVersion version) {
switch (version) {
case GameVersion.shareware:
return 'SHAREWARE';
case GameVersion.retail:
return 'RETAIL';
case GameVersion.spearOfDestiny:
return 'SPEAR OF DESTINY';
case GameVersion.spearOfDestinyDemo:
return 'SOD DEMO';
}
}
void _drawIntroSplash(
WolfEngine engine,
WolfClassicMenuArt art,
WolfMenuPresentation menu,
_AsciiMenuTypography menuTypography,
) {
final image = switch (engine.menuManager.currentIntroSlide) {
WolfIntroSlide.retailWarning => null,
WolfIntroSlide.pg13 => art.mappedPic(WolfMenuPic.pg13),
WolfIntroSlide.title => art.mappedPic(WolfMenuPic.title),
WolfIntroSlide.pg13 => menu.mappedPic(WolfMenuPic.pg13),
WolfIntroSlide.title => menu.mappedPic(WolfMenuPic.title),
};
int splashBg = _rgbToPaletteColor(engine.menuManager.menuBackgroundRgb);
@@ -1172,17 +1187,38 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
}
}
void _drawMainMenuOptionsSideBars(VgaImage optionsLabel, int optionsX320) {
_mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
_drawScaledColumnBand(_mainMenuBandFirstColumn!);
}
List<int> _cacheFirstColumn(VgaImage image) {
final List<int> column = List<int>.filled(image.height, 0);
for (int y = 0; y < image.height; y++) {
column[y] = image.decodePixel(0, y);
}
return column;
void _drawMainMenuOptionsSideBars(
VgaImage optionsLabel,
int optionsX320, {
required String debugContext,
}) {
_mainMenuBandFirstColumn = MenuHeaderBand.firstColumn(optionsLabel);
MenuHeaderBand.applyFromHeadingImage(
image: optionsLabel,
imageX320: optionsX320,
debugContext: debugContext,
fillSideEdgesRow:
(
int y200,
int leftWidth320,
int rightStartX320,
int paletteIndex,
) {
final int color = ColorPalette.vga32Bit[paletteIndex];
if (leftWidth320 > 0) {
_fillRect320(0, y200, leftWidth320, 1, color);
}
if (rightStartX320 < 320) {
_fillRect320(
rightStartX320,
y200,
320 - rightStartX320,
1,
color,
);
}
},
);
}
void _drawScaledColumnBand(List<int> column) {
@@ -1190,6 +1226,18 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
return;
}
int firstBlack = -1;
int lastBlack = -1;
for (int y = 0; y < column.length; y++) {
if (column[y] == 0) {
firstBlack = firstBlack == -1 ? y : firstBlack;
lastBlack = y;
}
}
if (firstBlack == -1) {
return;
}
final int maxDrawHeight = _usesTerminalLayout
? _terminalPixelHeight
: height;
@@ -1198,6 +1246,9 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
for (int dy = 0; dy < destHeight; dy++) {
final int srcY = (dy / scaleY).toInt().clamp(0, column.length - 1);
if (srcY < firstBlack || srcY > lastBlack) {
continue;
}
final int fillColor = ColorPalette.vga32Bit[column[srcY]];
if (_usesTerminalLayout) {
@@ -1593,7 +1644,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
}
void _drawCustomizeMenuHeader(
WolfClassicMenuArt art,
WolfMenuPresentation menu,
int headingColor,
int backgroundColor,
) {
@@ -1603,9 +1654,14 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
barColor: ColorPalette.vga32Bit[0],
);
final VgaImage? heading = art.customizeLabel ?? art.optionsLabel;
final VgaImage? heading = menu.customizeLabel ?? menu.optionsLabel;
if (heading != null) {
final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319);
_drawMainMenuOptionsSideBars(
heading,
headingX,
debugContext: 'ascii/customizeHeader',
);
_blitVgaImageAscii(heading, headingX, 0);
return;
}
@@ -2516,4 +2572,20 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
int _rgbToPaletteColor(int rgb) {
return ColorPalette.vga32Bit[ColorPalette.findClosestPaletteIndex(rgb)];
}
void _drawTiledMenuBackdrop(VgaImage image, int fallbackColor) {
if (image.width <= 0 || image.height <= 0) {
if (_usesTerminalLayout) {
_fillTerminalRect(0, 0, width, _terminalPixelHeight, fallbackColor);
} else {
_fillRect(0, 0, width, height, activeTheme.solid, fallbackColor);
}
return;
}
for (int y = 0; y < 200; y += image.height) {
for (int x = 0; x < 320; x += image.width) {
_blitVgaImageAscii(image, x, y);
}
}
}
}
@@ -0,0 +1,162 @@
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
abstract class MenuHeaderBand {
static void Function(String message)? debugLogger;
static bool isSpearVariant(GameVersion version) {
switch (version) {
case GameVersion.spearOfDestiny:
case GameVersion.spearOfDestinyDemo:
return true;
case GameVersion.shareware:
case GameVersion.retail:
return false;
}
}
static List<int> firstColumn(VgaImage image) {
final List<int> column = List<int>.filled(image.height, 0);
final int sampleX = image.width > 1 ? 1 : 0;
for (int y = 0; y < image.height; y++) {
column[y] = image.decodePixel(sampleX, y);
}
final int effectiveRows = _effectiveBandRowCount(column);
if (effectiveRows == column.length) {
return column;
}
return column.sublist(0, effectiveRows);
}
static String gameTitle(GameVersion version) {
switch (version) {
case GameVersion.shareware:
return 'SHAREWARE';
case GameVersion.retail:
return 'RETAIL';
case GameVersion.spearOfDestiny:
return 'SPEAR OF DESTINY';
case GameVersion.spearOfDestinyDemo:
return 'SOD DEMO';
}
}
static void applyFromHeadingImage({
required VgaImage image,
required int imageX320,
required void Function(
int y200,
int leftWidth320,
int rightStartX320,
int paletteIndex,
)
fillSideEdgesRow,
int maxRows = 200,
String? debugContext,
}) {
if (image.width <= 0 || image.height <= 0) {
return;
}
final int sampledRows = image.height < maxRows ? image.height : maxRows;
if (sampledRows <= 0) {
return;
}
final int sampleX = image.width > 1 ? 1 : 0;
final List<int> sourceByRow = List<int>.filled(sampledRows, 0);
final List<bool> isBlackByRow = List<bool>.filled(sampledRows, false);
for (int y = 0; y < sampledRows; y++) {
final int sourceIndex = image.decodePixel(sampleX, y);
sourceByRow[y] = sourceIndex;
isBlackByRow[y] = sourceIndex == 0;
}
final int rowCount = _effectiveBandRowCount(sourceByRow);
if (rowCount <= 0) {
return;
}
final int leftWidth320 = imageX320.clamp(0, 320);
final int rightStartX320 = (imageX320 + image.width).clamp(0, 320);
// Extend rows that fall inside the span bounded by the first and last
// black row. Leading non-black rows (coloured image border at the top)
// and trailing non-black rows let the background show through. Interior
// non-black rows (e.g. a decorative stripe sandwiched between two black
// sections) are extended with their actual palette colour.
int firstBlack = -1;
int lastBlack = -1;
for (int y = 0; y < rowCount; y++) {
if (sourceByRow[y] == 0) {
firstBlack = firstBlack == -1 ? y : firstBlack;
lastBlack = y;
}
}
if (firstBlack == -1) {
return;
}
for (int y = firstBlack; y <= lastBlack; y++) {
fillSideEdgesRow(y, leftWidth320, rightStartX320, sourceByRow[y]);
}
final debug = debugLogger;
if (debug != null) {
final String label = debugContext ?? 'header-band';
final String runsText = _summarizeRunsByBlackState(isBlackByRow);
debug(
'$label rows=$rowCount left=$leftWidth320 right=$rightStartX320 runs=$runsText',
);
}
}
static String _summarizeRunsByBlackState(List<bool> isBlackByRow) {
if (isBlackByRow.isEmpty) {
return '';
}
int runStart = 0;
bool runIsBlack = isBlackByRow[0];
final List<String> chunks = <String>[];
for (int y = 1; y < isBlackByRow.length; y++) {
if (isBlackByRow[y] == runIsBlack) {
continue;
}
chunks.add(
'[$runStart-${y - 1}:${runIsBlack ? 'black' : 'non-black'}]',
);
runStart = y;
runIsBlack = isBlackByRow[y];
}
chunks.add(
'[$runStart-${isBlackByRow.length - 1}:${runIsBlack ? 'black' : 'non-black'}]',
);
return chunks.join(', ');
}
static int _effectiveBandRowCount(List<int> sampledByRow) {
if (sampledByRow.isEmpty) {
return 0;
}
int lastBlack = -1;
for (int y = sampledByRow.length - 1; y >= 0; y--) {
if (sampledByRow[y] == 0) {
lastBlack = y;
break;
}
}
if (lastBlack == -1) {
return sampledByRow.length;
}
final int trailingRows = sampledByRow.length - (lastBlack + 1);
const int maxTrailingRows = 3;
final int keptTrailingRows = trailingRows > maxTrailingRows
? maxTrailingRows
: trailingRows;
return lastBlack + 1 + keptTrailingRows;
}
}
@@ -9,6 +9,7 @@ import 'dart:typed_data';
import 'package:wolf_3d_dart/src/input/cli_input.dart';
import 'package:wolf_3d_dart/src/menu/menu_manager.dart';
import 'package:wolf_3d_dart/src/rendering/fizzle_fade.dart';
import 'package:wolf_3d_dart/src/rendering/menu_header_band.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_entities.dart';
@@ -476,35 +477,46 @@ class SixelRenderer extends CliRendererBackend<String> {
engine.menuManager.menuBackgroundRgb,
);
final int panelColor = _rgbToPaletteIndex(engine.menuPanelRgb);
final int headingIndex = WolfMenuPalette.headerTextIndex;
final int selectedTextIndex = WolfMenuPalette.selectedTextIndex;
final int unselectedTextIndex = WolfMenuPalette.unselectedTextIndex;
final int disabledTextIndex = WolfMenuPalette.disabledTextIndex;
final menu = WolfMenuPresentation(engine.data);
final bool isSpearVariant = MenuHeaderBand.isSpearVariant(
engine.data.version,
);
final int headingIndex = menu.headerTextIndex;
final int selectedTextIndex = menu.selectedTextIndex;
final int unselectedTextIndex = menu.unselectedTextIndex;
final int disabledTextIndex = menu.disabledTextIndex;
for (int i = 0; i < _screen.length; i++) {
_screen[i] = bgColor;
if (isSpearVariant && menu.heading != null) {
_drawTiledMenuBackdrop(menu.heading!, bgColor);
} else {
for (int i = 0; i < _screen.length; i++) {
_screen[i] = bgColor;
}
}
final art = WolfClassicMenuArt(engine.data);
final optionsLabel = art.optionsLabel;
final optionsLabel = menu.optionsLabel;
if (optionsLabel != null) {
_mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
_mainMenuBandFirstColumn = MenuHeaderBand.firstColumn(optionsLabel);
}
// Draw footer first so menu panels can clip overlap in the center.
_drawMenuFooterArt(art);
_drawMenuFooterArt(menu);
if (engine.menuManager.activeMenu == WolfMenuScreen.introSplash) {
_drawIntroSplash(engine, art);
_drawIntroSplash(engine, menu);
return;
}
if (engine.menuManager.activeMenu == WolfMenuScreen.mainMenu) {
_fillRect320(68, 52, 178, 136, panelColor);
final optionsLabel = art.optionsLabel;
final optionsLabel = menu.optionsLabel;
if (optionsLabel != null) {
final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319);
_drawMainMenuOptionsSideBars(optionsLabel, optionsX);
_drawMainMenuOptionsSideBars(
optionsLabel,
optionsX,
debugContext: 'sixel/mainMenu',
);
_blitVgaImage(optionsLabel, optionsX, 0);
} else {
_drawHeaderBarStack(
@@ -520,7 +532,7 @@ class SixelRenderer extends CliRendererBackend<String> {
);
}
final cursor = art.mappedPic(
final cursor = menu.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const int rowYStart = 55;
@@ -546,31 +558,43 @@ class SixelRenderer extends CliRendererBackend<String> {
}
if (engine.menuManager.activeMenu == WolfMenuScreen.gameSelect) {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: bgColor,
barColor: 0,
);
_fillRect320(28, 58, 264, 104, panelColor);
_drawMenuTextCentered(
'SELECT GAME',
_headerHeadingY,
headingIndex,
scale: 2,
);
final cursor = art.mappedPic(
final optionsLabel = menu.optionsLabel;
if (optionsLabel != null) {
final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319);
_drawMainMenuOptionsSideBars(
optionsLabel,
optionsX,
debugContext: 'sixel/gameSelect',
);
_blitVgaImage(optionsLabel, optionsX, 0);
} else {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: bgColor,
barColor: 0,
);
_drawMenuTextCentered(
'SELECT GAME',
_headerHeadingY,
headingIndex,
scale: 2,
);
}
_fillRect320(68, 52, 178, 136, panelColor);
final cursor = menu.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const int rowYStart = 84;
const int rowStep = 18;
const int rowYStart = 55;
const int rowStep = 13;
for (int i = 0; i < engine.availableGames.length; i++) {
final bool isSelected = i == engine.menuManager.selectedGameIndex;
if (isSelected && cursor != null) {
_blitVgaImage(cursor, 38, (rowYStart + (i * rowStep)) - 2);
_blitVgaImage(cursor, 72, (rowYStart + (i * rowStep)) - 2);
}
_drawMenuText(
_gameTitle(engine.availableGames[i].version),
70,
MenuHeaderBand.gameTitle(engine.availableGames[i].version),
100,
rowYStart + (i * rowStep),
isSelected ? selectedTextIndex : unselectedTextIndex,
scale: 1,
@@ -593,7 +617,7 @@ class SixelRenderer extends CliRendererBackend<String> {
headingIndex,
scale: 2,
);
final cursor = art.mappedPic(
final cursor = menu.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const int rowYStart = 30;
@@ -604,7 +628,7 @@ class SixelRenderer extends CliRendererBackend<String> {
if (isSelected && cursor != null) {
_blitVgaImage(cursor, 16, y + 2);
}
final image = art.episodeOption(i);
final image = menu.episodeOption(i);
if (image != null) {
_blitVgaImage(image, 40, y);
}
@@ -633,12 +657,16 @@ class SixelRenderer extends CliRendererBackend<String> {
}
if (engine.menuManager.activeMenu == WolfMenuScreen.changeView) {
_drawCustomizeMenuHeader(art, headingIndex, bgColor);
final cursor = art.mappedPic(
_drawCustomizeMenuHeader(
menu,
headingIndex,
bgColor,
);
final cursor = menu.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
final selectedMarker = art.selectedMarker;
final unselectedMarker = art.unselectedMarker;
final selectedMarker = menu.selectedMarker;
final unselectedMarker = menu.unselectedMarker;
const int rowYStart = 66;
const int rowStep = 18;
const int cursorX = 62;
@@ -726,7 +754,11 @@ class SixelRenderer extends CliRendererBackend<String> {
}
if (engine.menuManager.activeMenu == WolfMenuScreen.rendererOptions) {
_drawCustomizeMenuHeader(art, headingIndex, bgColor);
_drawCustomizeMenuHeader(
menu,
headingIndex,
bgColor,
);
_fillRect320(56, 52, 208, 120, panelColor);
_drawMenuTextCentered(
engine.menuManager.rendererOptionsTitle,
@@ -735,11 +767,11 @@ class SixelRenderer extends CliRendererBackend<String> {
scale: 1,
);
final cursor = art.mappedPic(
final cursor = menu.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
final selectedMarker = art.selectedMarker;
final unselectedMarker = art.unselectedMarker;
final selectedMarker = menu.selectedMarker;
final unselectedMarker = menu.unselectedMarker;
const int rowYStart = 68;
const int rowStep = 20;
const int cursorX = 62;
@@ -781,7 +813,12 @@ class SixelRenderer extends CliRendererBackend<String> {
);
_fillRect320(28, 70, 264, 82, panelColor);
if (_useCompactMenuLayout) {
_drawCompactMenu(selectedDifficultyIndex, headingIndex, panelColor);
_drawCompactMenu(
selectedDifficultyIndex,
headingIndex,
panelColor,
menu,
);
_applyMenuTransition(engine.menuManager, bgColor);
return;
}
@@ -793,14 +830,14 @@ class SixelRenderer extends CliRendererBackend<String> {
scale: _menuHeadingScale,
);
final face = art.difficultyOption(
final face = menu.difficultyOption(
Difficulty.values[selectedDifficultyIndex],
);
if (face != null) {
_blitVgaImage(face, 28 + 264 - face.width - 10, 92);
}
final cursor = art.mappedPic(
final cursor = menu.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const rowYStart = 86;
@@ -827,7 +864,7 @@ class SixelRenderer extends CliRendererBackend<String> {
}
void _drawCustomizeMenuHeader(
WolfClassicMenuArt art,
WolfMenuPresentation menu,
int headingIndex,
int backgroundColor,
) {
@@ -837,9 +874,14 @@ class SixelRenderer extends CliRendererBackend<String> {
barColor: 0,
);
final VgaImage? heading = art.customizeLabel ?? art.optionsLabel;
final VgaImage? heading = menu.customizeLabel ?? menu.optionsLabel;
if (heading != null) {
final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319);
_drawMainMenuOptionsSideBars(
heading,
headingX,
debugContext: 'sixel/customizeHeader',
);
_blitVgaImage(heading, headingX, 0);
return;
}
@@ -865,8 +907,8 @@ class SixelRenderer extends CliRendererBackend<String> {
_drawMenuTextCentered(text, y200 + 2, textColor, scale: 1);
}
void _drawMenuFooterArt(WolfClassicMenuArt art) {
final bottom = art.mappedPic(15);
void _drawMenuFooterArt(WolfMenuPresentation menu) {
final bottom = menu.mappedPic(15);
if (bottom == null) {
return;
}
@@ -877,24 +919,11 @@ class SixelRenderer extends CliRendererBackend<String> {
);
}
String _gameTitle(GameVersion version) {
switch (version) {
case GameVersion.shareware:
return 'SHAREWARE';
case GameVersion.retail:
return 'RETAIL';
case GameVersion.spearOfDestiny:
return 'SPEAR OF DESTINY';
case GameVersion.spearOfDestinyDemo:
return 'SOD DEMO';
}
}
void _drawIntroSplash(WolfEngine engine, WolfClassicMenuArt art) {
void _drawIntroSplash(WolfEngine engine, WolfMenuPresentation menu) {
final image = switch (engine.menuManager.currentIntroSlide) {
WolfIntroSlide.retailWarning => null,
WolfIntroSlide.pg13 => art.mappedPic(WolfMenuPic.pg13),
WolfIntroSlide.title => art.mappedPic(WolfMenuPic.title),
WolfIntroSlide.pg13 => menu.mappedPic(WolfMenuPic.pg13),
WolfIntroSlide.title => menu.mappedPic(WolfMenuPic.title),
};
int splashBg = _rgbToPaletteIndex(engine.menuManager.menuBackgroundRgb);
@@ -1105,17 +1134,37 @@ class SixelRenderer extends CliRendererBackend<String> {
_fillRect320(0, mainBarTop, 320, 22, barColor);
}
void _drawMainMenuOptionsSideBars(VgaImage optionsLabel, int optionsX320) {
_mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
_drawScaledColumnBand(_mainMenuBandFirstColumn!);
}
List<int> _cacheFirstColumn(VgaImage image) {
final List<int> column = List<int>.filled(image.height, 0);
for (int y = 0; y < image.height; y++) {
column[y] = image.decodePixel(0, y);
}
return column;
void _drawMainMenuOptionsSideBars(
VgaImage optionsLabel,
int optionsX320, {
required String debugContext,
}) {
_mainMenuBandFirstColumn = MenuHeaderBand.firstColumn(optionsLabel);
MenuHeaderBand.applyFromHeadingImage(
image: optionsLabel,
imageX320: optionsX320,
debugContext: debugContext,
fillSideEdgesRow:
(
int y200,
int leftWidth320,
int rightStartX320,
int paletteIndex,
) {
if (leftWidth320 > 0) {
_fillRect320(0, y200, leftWidth320, 1, paletteIndex);
}
if (rightStartX320 < 320) {
_fillRect320(
rightStartX320,
y200,
320 - rightStartX320,
1,
paletteIndex,
);
}
},
);
}
void _drawScaledColumnBand(List<int> column) {
@@ -1123,6 +1172,18 @@ class SixelRenderer extends CliRendererBackend<String> {
return;
}
int firstBlack = -1;
int lastBlack = -1;
for (int y = 0; y < column.length; y++) {
if (column[y] == 0) {
firstBlack = firstBlack == -1 ? y : firstBlack;
lastBlack = y;
}
}
if (firstBlack == -1) {
return;
}
final double scaleY = height / 200.0;
final int destHeight = math.max(1, (column.length * scaleY).toInt());
@@ -1136,8 +1197,10 @@ class SixelRenderer extends CliRendererBackend<String> {
0,
column.length - 1,
);
final int paletteIndex = column[srcY];
final int fillIndex = paletteIndex == 0 ? 0 : paletteIndex;
if (srcY < firstBlack || srcY > lastBlack) {
continue;
}
final int fillIndex = column[srcY];
final int rowStart = drawY * width;
for (int drawX = 0; drawX < width; drawX++) {
_screen[rowStart + drawX] = fillIndex;
@@ -1157,6 +1220,7 @@ class SixelRenderer extends CliRendererBackend<String> {
int selectedDifficultyIndex,
int headingIndex,
int panelColor,
WolfMenuPresentation menu,
) {
_fillRect320(16, 52, 288, 112, panelColor);
_drawMenuTextCentered(Difficulty.menuText, 60, headingIndex, scale: 1);
@@ -1170,9 +1234,7 @@ class SixelRenderer extends CliRendererBackend<String> {
prefix + Difficulty.values[i].title,
42,
rowYStart + (i * rowStep),
isSelected
? WolfMenuPalette.selectedTextIndex
: WolfMenuPalette.unselectedTextIndex,
isSelected ? menu.selectedTextIndex : menu.unselectedTextIndex,
scale: 1,
);
}
@@ -1540,4 +1602,18 @@ class SixelRenderer extends CliRendererBackend<String> {
int _rgbToPaletteIndex(int rgb) {
return ColorPalette.findClosestPaletteIndex(rgb);
}
void _drawTiledMenuBackdrop(VgaImage image, int fallbackColor) {
if (image.width <= 0 || image.height <= 0) {
for (int i = 0; i < _screen.length; i++) {
_screen[i] = fallbackColor;
}
return;
}
for (int y = 0; y < 200; y += image.height) {
for (int x = 0; x < 320; x += image.width) {
_blitVgaImage(image, x, y);
}
}
}
}
@@ -0,0 +1,25 @@
library;
import 'dart:async';
import 'ascii_renderer.dart';
/// Web-safe stub used when dart:io is unavailable.
class SixelRenderer extends AsciiRenderer {
SixelRenderer() : super(mode: AsciiRendererMode.terminalAnsi);
bool isSixelSupported = false;
@override
bool isTerminalSizeSupported(int columns, int rows) => false;
@override
String get terminalSizeRequirement =>
'Sixel renderer is unavailable on this platform.';
static Future<bool> checkTerminalSixelSupport({
Stream<List<int>>? inputStream,
}) async {
return false;
}
}
@@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'package:wolf_3d_dart/src/menu/menu_manager.dart';
import 'package:wolf_3d_dart/src/rendering/fizzle_fade.dart';
import 'package:wolf_3d_dart/src/rendering/menu_font.dart';
import 'package:wolf_3d_dart/src/rendering/menu_header_band.dart';
import 'package:wolf_3d_dart/src/rendering/renderer_backend.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
@@ -254,31 +255,38 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
void drawMenu(WolfEngine engine) {
final int bgColor = _rgbToFrameColor(engine.menuManager.menuBackgroundRgb);
final int panelColor = _rgbToFrameColor(engine.menuPanelRgb);
final int headingColor = WolfMenuPalette.headerTextColor;
final int selectedTextColor = WolfMenuPalette.selectedTextColor;
final int unselectedTextColor = WolfMenuPalette.unselectedTextColor;
final int disabledTextColor = WolfMenuPalette.disabledTextColor;
final menu = WolfMenuPresentation(engine.data);
final bool isSpearVariant = MenuHeaderBand.isSpearVariant(
engine.data.version,
);
final int headingColor = menu.headerTextColor;
final int selectedTextColor = menu.selectedTextColor;
final int unselectedTextColor = menu.unselectedTextColor;
final int disabledTextColor = menu.disabledTextColor;
for (int i = 0; i < _buffer.pixels.length; i++) {
_buffer.pixels[i] = bgColor;
if (isSpearVariant && menu.heading != null) {
_drawTiledMenuBackdrop(menu.heading!, bgColor);
} else {
for (int i = 0; i < _buffer.pixels.length; i++) {
_buffer.pixels[i] = bgColor;
}
}
final art = WolfClassicMenuArt(engine.data);
final optionsLabel = art.optionsLabel;
final optionsLabel = menu.optionsLabel;
if (optionsLabel != null) {
_mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
_mainMenuBandFirstColumn = MenuHeaderBand.firstColumn(optionsLabel);
}
// Draw footer first so menu panels can clip overlap in the center.
_drawCenteredMenuFooter(art);
_drawCenteredMenuFooter(menu);
switch (engine.menuManager.activeMenu) {
case WolfMenuScreen.introSplash:
_drawIntroSplash(engine, art);
_drawIntroSplash(engine, menu);
break;
case WolfMenuScreen.mainMenu:
_drawMainMenu(
engine,
art,
menu,
panelColor,
headingColor,
selectedTextColor,
@@ -289,7 +297,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
case WolfMenuScreen.gameSelect:
_drawGameSelectMenu(
engine,
art,
menu,
panelColor,
headingColor,
selectedTextColor,
@@ -299,7 +307,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
case WolfMenuScreen.episodeSelect:
_drawEpisodeSelectMenu(
engine,
art,
menu,
panelColor,
headingColor,
selectedTextColor,
@@ -309,7 +317,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
case WolfMenuScreen.difficultySelect:
_drawDifficultyMenu(
engine,
art,
menu,
panelColor,
headingColor,
selectedTextColor,
@@ -319,7 +327,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
case WolfMenuScreen.changeView:
_drawChangeViewMenu(
engine,
art,
menu,
panelColor,
headingColor,
selectedTextColor,
@@ -330,7 +338,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
case WolfMenuScreen.rendererOptions:
_drawRendererOptionsMenu(
engine,
art,
menu,
panelColor,
headingColor,
selectedTextColor,
@@ -343,11 +351,11 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
_applyMenuTransition(engine.menuManager, bgColor);
}
void _drawIntroSplash(WolfEngine engine, WolfClassicMenuArt art) {
void _drawIntroSplash(WolfEngine engine, WolfMenuPresentation menu) {
final image = switch (engine.menuManager.currentIntroSlide) {
WolfIntroSlide.retailWarning => null,
WolfIntroSlide.pg13 => art.mappedPic(WolfMenuPic.pg13),
WolfIntroSlide.title => art.mappedPic(WolfMenuPic.title),
WolfIntroSlide.pg13 => menu.mappedPic(WolfMenuPic.pg13),
WolfIntroSlide.title => menu.mappedPic(WolfMenuPic.title),
};
int splashBgColor = _rgbToFrameColor(engine.menuManager.menuBackgroundRgb);
@@ -478,7 +486,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
void _drawMainMenu(
WolfEngine engine,
WolfClassicMenuArt art,
WolfMenuPresentation menu,
int panelColor,
int headingColor,
int selectedTextColor,
@@ -491,10 +499,14 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
const int panelH = 136;
_fillCanonicalRect(panelX, panelY, panelW, panelH, panelColor);
final optionsLabel = art.optionsLabel;
final optionsLabel = menu.optionsLabel;
if (optionsLabel != null) {
final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319);
_drawMainMenuOptionsSideBars(optionsLabel, optionsX);
_drawMainMenuOptionsSideBars(
optionsLabel,
optionsX,
debugContext: 'software/mainMenu',
);
_blitVgaImage(optionsLabel, optionsX, 0);
} else {
_drawHeaderBarStack(
@@ -510,7 +522,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
);
}
final cursor = art.mappedPic(
final cursor = menu.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const int rowYStart = 55;
@@ -538,7 +550,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
void _drawChangeViewMenu(
WolfEngine engine,
WolfClassicMenuArt art,
WolfMenuPresentation menu,
int panelColor,
int headingColor,
int selectedTextColor,
@@ -557,9 +569,14 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
const int optionsPanelX = 46;
const int optionsPanelW = 228;
final VgaImage? heading = art.customizeLabel ?? art.optionsLabel;
final VgaImage? heading = menu.customizeLabel ?? menu.optionsLabel;
if (heading != null) {
final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319);
_drawMainMenuOptionsSideBars(
heading,
headingX,
debugContext: 'software/changeView',
);
_blitVgaImage(heading, headingX, 0);
} else {
_drawCanonicalMenuTextCentered(
@@ -570,9 +587,9 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
);
}
final VgaImage? selectedMarker = art.selectedMarker;
final VgaImage? unselectedMarker = art.unselectedMarker;
final VgaImage? cursor = art.mappedPic(
final VgaImage? selectedMarker = menu.selectedMarker;
final VgaImage? unselectedMarker = menu.unselectedMarker;
final VgaImage? cursor = menu.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
@@ -684,7 +701,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
void _drawRendererOptionsMenu(
WolfEngine engine,
WolfClassicMenuArt art,
WolfMenuPresentation menu,
int panelColor,
int headingColor,
int selectedTextColor,
@@ -703,9 +720,14 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
const int panelH = 120;
_fillCanonicalRect(panelX, panelY, panelW, panelH, panelColor);
final VgaImage? heading = art.customizeLabel ?? art.optionsLabel;
final VgaImage? heading = menu.customizeLabel ?? menu.optionsLabel;
if (heading != null) {
final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319);
_drawMainMenuOptionsSideBars(
heading,
headingX,
debugContext: 'software/rendererOptions',
);
_blitVgaImage(heading, headingX, 0);
} else {
_drawCanonicalMenuTextCentered(
@@ -716,9 +738,9 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
);
}
final VgaImage? selectedMarker = art.selectedMarker;
final VgaImage? unselectedMarker = art.unselectedMarker;
final VgaImage? cursor = art.mappedPic(
final VgaImage? selectedMarker = menu.selectedMarker;
final VgaImage? unselectedMarker = menu.unselectedMarker;
final VgaImage? cursor = menu.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
@@ -766,48 +788,58 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
void _drawGameSelectMenu(
WolfEngine engine,
WolfClassicMenuArt art,
WolfMenuPresentation menu,
int panelColor,
int headingColor,
int selectedTextColor,
int unselectedTextColor,
) {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: _rgbToFrameColor(engine.menuManager.menuBackgroundRgb),
barColor: ColorPalette.vga32Bit[0],
);
final optionsLabel = menu.optionsLabel;
if (optionsLabel != null) {
final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319);
_drawMainMenuOptionsSideBars(
optionsLabel,
optionsX,
debugContext: 'software/gameSelect',
);
_blitVgaImage(optionsLabel, optionsX, 0);
} else {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: _rgbToFrameColor(engine.menuManager.menuBackgroundRgb),
barColor: ColorPalette.vga32Bit[0],
);
_drawCanonicalMenuTextCentered(
'SELECT GAME',
_headerHeadingY,
headingColor,
scale: 2,
);
}
const int panelX = 28;
const int panelY = 58;
const int panelW = 264;
const int panelH = 104;
const int panelX = 68;
const int panelY = 52;
const int panelW = 178;
const int panelH = 136;
_fillCanonicalRect(panelX, panelY, panelW, panelH, panelColor);
_drawCanonicalMenuTextCentered(
'SELECT GAME',
_headerHeadingY,
headingColor,
scale: 2,
);
final cursor = art.mappedPic(
final cursor = menu.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const int rowYStart = 78;
const int rowStep = 20;
const int textX = 70;
const int rowYStart = 55;
const int rowStep = 13;
const int textX = 100;
final int selectedIndex = engine.menuManager.selectedGameIndex;
for (int i = 0; i < engine.availableGames.length; i++) {
final bool isSelected = i == selectedIndex;
final int y = rowYStart + (i * rowStep);
if (isSelected && cursor != null) {
_blitVgaImage(cursor, panelX + 10, y - 2);
_blitVgaImage(cursor, panelX + 4, y - 2);
}
_drawCanonicalMenuText(
_gameTitle(engine.availableGames[i].version),
MenuHeaderBand.gameTitle(engine.availableGames[i].version),
textX,
y,
isSelected ? selectedTextColor : unselectedTextColor,
@@ -837,7 +869,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
void _drawEpisodeSelectMenu(
WolfEngine engine,
WolfClassicMenuArt art,
WolfMenuPresentation menu,
int panelColor,
int headingColor,
int selectedTextColor,
@@ -862,7 +894,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
scale: 2,
);
final cursor = art.mappedPic(
final cursor = menu.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const int rowYStart = 30;
@@ -879,7 +911,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
_blitVgaImage(cursor, 16, y + 2);
}
final image = art.episodeOption(i);
final image = menu.episodeOption(i);
if (image != null) {
_blitVgaImage(image, imageX, y);
}
@@ -904,8 +936,8 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
}
}
void _drawCenteredMenuFooter(WolfClassicMenuArt art) {
final bottom = art.mappedPic(15);
void _drawCenteredMenuFooter(WolfMenuPresentation menu) {
final bottom = menu.mappedPic(15);
if (bottom != null) {
final int x = ((320 - bottom.width) ~/ 2).clamp(0, 319);
final int y = (200 - bottom.height - _menuFooterBottomMargin).clamp(
@@ -954,7 +986,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
void _drawDifficultyMenu(
WolfEngine engine,
WolfClassicMenuArt art,
WolfMenuPresentation menu,
int panelColor,
int headingColor,
int selectedTextColor,
@@ -981,14 +1013,14 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
scale: 2,
);
final face = art.difficultyOption(
final face = menu.difficultyOption(
Difficulty.values[selectedDifficultyIndex],
);
if (face != null) {
_blitVgaImage(face, panelX + panelW - face.width - 10, panelY + 22);
}
final cursor = art.mappedPic(
final cursor = menu.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const int rowYStart = panelY + 16;
@@ -1052,19 +1084,6 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
_buffer.pixels[(y * width) + x] = color;
}
String _gameTitle(GameVersion version) {
switch (version) {
case GameVersion.shareware:
return 'SHAREWARE';
case GameVersion.retail:
return 'RETAIL';
case GameVersion.spearOfDestiny:
return 'SPEAR OF DESTINY';
case GameVersion.spearOfDestinyDemo:
return 'SOD DEMO';
}
}
void _applyMenuTransition(MenuManager menuManager, int coverColor) {
switch (menuManager.transitionEffect) {
case WolfTransitionEffect.none:
@@ -1215,10 +1234,24 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
final List<int>? cachedColumn = _mainMenuBandFirstColumn;
if (cachedColumn != null && cachedColumn.isNotEmpty) {
final int bandHeight = cachedColumn.length.clamp(0, 200);
int firstBlack = -1;
int lastBlack = -1;
for (int y = 0; y < bandHeight; y++) {
final int paletteIndex = cachedColumn[y];
final int fillIndex = paletteIndex == 0 ? 0 : paletteIndex;
_fillCanonicalRect(0, y, 320, 1, ColorPalette.vga32Bit[fillIndex]);
if (cachedColumn[y] == 0) {
firstBlack = firstBlack == -1 ? y : firstBlack;
lastBlack = y;
}
}
if (firstBlack != -1) {
for (int y = firstBlack; y <= lastBlack; y++) {
_fillCanonicalRect(
0,
y,
320,
1,
ColorPalette.vga32Bit[cachedColumn[y]],
);
}
}
return;
}
@@ -1266,22 +1299,52 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
}
}
void _drawMainMenuOptionsSideBars(VgaImage optionsLabel, int optionsX320) {
_mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
final List<int> firstColumn = _mainMenuBandFirstColumn!;
for (int y = 0; y < optionsLabel.height; y++) {
final int paletteIndex = firstColumn[y];
final int fillIndex = paletteIndex == 0 ? 0 : paletteIndex;
_fillCanonicalRect(0, y, 320, 1, ColorPalette.vga32Bit[fillIndex]);
void _drawTiledMenuBackdrop(VgaImage image, int fallbackColor) {
if (image.width <= 0 || image.height <= 0) {
for (int i = 0; i < _buffer.pixels.length; i++) {
_buffer.pixels[i] = fallbackColor;
}
return;
}
for (int y = 0; y < 200; y += image.height) {
for (int x = 0; x < 320; x += image.width) {
_blitVgaImage(image, x, y);
}
}
}
List<int> _cacheFirstColumn(VgaImage image) {
final List<int> column = List<int>.filled(image.height, 0);
for (int y = 0; y < image.height; y++) {
column[y] = image.decodePixel(0, y);
}
return column;
void _drawMainMenuOptionsSideBars(
VgaImage optionsLabel,
int optionsX320, {
required String debugContext,
}) {
_mainMenuBandFirstColumn = MenuHeaderBand.firstColumn(optionsLabel);
MenuHeaderBand.applyFromHeadingImage(
image: optionsLabel,
imageX320: optionsX320,
debugContext: debugContext,
fillSideEdgesRow:
(
int y200,
int leftWidth320,
int rightStartX320,
int paletteIndex,
) {
final int color = ColorPalette.vga32Bit[paletteIndex];
if (leftWidth320 > 0) {
_fillCanonicalRect(0, y200, leftWidth320, 1, color);
}
if (rightStartX320 < 320) {
_fillCanonicalRect(
rightStartX320,
y200,
320 - rightStartX320,
1,
color,
);
}
},
);
}
void _drawCanonicalMenuText(
@@ -0,0 +1,6 @@
library;
export 'src/engine/audio/debug_music_player.dart' show DebugMusicPlayer;
export 'src/engine/audio/native_subprocess_audio_stub.dart'
if (dart.library.io) 'src/engine/audio/native_subprocess_audio_io.dart'
show NativeSubprocessAudio;
@@ -26,12 +26,12 @@ export 'src/data_types/wolf_level.dart' show WolfLevel;
export 'src/data_types/wolfenstein_data.dart' show WolfensteinData;
// Registry public surface
export 'src/registry/asset_registry.dart' show AssetRegistry;
export 'src/registry/built_in/retail_asset_registry.dart'
show RetailAssetRegistry;
export 'src/registry/built_in/shareware_asset_registry.dart'
show SharewareAssetRegistry;
export 'src/registry/built_in/spear_demo_asset_registry.dart'
export 'src/registry/built_in/menu/spear/spear_demo_asset_registry.dart'
show SpearDemoAssetRegistry;
export 'src/registry/built_in/menu/wolf/retail_asset_registry.dart'
show RetailAssetRegistry;
export 'src/registry/built_in/menu/wolf/shareware_asset_registry.dart'
show SharewareAssetRegistry;
export 'src/registry/keys/entity_key.dart' show EntityKey;
export 'src/registry/keys/hud_key.dart' show HudKey;
export 'src/registry/keys/menu_pic_key.dart' show MenuPicKey;
@@ -42,6 +42,8 @@ export 'src/registry/modules/entity_asset_module.dart'
export 'src/registry/modules/hud_module.dart' show HudModule, HudAssetRef;
export 'src/registry/modules/menu_pic_module.dart'
show MenuPicModule, MenuPicRef;
export 'src/registry/modules/menu_presentation_module.dart'
show MenuPresentationModule;
export 'src/registry/modules/music_module.dart' show MusicModule, MusicRoute;
export 'src/registry/modules/sfx_module.dart' show SfxModule, SoundAssetRef;
export 'src/registry/registry_resolver.dart'
@@ -6,12 +6,18 @@
library;
export 'src/engine/audio/engine_audio.dart';
export 'src/engine/audio/silent_renderer.dart';
export 'src/engine/audio/silent_audio.dart';
export 'src/engine/input/engine_input.dart';
export 'src/engine/managers/door_manager.dart';
export 'src/engine/managers/pushwall_manager.dart';
export 'src/engine/player/player.dart';
export 'src/engine/player_locomotion_constants.dart';
export 'src/engine/rendering/default_renderer_settings_persistence.dart';
export 'src/engine/rendering/renderer_settings.dart';
export 'src/engine/rendering/renderer_settings_persistence.dart';
export 'src/engine/save/default_save_game_persistence.dart';
export 'src/engine/save/game_session_snapshot.dart';
export 'src/engine/save/save_game_codec.dart';
export 'src/engine/save/save_game_persistence.dart';
export 'src/engine/wolf_3d_engine.dart';
export 'src/engine/wolf_3d_engine_base.dart';
@@ -0,0 +1,8 @@
/// Shared host-facing helpers for Wolf3D app shells.
library;
export 'src/host/cli_game_loop.dart' show CliGameLoop;
export 'src/host/managers/game_persistence_manager.dart'
show GamePersistenceManager;
export 'src/host/managers/game_renderer_mode_manager.dart'
show GameRendererMode, gameRendererModeFromSettings, handleGlslUnavailable;
+2 -216
View File
@@ -1,219 +1,5 @@
/// Shared menu helpers for Wolf3D hosts.
library;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
/// Known VGA picture indexes used by the original Wolf3D control-panel menus.
///
/// Values below are picture-table indexes (not raw chunk ids).
/// For example, `C_CONTROLPIC` is chunk 26 in `GFXV_WL6.H`, so its picture
/// index is `26 - STARTPICS(3) = 23`.
abstract class WolfMenuPic {
static const int hBj = 0; // H_BJPIC
static const int hTopWindow = 3; // H_TOPWINDOWPIC
static const int cOptions = 7; // C_OPTIONSPIC
static const int cCursor1 = 8; // C_CURSOR1PIC
static const int cCursor2 = 9; // C_CURSOR2PIC
static const int cNotSelected = 10; // C_NOTSELECTEDPIC
static const int cSelected = 11; // C_SELECTEDPIC
static const int cBabyMode = 16; // C_BABYMODEPIC
static const int cEasy = 17; // C_EASYPIC
static const int cNormal = 18; // C_NORMALPIC
static const int cHard = 19; // C_HARDPIC
static const int cControl = 23; // C_CONTROLPIC
static const int cCustomize = 24; // C_CUSTOMIZEPIC
static const int cEpisode1 = 27; // C_EPISODE1PIC
static const int cEpisode2 = 28; // C_EPISODE2PIC
static const int cEpisode3 = 29; // C_EPISODE3PIC
static const int cEpisode4 = 30; // C_EPISODE4PIC
static const int cEpisode5 = 31; // C_EPISODE5PIC
static const int cEpisode6 = 32; // C_EPISODE6PIC
static const int statusBar = 83; // STATUSBARPIC
static const int title = 84; // TITLEPIC
static const int pg13 = 85; // PG13PIC
static const int credits = 86; // CREDITSPIC
static const int highScores = 87; // HIGHSCORESPIC
static const List<int> episodePics = [
cEpisode1,
cEpisode2,
cEpisode3,
cEpisode4,
cEpisode5,
cEpisode6,
];
}
/// Shared menu text colors resolved from the VGA palette.
///
/// Keep menu color choices centralized so renderers don't duplicate
/// hard-coded palette slots or RGB conversion logic.
abstract class WolfMenuPalette {
static const int selectedTextIndex = 19;
static const int unselectedTextIndex = 23;
static const int disabledTextIndex = 4;
static const int _headerTargetRgb = 0xFFF700;
static int? _cachedHeaderTextIndex;
static int get headerTextIndex =>
_cachedHeaderTextIndex ??= _nearestPaletteIndex(_headerTargetRgb);
static int get selectedTextColor => ColorPalette.vga32Bit[selectedTextIndex];
static int get unselectedTextColor =>
ColorPalette.vga32Bit[unselectedTextIndex];
static int get disabledTextColor => ColorPalette.vga32Bit[disabledTextIndex];
static int get headerTextColor => ColorPalette.vga32Bit[headerTextIndex];
static int _nearestPaletteIndex(int rgb) {
final int targetR = (rgb >> 16) & 0xFF;
final int targetG = (rgb >> 8) & 0xFF;
final int targetB = rgb & 0xFF;
int bestIndex = 0;
int bestDistance = 1 << 30;
for (int i = 0; i < 256; i++) {
final int color = ColorPalette.vga32Bit[i];
final int r = color & 0xFF;
final int g = (color >> 8) & 0xFF;
final int b = (color >> 16) & 0xFF;
final int dr = targetR - r;
final int dg = targetG - g;
final int db = targetB - b;
final int distance = (dr * dr) + (dg * dg) + (db * db);
if (distance < bestDistance) {
bestDistance = distance;
bestIndex = i;
}
}
return bestIndex;
}
}
/// Structured accessors for classic Wolf3D menu art.
class WolfClassicMenuArt {
final WolfensteinData data;
WolfClassicMenuArt(this.data);
VgaImage? get controlBackground {
return _imageForKey(MenuPicKey.controlBackground);
}
VgaImage? get title => _imageForKey(MenuPicKey.title);
VgaImage? get heading => _imageForKey(MenuPicKey.heading);
VgaImage? get selectedMarker => _imageForKey(MenuPicKey.markerSelected);
VgaImage? get unselectedMarker => _imageForKey(MenuPicKey.markerUnselected);
VgaImage? get optionsLabel => _imageForKey(MenuPicKey.optionsLabel);
VgaImage? get customizeLabel => _imageForKey(MenuPicKey.customizeLabel);
VgaImage? get credits => _imageForKey(MenuPicKey.credits);
VgaImage? episodeOption(int episodeIndex) {
if (episodeIndex < 0) {
return null;
}
final key = data.registry.menu.episodeKey(episodeIndex);
return _imageForKey(key);
}
VgaImage? difficultyOption(Difficulty difficulty) {
final key = data.registry.menu.difficultyKey(difficulty);
return _imageForKey(key);
}
/// Legacy numeric lookup API retained for existing renderer call sites.
///
/// Known legacy indices are mapped through symbolic registry keys first.
/// Unknown indices fall back to direct picture-table indexing.
VgaImage? mappedPic(int index) {
final key = _legacyKeyForIndex(index);
if (key != null) {
return _imageForKey(key);
}
return pic(index);
}
VgaImage? pic(int index) {
if (index < 0 || index >= data.vgaImages.length) {
return null;
}
final image = data.vgaImages[index];
if (image.width <= 0 || image.height <= 0) {
return null;
}
return image;
}
VgaImage? _imageForKey(MenuPicKey key) {
final ref = data.registry.menu.resolve(key);
if (ref == null) {
return null;
}
return pic(ref.pictureIndex);
}
MenuPicKey? _legacyKeyForIndex(int index) {
switch (index) {
case WolfMenuPic.hTopWindow:
return MenuPicKey.heading;
case WolfMenuPic.cOptions:
return MenuPicKey.optionsLabel;
case WolfMenuPic.cCursor1:
return MenuPicKey.cursorActive;
case WolfMenuPic.cCursor2:
return MenuPicKey.cursorInactive;
case WolfMenuPic.cNotSelected:
return MenuPicKey.markerUnselected;
case WolfMenuPic.cSelected:
return MenuPicKey.markerSelected;
case 15:
return MenuPicKey.footer;
case WolfMenuPic.cBabyMode:
return MenuPicKey.difficultyBaby;
case WolfMenuPic.cEasy:
return MenuPicKey.difficultyEasy;
case WolfMenuPic.cNormal:
return MenuPicKey.difficultyNormal;
case WolfMenuPic.cHard:
return MenuPicKey.difficultyHard;
case WolfMenuPic.cControl:
return MenuPicKey.controlBackground;
case WolfMenuPic.cCustomize:
return MenuPicKey.customizeLabel;
case WolfMenuPic.cEpisode1:
return MenuPicKey.episode1;
case WolfMenuPic.cEpisode2:
return MenuPicKey.episode2;
case WolfMenuPic.cEpisode3:
return MenuPicKey.episode3;
case WolfMenuPic.cEpisode4:
return MenuPicKey.episode4;
case WolfMenuPic.cEpisode5:
return MenuPicKey.episode5;
case WolfMenuPic.cEpisode6:
return MenuPicKey.episode6;
case WolfMenuPic.title:
return MenuPicKey.title;
case WolfMenuPic.pg13:
return MenuPicKey.pg13;
case WolfMenuPic.credits:
return MenuPicKey.credits;
default:
return null;
}
}
}
export 'src/menu/wolf_menu_pic.dart';
export 'src/menu/wolf_menu_presentation.dart';
@@ -6,5 +6,6 @@ export 'src/rendering/ascii_renderer.dart'
show AsciiRenderer, AsciiRendererMode, AsciiTheme, AsciiThemes, ColoredChar;
export 'src/rendering/cli_renderer_backend.dart';
export 'src/rendering/renderer_backend.dart';
export 'src/rendering/sixel_renderer.dart';
export 'src/rendering/sixel_renderer_stub.dart'
if (dart.library.io) 'src/rendering/sixel_renderer.dart';
export 'src/rendering/software_renderer.dart';
+2 -3
View File
@@ -1,7 +1,6 @@
/// Entry point for the sound synthesis module of Wolf3D.
///
/// This library provides access to audio functionalities, primarily by exporting
/// the [WolfAudio] class.
/// This library provides access to audio synthesis and WAV encoding helpers.
library;
export 'src/synth/wolf_3d_audio.dart' show WolfAudio;
export 'src/synth/imf_renderer.dart' show ImfRenderer;
-2
View File
@@ -8,8 +8,6 @@ environment:
dependencies:
arcane_helper_utils: ^1.4.7
audioplayers: ^6.6.0
crypto: ^3.0.7
dev_dependencies:
lints: ^6.0.0
@@ -0,0 +1,21 @@
import 'package:test/test.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
void main() {
group('DataVersion.fromChecksum', () {
test('resolves all known checksum constants', () {
for (final version in DataVersion.values.where(
(version) => version != DataVersion.unknown,
)) {
expect(DataVersion.fromChecksum(version.checksum), version);
}
});
test('returns unknown for unrecognized checksum', () {
expect(
DataVersion.fromChecksum('ffffffffffffffffffffffffffffffff'),
DataVersion.unknown,
);
});
});
}
@@ -0,0 +1,34 @@
import 'dart:convert';
import 'package:test/test.dart';
import 'package:wolf_3d_dart/src/data/md5_hash.dart';
void main() {
group('md5HexLower', () {
test('matches canonical RFC vectors', () {
expect(md5HexLower(const <int>[]), 'd41d8cd98f00b204e9800998ecf8427e');
expect(
md5HexLower(utf8.encode('abc')),
'900150983cd24fb0d6963f7d28e17f72',
);
expect(
md5HexLower(utf8.encode('The quick brown fox jumps over the lazy dog')),
'9e107d9d372bb6826bd81d3542a419d6',
);
expect(
md5HexLower(
utf8.encode(
'12345678901234567890123456789012345678901234567890123456789012345678901234567890',
),
),
'57edf4a22be3c955ac49da2e2107b67a',
);
});
test('returns lowercase 32-character hex output', () {
final digest = md5HexLower(utf8.encode('Wolf3D'));
expect(digest, hasLength(32));
expect(digest, matches(RegExp(r'^[0-9a-f]{32}$')));
});
});
}
@@ -0,0 +1,351 @@
import 'dart:typed_data';
import 'package:test/test.dart';
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_asset_registry.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_entities.dart';
import 'package:wolf_3d_dart/wolf_3d_input.dart';
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
void main() {
test(
'captureSaveState and restoreSaveState round-trip live session state',
() {
final engine = _buildEngine();
engine.init();
engine.player
..health = 47
..ammo = 33
..score = 1200
..lives = 5
..x = 10.5
..y = 11.5
..angle = 1.25
..hasGoldKey = true
..hasMachineGun = true
..weapons[WeaponType.machineGun] = MachineGun()
..currentWeapon = MachineGun();
engine.currentLevel[8][8] = 0;
engine.currentLevel[5][6] = 98;
final door = engine.doorManager.doors.values.first
..state = DoorState.opening
..offset = 0.42
..openTime = 1337;
final pushwall = engine.pushwallManager.pushwalls.values.first
..dirX = 1
..dirY = 0
..offset = 0.5
..tilesMoved = 1;
engine.pushwallManager.activePushwall = pushwall;
final guard =
EntityRegistry.spawn(
MapObject.guardStart,
12.5,
13.5,
Difficulty.medium,
engine.data.sprites.length,
registry: engine.data.registry,
)!
as Guard
..health = 17
..isAlerted = true
..state = EntityState.attacking
..currentFrame = 2
..lastActionTime = 222;
final droppedAmmo = SmallAmmoCollectible(x: 7.5, y: 9.5)
..spriteIndex = 999 % engine.data.sprites.length;
engine.entities = <Entity>[guard, droppedAmmo];
final snapshot = engine.captureSaveState();
engine.player
..health = 1
..ammo = 0
..score = 0
..x = 2.5
..y = 2.5
..hasGoldKey = false;
engine.currentLevel[8][8] = 55;
door
..state = DoorState.closed
..offset = 0.0
..openTime = 0;
engine.pushwallManager.activePushwall = null;
engine.entities = <Entity>[];
engine.restoreSaveState(snapshot);
expect(engine.currentGameIndex, 0);
expect(engine.currentEpisodeIndex, 0);
expect(engine.currentLevelIndex, 0);
expect(engine.player.health, 47);
expect(engine.player.ammo, 33);
expect(engine.player.score, 1200);
expect(engine.player.lives, 5);
expect(engine.player.x, closeTo(10.5, 0.001));
expect(engine.player.y, closeTo(11.5, 0.001));
expect(engine.player.angle, closeTo(1.25, 0.001));
expect(engine.player.hasGoldKey, isTrue);
expect(engine.player.hasMachineGun, isTrue);
expect(engine.player.currentWeapon.type, WeaponType.machineGun);
expect(engine.currentLevel[8][8], 0);
expect(engine.currentLevel[5][6], 98);
final restoredDoor = engine.doorManager.doors.values.first;
expect(restoredDoor.state, DoorState.opening);
expect(restoredDoor.offset, closeTo(0.42, 0.001));
expect(restoredDoor.openTime, 1337);
expect(engine.pushwallManager.activePushwall, isNotNull);
expect(
engine.pushwallManager.activePushwall!.offset,
closeTo(0.5, 0.001),
);
expect(engine.pushwallManager.activePushwall!.tilesMoved, 1);
expect(engine.pushwallManager.activePushwall!.dirX, 1);
expect(engine.entities, hasLength(2));
expect(engine.entities.first, isA<Guard>());
final restoredGuard = engine.entities.first as Guard;
expect(restoredGuard.health, 17);
expect(restoredGuard.isAlerted, isTrue);
expect(restoredGuard.state, EntityState.attacking);
expect(restoredGuard.currentFrame, 2);
expect(engine.entities.last, isA<SmallAmmoCollectible>());
},
);
test('restoreSaveState applies menu theme from restored active game', () {
final engine = _buildEngineWithTwoGames();
engine.init();
final GameSessionSnapshot snapshot = engine.captureSaveState();
final GameSessionSnapshot restoredSnapshot = GameSessionSnapshot(
currentGameIndex: 1,
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,
);
engine.restoreSaveState(restoredSnapshot);
final presentation = WolfMenuPresentation(engine.data);
final bool isSpear =
engine.data.version == GameVersion.spearOfDestiny ||
engine.data.version == GameVersion.spearOfDestinyDemo;
final int expectedBackground = isSpear
? _rgb24FromVgaIndex(
_resolvedMenuColorIndex(
presentation.backgroundIndex,
engine.data.version,
),
)
: _paletteMappedRgb24(0x890000);
final int expectedPanel = isSpear
? 0x000359
: _paletteMappedRgb24(0x590002);
expect(engine.currentGameIndex, 1);
expect(engine.menuBackgroundRgb, expectedBackground);
expect(engine.menuPanelRgb, expectedPanel);
expect(engine.menuManager.menuBackgroundRgb, expectedBackground);
});
}
int _rgb24FromVgaIndex(int paletteIndex) {
final int argb = ColorPalette.argbFromVgaIndex(paletteIndex);
final int r = (argb >> 16) & 0xFF;
final int g = (argb >> 8) & 0xFF;
final int b = argb & 0xFF;
return (r << 16) | (g << 8) | b;
}
int _resolvedMenuColorIndex(int paletteIndex, GameVersion version) {
final bool isSpear =
version == GameVersion.spearOfDestiny ||
version == GameVersion.spearOfDestinyDemo;
if (!isSpear && paletteIndex >= 0x20 && paletteIndex <= 0x2F) {
return paletteIndex + 0x70;
}
return paletteIndex;
}
int _paletteMappedRgb24(int rgb) {
final int index = _closestVgaIndexForRgb24(rgb);
return _rgb24FromVgaIndex(index);
}
int _closestVgaIndexForRgb24(int rgb24) {
final int targetR = (rgb24 >> 16) & 0xFF;
final int targetG = (rgb24 >> 8) & 0xFF;
final int targetB = rgb24 & 0xFF;
int bestIndex = 0;
int bestDistance = 0x7FFFFFFF;
for (int index = 0; index < 256; index++) {
final int argb = ColorPalette.argbFromVgaIndex(index);
final int r = (argb >> 16) & 0xFF;
final int g = (argb >> 8) & 0xFF;
final int b = argb & 0xFF;
final int dr = targetR - r;
final int dg = targetG - g;
final int db = targetB - b;
final int distance = (dr * dr) + (dg * dg) + (db * db);
if (distance < bestDistance) {
bestDistance = distance;
bestIndex = index;
}
}
return bestIndex;
}
class _TestInput extends Wolf3dInput {
@override
void update() {}
}
class _SilentAudio implements EngineAudio {
@override
WolfensteinData? activeGame;
@override
Future<void> debugSoundTest() async {}
@override
Future<void> init() async {}
@override
void dispose() {}
@override
void playLevelMusic(Music music) {}
@override
void playMenuMusic() {}
@override
void playSoundEffect(SoundEffect effect) {}
@override
void playSoundEffectId(int sfxId) {}
@override
void stopMusic() {}
@override
Future<void> stopAllAudio() async {}
}
WolfEngine _buildEngine() {
final data = _buildTestData(version: GameVersion.retail);
return WolfEngine(
data: data,
difficulty: Difficulty.medium,
startingEpisode: 0,
frameBuffer: FrameBuffer(64, 64),
input: _TestInput(),
onGameWon: () {},
engineAudio: _SilentAudio(),
);
}
WolfEngine _buildEngineWithTwoGames() {
final retail = _buildTestData(version: GameVersion.retail);
final spear = _buildTestData(version: GameVersion.spearOfDestiny);
return WolfEngine(
availableGames: <WolfensteinData>[retail, spear],
difficulty: Difficulty.medium,
startingEpisode: 0,
frameBuffer: FrameBuffer(64, 64),
input: _TestInput(),
onGameWon: () {},
engineAudio: _SilentAudio(),
);
}
WolfensteinData _buildTestData({required GameVersion version}) {
final wallGrid = _buildGrid();
final objectGrid = _buildGrid();
_fillBoundaries(wallGrid, 2);
objectGrid[2][2] = MapObject.playerEast;
objectGrid[4][4] = MapObject.pushwallTrigger;
wallGrid[2][3] = 90;
wallGrid[4][4] = 5;
return WolfensteinData(
version: version,
dataVersion: DataVersion.unknown,
registry: switch (version) {
GameVersion.spearOfDestiny => SpearAssetRegistry(),
_ => RetailAssetRegistry(),
},
walls: [
_solidSprite(1),
_solidSprite(1),
_solidSprite(2),
_solidSprite(2),
],
sprites: List.generate(436, (_) => _solidSprite(255)),
sounds: List.generate(200, (_) => PcmSound(Uint8List(1))),
adLibSounds: const [],
music: const [],
vgaImages: const [],
episodes: [
Episode(
name: 'Episode 1',
levels: [
WolfLevel(
name: 'Level 1',
wallGrid: wallGrid,
areaGrid: List.generate(64, (_) => List.filled(64, -1)),
objectGrid: objectGrid,
music: Music.level01,
),
],
),
],
);
}
SpriteMap _buildGrid() => List.generate(64, (_) => List.filled(64, 0));
void _fillBoundaries(SpriteMap grid, int wallId) {
for (int i = 0; i < 64; i++) {
grid[0][i] = wallId;
grid[63][i] = wallId;
grid[i][0] = wallId;
grid[i][63] = wallId;
}
}
Sprite _solidSprite(int paletteIndex) {
return Sprite(Uint8List.fromList(<int>[paletteIndex]));
}
@@ -242,7 +242,7 @@ void main() {
engine.menuManager.mainMenuEntries
.map((entry) => entry.isEnabled)
.toList(),
[true, false, false, false, false, true, false, true, true, true],
[true, false, false, false, true, true, false, true, true, true],
);
input.isMovingForward = true;
@@ -288,6 +288,10 @@ void main() {
manager.updateMainMenu(const EngineInput(isMovingBackward: true));
manager.updateMainMenu(const EngineInput());
expect(manager.selectedMainIndex, 0);
manager.updateMainMenu(const EngineInput(isMovingBackward: true));
manager.updateMainMenu(const EngineInput());
expect(manager.selectedMainIndex, 5);
});
test('menu transition defaults to normal fade and can opt into fizzle', () {
@@ -398,14 +402,138 @@ void main() {
expect(quitCalls, 0);
expect(exitCalls, 1);
});
test('load availability is scoped to active game version', () async {
final persistence = _InMemorySaveGamePersistence(
saves: {
_InMemorySaveGamePersistence.key(
slot: 0,
version: GameVersion.shareware,
): Uint8List.fromList(const <int>[
1,
]),
},
);
final retailEngine = WolfEngine(
data: _buildTestData(gameVersion: GameVersion.retail),
difficulty: null,
startingEpisode: 0,
frameBuffer: FrameBuffer(64, 64),
input: _TestInput(),
engineAudio: _SilentAudio(),
saveGamePersistence: persistence,
onGameWon: () {},
);
retailEngine.init();
await Future<void>.delayed(Duration.zero);
final sharewareEngine = WolfEngine(
data: _buildTestData(gameVersion: GameVersion.shareware),
difficulty: null,
startingEpisode: 0,
frameBuffer: FrameBuffer(64, 64),
input: _TestInput(),
engineAudio: _SilentAudio(),
saveGamePersistence: persistence,
onGameWon: () {},
);
sharewareEngine.init();
await Future<void>.delayed(Duration.zero);
expect(retailEngine.hasLoadableSave, isFalse);
expect(sharewareEngine.hasLoadableSave, isTrue);
});
test(
'loadFromSlot prefers save metadata over stale snapshot game index',
() async {
final input = _TestInput();
final persistence = _InMemorySaveGamePersistence();
final engine = _buildMultiGameEngine(
input: input,
difficulty: Difficulty.medium,
saveGamePersistence: persistence,
);
engine.init();
final GameSessionSnapshot retailSnapshot = engine.captureSaveState();
final GameSessionSnapshot staleIndexSnapshot =
_copySnapshotWithGameIndex(
retailSnapshot,
1,
);
final SaveGameFile mismatchedFile = SaveGameFile(
slot: 0,
gameVersion: GameVersion.retail,
dataVersionName: DataVersion.unknown.name,
description: 'Retail save with stale snapshot index',
createdAtMs: DateTime.now().millisecondsSinceEpoch,
snapshot: staleIndexSnapshot,
checksum: 0,
);
await persistence.save(
slot: 0,
version: GameVersion.retail,
bytes: engine.saveGameCodec.encode(mismatchedFile),
);
final bool loaded = await engine.loadFromSlot(0);
expect(loaded, isTrue);
expect(engine.data.version, GameVersion.retail);
expect(engine.currentGameIndex, 0);
final bool saved = await engine.saveToSlot(
1,
description: 'After load',
);
expect(saved, isTrue);
expect(
await persistence.exists(slot: 1, version: GameVersion.retail),
isTrue,
);
expect(
await persistence.exists(slot: 1, version: GameVersion.shareware),
isFalse,
);
},
);
});
}
GameSessionSnapshot _copySnapshotWithGameIndex(
GameSessionSnapshot snapshot,
int gameIndex,
) {
return GameSessionSnapshot(
currentGameIndex: gameIndex,
currentEpisodeIndex: snapshot.currentEpisodeIndex,
currentLevelIndex: snapshot.currentLevelIndex,
returnLevelIndex: snapshot.returnLevelIndex,
difficulty: snapshot.difficulty,
timeAliveMs: snapshot.timeAliveMs,
lastAcousticAlertTime: snapshot.lastAcousticAlertTime,
isMapOverlayVisible: snapshot.isMapOverlayVisible,
isMenuOverlayVisible: snapshot.isMenuOverlayVisible,
player: snapshot.player,
currentLevel: snapshot.currentLevel,
areaGrid: snapshot.areaGrid,
areasByPlayer: snapshot.areasByPlayer,
entities: snapshot.entities,
doors: snapshot.doors,
pushwalls: snapshot.pushwalls,
);
}
WolfEngine _buildMultiGameEngine({
required _TestInput input,
required Difficulty? difficulty,
void Function()? onMenuExit,
void Function()? onQuit,
SaveGamePersistence? saveGamePersistence,
}) {
final WolfensteinData retail = _buildTestData(
gameVersion: GameVersion.retail,
@@ -421,6 +549,7 @@ WolfEngine _buildMultiGameEngine({
frameBuffer: FrameBuffer(64, 64),
input: input,
engineAudio: _SilentAudio(),
saveGamePersistence: saveGamePersistence,
onGameWon: () {},
onMenuExit: onMenuExit,
onQuit: onQuit,
@@ -581,10 +710,43 @@ class _SilentAudio implements EngineAudio {
void dispose() {}
}
class _InMemorySaveGamePersistence implements SaveGamePersistence {
_InMemorySaveGamePersistence({Map<String, Uint8List>? saves})
: _saves = saves ?? <String, Uint8List>{};
final Map<String, Uint8List> _saves;
static String key({required int slot, required GameVersion version}) =>
'${version.name}:$slot';
@override
Future<Uint8List?> load({
required int slot,
required GameVersion version,
}) async {
return _saves[key(slot: slot, version: version)];
}
@override
Future<bool> exists({required int slot, required GameVersion version}) async {
final Uint8List? bytes = _saves[key(slot: slot, version: version)];
return bytes != null && bytes.isNotEmpty;
}
@override
Future<void> save({
required int slot,
required GameVersion version,
required Uint8List bytes,
}) async {
_saves[key(slot: slot, version: version)] = Uint8List.fromList(bytes);
}
}
void _dismissIntroSplash(WolfEngine engine, _TestInput input) {
int safety = 0;
while (engine.menuManager.activeMenu == WolfMenuScreen.introSplash &&
safety < 160) {
safety < 600) {
input.isInteracting = safety.isEven;
engine.tick(const Duration(milliseconds: 16));
safety++;
@@ -0,0 +1,393 @@
import 'dart:typed_data';
import 'package:test/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_entities.dart';
import 'package:wolf_3d_dart/wolf_3d_input.dart';
void main() {
test('SaveGameCodec round-trips a captured engine session snapshot', () {
final WolfEngine engine = _buildEngine();
engine.init();
engine.player
..health = 80
..ammo = 40
..score = 900
..lives = 6
..hasMachineGun = true
..weapons[WeaponType.machineGun] = MachineGun()
..currentWeapon = MachineGun();
final Guard guard =
EntityRegistry.spawn(
MapObject.guardStart,
8.5,
7.5,
Difficulty.medium,
engine.data.sprites.length,
registry: engine.data.registry,
)!
as Guard
..health = 11
..state = EntityState.attacking;
engine.entities = <Entity>[guard, SmallAmmoCollectible(x: 7.5, y: 6.5)];
final GameSessionSnapshot snapshot = engine.captureSaveState();
final SaveGameCodec codec = SaveGameCodec();
final SaveGameFile file = SaveGameFile(
slot: 0,
gameVersion: engine.data.version,
dataVersionName: engine.data.dataVersion.name,
description: 'Unit Test Save',
createdAtMs: 123456789,
snapshot: snapshot,
checksum: 0,
);
final Uint8List encoded = codec.encode(file);
final SaveGameFile decoded = codec.decode(encoded);
expect(decoded.slot, 0);
expect(decoded.gameVersion, engine.data.version);
expect(decoded.description, 'Unit Test Save');
expect(decoded.snapshot.player.health, 80);
expect(decoded.snapshot.player.currentWeaponType, WeaponType.machineGun);
expect(decoded.snapshot.entities, hasLength(2));
expect(decoded.snapshot.entities.first.kind, 'Guard');
expect(decoded.snapshot.entities.first.state, EntityState.attacking);
});
test('SaveGameCodec rejects payloads with invalid checksum', () {
final WolfEngine engine = _buildEngine();
engine.init();
final SaveGameCodec codec = SaveGameCodec();
final Uint8List encoded = codec.encode(
SaveGameFile(
slot: 0,
gameVersion: engine.data.version,
dataVersionName: engine.data.dataVersion.name,
description: 'Checksum Test',
createdAtMs: 42,
snapshot: engine.captureSaveState(),
checksum: 0,
),
);
encoded[encoded.length - 1] ^= 0xFF;
expect(
() => codec.decode(encoded),
throwsA(isA<FormatException>()),
);
});
test('OriginalLayoutEnvelopeSaveGameCodec round-trips save metadata', () {
final WolfEngine engine = _buildEngine();
engine.init();
final OriginalLayoutEnvelopeSaveGameCodec codec =
OriginalLayoutEnvelopeSaveGameCodec();
final SaveGameFile file = SaveGameFile(
slot: 3,
gameVersion: engine.data.version,
dataVersionName: engine.data.dataVersion.name,
description: 'Classic Envelope',
createdAtMs: 999,
snapshot: engine.captureSaveState(),
checksum: 0,
);
final Uint8List encoded = codec.encode(file);
final SaveGameFile decoded = codec.decode(encoded);
expect(decoded.slot, 3);
expect(decoded.description, 'Classic Envelope');
expect(decoded.createdAtMs, 999);
expect(decoded.gameVersion, engine.data.version);
expect(
decoded.snapshot.currentEpisodeIndex,
file.snapshot.currentEpisodeIndex,
);
expect(decoded.snapshot.currentLevelIndex, file.snapshot.currentLevelIndex);
});
test('OriginalLayoutEnvelopeSaveGameCodec rejects invalid checksum', () {
final WolfEngine engine = _buildEngine();
engine.init();
final OriginalLayoutEnvelopeSaveGameCodec codec =
OriginalLayoutEnvelopeSaveGameCodec();
final Uint8List encoded = codec.encode(
SaveGameFile(
slot: 0,
gameVersion: engine.data.version,
dataVersionName: engine.data.dataVersion.name,
description: 'Classic Checksum',
createdAtMs: 12,
snapshot: engine.captureSaveState(),
checksum: 0,
),
);
encoded[encoded.length - 1] ^= 0xFF;
expect(
() => codec.decode(encoded),
throwsA(isA<FormatException>()),
);
});
test('CompatibleSaveGameCodec decodes legacy W3DS saves', () {
final WolfEngine engine = _buildEngine();
engine.init();
final SaveGameCodec legacyCodec = SaveGameCodec();
final SaveGameFile file = SaveGameFile(
slot: 1,
gameVersion: engine.data.version,
dataVersionName: engine.data.dataVersion.name,
description: 'Legacy Save',
createdAtMs: 777,
snapshot: engine.captureSaveState(),
checksum: 0,
);
final Uint8List legacyBytes = legacyCodec.encode(file);
final CompatibleSaveGameCodec compatibleCodec = CompatibleSaveGameCodec();
final SaveGameFile decoded = compatibleCodec.decode(legacyBytes);
expect(decoded.slot, anyOf(0, 1));
expect(decoded.description, 'Legacy Save');
expect(decoded.createdAtMs, anyOf(0, 777));
});
test('CompatibleSaveGameCodec round-trips with block payload format', () {
final WolfEngine engine = _buildEngine();
engine.init();
final CompatibleSaveGameCodec codec = CompatibleSaveGameCodec();
final SaveGameFile file = SaveGameFile(
slot: 2,
gameVersion: engine.data.version,
dataVersionName: engine.data.dataVersion.name,
description: 'Compatible Block Save',
createdAtMs: 1234,
snapshot: engine.captureSaveState(),
checksum: 0,
);
final Uint8List encoded = codec.encode(file);
final SaveGameFile decoded = codec.decode(encoded);
expect(decoded.slot, 2);
expect(decoded.description, 'Compatible Block Save');
expect(decoded.createdAtMs, 1234);
expect(decoded.dataVersionName, file.dataVersionName);
expect(
decoded.snapshot.currentEpisodeIndex,
file.snapshot.currentEpisodeIndex,
);
expect(decoded.snapshot.currentLevelIndex, file.snapshot.currentLevelIndex);
});
test('CompatibleSaveGameCodec preserves entity state fidelity', () {
final WolfEngine engine = _buildEngine();
engine.init();
final Guard guard =
EntityRegistry.spawn(
MapObject.guardStart,
8.5,
7.5,
Difficulty.medium,
engine.data.sprites.length,
registry: engine.data.registry,
)!
as Guard
..health = 5
..state = EntityState.dead;
engine.entities = <Entity>[guard];
final CompatibleSaveGameCodec codec = CompatibleSaveGameCodec();
final SaveGameFile decoded = codec.decode(
codec.encode(
SaveGameFile(
slot: 0,
gameVersion: engine.data.version,
dataVersionName: engine.data.dataVersion.name,
description: 'Entity Fidelity',
createdAtMs: 1,
snapshot: engine.captureSaveState(),
checksum: 0,
),
),
);
expect(decoded.snapshot.entities, hasLength(1));
expect(decoded.snapshot.entities.first.kind, 'Guard');
expect(decoded.snapshot.entities.first.state, EntityState.dead);
expect(decoded.snapshot.entities.first.extraData['health'], 5);
});
test('CompatibleSaveGameCodec decodes old envelope payload format', () {
final WolfEngine engine = _buildEngine();
engine.init();
final OriginalLayoutEnvelopeSaveGameCodec oldEnvelopeCodec =
OriginalLayoutEnvelopeSaveGameCodec();
final SaveGameFile file = SaveGameFile(
slot: 4,
gameVersion: engine.data.version,
dataVersionName: engine.data.dataVersion.name,
description: 'Old Envelope Save',
createdAtMs: 333,
snapshot: engine.captureSaveState(),
checksum: 0,
);
final Uint8List oldEncoded = oldEnvelopeCodec.encode(file);
final CompatibleSaveGameCodec compatibleCodec = CompatibleSaveGameCodec();
final SaveGameFile decoded = compatibleCodec.decode(oldEncoded);
expect(decoded.slot, anyOf(0, 4));
expect(decoded.description, 'Old Envelope Save');
expect(decoded.createdAtMs, anyOf(0, 333));
});
test(
'CompatibleSaveGameCodec writes DOS-style description-prefixed files',
() {
final WolfEngine engine = _buildEngine();
engine.init();
final CompatibleSaveGameCodec codec = CompatibleSaveGameCodec();
final SaveGameFile file = SaveGameFile(
slot: 0,
gameVersion: engine.data.version,
dataVersionName: engine.data.dataVersion.name,
description: 'DOS Layout Save',
createdAtMs: 222,
snapshot: engine.captureSaveState(),
checksum: 0,
);
final Uint8List encoded = codec.encode(file);
expect(encoded.length, greaterThan(32));
expect(String.fromCharCodes(encoded.sublist(0, 3)), 'DOS');
expect(
encoded[32],
isNot(equals(0x57)),
); // not WLFS signature at offset 32
},
);
}
class _TestInput extends Wolf3dInput {
@override
void update() {}
}
class _SilentAudio implements EngineAudio {
@override
WolfensteinData? activeGame;
@override
Future<void> debugSoundTest() async {}
@override
void dispose() {}
@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() {}
}
WolfEngine _buildEngine() {
final SpriteMap wallGrid = _buildGrid();
final SpriteMap objectGrid = _buildGrid();
_fillBoundaries(wallGrid, 2);
objectGrid[2][2] = MapObject.playerEast;
wallGrid[2][3] = 90;
return WolfEngine(
data: WolfensteinData(
version: GameVersion.retail,
dataVersion: DataVersion.unknown,
registry: RetailAssetRegistry(),
walls: <Sprite>[
_solidSprite(1),
_solidSprite(1),
_solidSprite(2),
_solidSprite(2),
],
sprites: List<Sprite>.generate(436, (_) => _solidSprite(255)),
sounds: List<PcmSound>.generate(200, (_) => PcmSound(Uint8List(1))),
adLibSounds: const <PcmSound>[],
music: const <ImfMusic>[],
vgaImages: const <VgaImage>[],
episodes: <Episode>[
Episode(
name: 'Episode 1',
levels: <WolfLevel>[
WolfLevel(
name: 'Level 1',
wallGrid: wallGrid,
areaGrid: List<List<int>>.generate(
64,
(_) => List<int>.filled(64, -1),
growable: false,
),
objectGrid: objectGrid,
music: Music.level01,
),
],
),
],
),
difficulty: Difficulty.medium,
startingEpisode: 0,
frameBuffer: FrameBuffer(64, 64),
input: _TestInput(),
onGameWon: () {},
engineAudio: _SilentAudio(),
);
}
SpriteMap _buildGrid() =>
List<List<int>>.generate(64, (_) => List<int>.filled(64, 0));
void _fillBoundaries(SpriteMap grid, int wallId) {
for (int i = 0; i < 64; i++) {
grid[0][i] = wallId;
grid[63][i] = wallId;
grid[i][0] = wallId;
grid[i][63] = wallId;
}
}
Sprite _solidSprite(int paletteIndex) {
return Sprite(Uint8List.fromList(<int>[paletteIndex]));
}

Some files were not shown because too many files have changed in this diff Show More