Compare commits

...

76 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
hans a66ccf52c5 feat: Enhance weapon switching logic and add tests for animation pacing and menu behavior
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 12:35:31 +01:00
hans 827b8c779e feat: Add mutant death and god mode face animations, update HUD rendering and player damage handling
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 12:29:56 +01:00
hans 400ce4f680 feat: Implement chaingun pickup face animation and update HUD rendering logic
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 12:23:10 +01:00
hans 8ed460b03e feat: Add bonus flash effect for player pickups and update rendering logic
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 12:11:19 +01:00
hans 604923618a feat: Implement player locomotion constants and update movement logic in engine
feat: Add key icons to HUD modules and implement key rendering in HUD
test: Add player movement and rotation parity tests to ensure consistency with classic Wolf3D
test: Enhance HUD rendering tests for gold and silver key icons

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 12:04:25 +01:00
hans 7941c2902c refactor: Improve formatting and readability of secret-exit transition test
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 11:36:59 +01:00
hans b0f6e865b4 feat: Update HUD rendering to display current player lives dynamically
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 11:36:07 +01:00
hans 85583214ba feat: Update viewport calculations in renderers for consistent height handling and improve map rendering logic
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 11:29:27 +01:00
hans 35cfe8d88c feat: Conditionally display debug tools button based on debug mode and engine difficulty
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 11:24:29 +01:00
hans 0c74abcb7e feat: Enhance rendering with pushwall and enemy color support in ASCII, Sixel, and Software renderers
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 11:15:17 +01:00
hans 1165e0bc44 feat: Implement map overlay toggle functionality and rendering across input and rendering systems
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 11:08:43 +01:00
hans d63b316f1b feat: Implement fizzle fade transition effects for menus and intros, enhancing visual transitions
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 11:00:48 +01:00
hans a84c677845 feat: Add Spear of Destiny demo support with dedicated asset registry and entity definitions
- Introduced SpearDemoAssetRegistry for managing assets specific to the Spear of Destiny demo.
- Created SpearDemoEntityModule to define enemy animations with adjusted sprite ranges.
- Implemented SpearDemoHudModule and SpearDemoMenuPicModule for HUD and menu assets.
- Added SpearDemoSfxModule for sound effect mappings specific to the demo version.
- Updated enemy classes (Guard, Mutant, Officer, SS) to support custom animation sets.
- Modified entity registry to accept a custom AssetRegistry for spawning entities.
- Enhanced rendering with CRT phosphor bloom effect in GLSL shaders.
- Adjusted ASCII and software renderer layouts for improved UI spacing.
- Added tests for SpearDemoAssetRegistry to ensure correct asset resolution and enemy spawning.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 10:37:50 +01:00
hans 528d6276b1 Add Spear of Destiny shareware assets and update project files
- Added new asset files for Spear of Destiny shareware including AUDIOHED.SDM, AUDIOT.SDM, GAMEMAPS.SDM, MAPHEAD.SDM, VGADICT.SDM, VGAGRAPH.SDM, VGAHEAD.SDM, VSWAP.SDM, and documentation files (README.txt, SOD.DOC).
- Included necessary DLLs (SDL.dll, SDL_mixer.dll) for audio support.
- Updated pubspec.yaml to include the new assets directory for Spear of Destiny.
- Enhanced data versioning to recognize Spear of Destiny shareware in data_version.dart.
- Modified registry_resolver.dart to handle asset registry for Spear of Destiny shareware.
- Added license agreements for both id Software and MAME.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 10:00:27 +01:00
hans 3270338f44 feat: Implement Change View and Renderer Options menus
- Added functionality to display and navigate the Change View menu in SixelRenderer and SoftwareRenderer.
- Introduced methods to draw the Change View and Renderer Options menus, including handling cursor and selection states.
- Updated WolfClassicMenuArt to include a customize label for the new menu.
- Enhanced WolfMenuScreen to support new menu states.
- Created tests for Change View menu interactions, ensuring proper transitions and renderer settings toggling.
- Implemented persistence for renderer settings in Flutter, allowing settings to be saved and loaded from a local file.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-20 20:49:37 +01:00
hans 45e5302eac Automatically use sixel if it's available
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-20 18:19:19 +01:00
hans 2598218a4d feat: Improve Sixel rendering stability by adjusting output height and anchoring behavior
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-20 18:03:19 +01:00
hans 1e5222368a feat: Increase rendering resolution for Flutter and GLSL renderers to enhance visual clarity
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-20 17:45:54 +01:00
hans 5e19f3c098 feat: Enhance bezel rendering with edge color bleeding and improved glow effects
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-20 17:38:17 +01:00
hans 4bac9d519b refactor: Update imports and restructure key definitions for music and sound effects
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-20 17:34:42 +01:00
hans 10417d26ba Refactor audio module to use built-in music and sound effect identifiers
- Introduced BuiltInMusicModule and BuiltInSfxModule to replace RetailMusicModule and RetailSfxModule.
- Updated RetailAssetRegistry and SharewareAssetRegistry to utilize the new built-in modules.
- Removed deprecated MusicKey and SfxKey classes, replacing them with Music and SoundEffect enums for better clarity and maintainability.
- Adjusted music and sound effect resolution methods to align with the new structure.
- Updated audio playback methods in WolfAudio and FlutterAudioAdapter to accept the new Music and SoundEffect types.
- Refactored tests to accommodate changes in audio event handling and ensure compatibility with the new identifiers.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-20 17:07:25 +01:00
hans 8cca66e966 feat: Add Audio Gallery screen and integrate into Debug Tools menu
feat: Implement audio playback controls and audio management in the gallery
refactor: Update audio engine interface to include stopAllAudio method

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-20 16:15:46 +01:00
hans 03dd871a46 feat: Implement game selection in sprite and VGA galleries with GalleryGameSelector
refactor: Update VgaGallery and SpriteGallery to use selected game data
chore: Remove unused plugins from generated plugin registrant and CMake files
chore: Clean up pubspec.yaml by removing super_clipboard dependency

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-20 15:53:24 +01:00
hans ed1e480555 refactor: Simplify column rendering logic in AsciiRenderer and SixelRenderer
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-20 15:37:15 +01:00
hans 4d5b30f007 feat: Enhance menu rendering and input handling
- Added support for new plugins: IrondashEngineContext and SuperNativeExtensions in the Flutter plugin registrant.
- Updated CMake configuration to include new plugins.
- Introduced a new dependency, super_clipboard, in pubspec.yaml.
- Enhanced the WolfEngine to set the menu background color.
- Implemented keyboard shortcuts for renderer mode toggling and ASCII theme cycling in CLI input handling.
- Updated menu manager to include a universal menu background color.
- Refactored ASCII and Sixel renderers to utilize the new menu background color and improved header drawing logic.
- Simplified the drawing of menu options sidebars and header bars across different renderers.
- Improved the layout and centering of menu titles in the header bar.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-20 14:46:08 +01:00
hans 297f6f0260 refactor: Remove unnecessary blank lines in ASCII and Flutter renderer classes
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-20 12:21:51 +01:00
hans 27e15e60db refactor: Simplify scaffoldColor logic to use a constant black color
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-20 12:20:34 +01:00
hans 436f498778 feat: Enhance intro splash screen with retail warning and dynamic slide management
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-20 12:18:34 +01:00
hans b23c02f716 feat: Add intro splash screen with transition effects and rendering support
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-20 12:05:34 +01:00
hans 9733516693 refactor: Enhance bezel shading and depth effects for improved visual realism
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-20 11:43:08 +01:00
hans 862191d245 feat: Implement host shortcut system for desktop window management and input suppression
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-20 11:35:22 +01:00
hans c81eb6750d feat: Integrate window manager for desktop windowing support and enhance input handling
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-20 11:25:05 +01:00
hans 10eaef9690 refactor: Enhance TV bezel shading and texture effects for improved visual fidelity
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-20 11:05:39 +01:00
hans abca679a99 feat: Add GLSL effects toggle and enhance shader for CRT-like post-processing
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-20 11:03:33 +01:00
hans cbbcd3223a refactor: Update renderer mode enum and input key handling for improved clarity
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-20 10:54:10 +01:00
228 changed files with 21359 additions and 3291 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"
},
{
+5 -1
View File
@@ -1,3 +1,7 @@
{
"cmake.ignoreCMakeListsMissing": true
"cmake.ignoreCMakeListsMissing": true,
"chat.tools.terminal.autoApprove": {
"flutter": true,
"dart": true
}
}
+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)
+66 -14
View File
@@ -6,10 +6,12 @@ library;
import 'dart:io';
import 'package:wolf_3d_cli/cli_game_loop.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].
@@ -20,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;
final targetUri = scriptUri.resolve(
'../../../packages/wolf_3d_assets/assets/retail',
/// 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 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');
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(
@@ -60,15 +102,25 @@ 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 = DefaultRendererSettingsPersistence(
hostKey: rendererSettingsHostCli,
);
final WolfRendererSettings? saved = await persistence.load();
gameLoop = CliGameLoop(
engine: engine,
onExit: stopAndExit,
persistence: persistence,
initialSettings: saved,
);
await gameLoop.start();
+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;
}
+14 -67
View File
@@ -1,80 +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: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();
final Wolf3d wolf3d = await Wolf3d().init();
final seededGames = await loadPackagedGames();
final Wolf3dFlutterEngine wolf3d = await Wolf3dFlutterEngine(
debug: kDebugMode,
).init(seededGames: seededGames);
if (kDebugMode) {
wolf3d.enableMenuHeaderBandDebugLogging(prefix: '[wolf_3d_gui]');
}
runApp(
MaterialApp(
home: wolf3d.availableGames.isEmpty
? const _NoGameDataScreen()
: GameScreen(wolf3d: wolf3d),
home: Wolf3dGuiApp(engine: wolf3d),
),
);
}
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,127 +0,0 @@
/// Episode picker and asset-browser entry point for the selected game version.
library;
import 'package:flutter/material.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
import 'package:wolf_3d_gui/screens/game_screen.dart';
import 'package:wolf_3d_gui/screens/sprite_gallery.dart';
import 'package:wolf_3d_gui/screens/vga_gallery.dart';
/// Presents the episode list and shortcuts into the asset gallery screens.
class EpisodeScreen extends StatefulWidget {
/// Shared application facade whose active game must already be set.
final Wolf3d wolf3d;
/// Creates the episode-selection screen for [wolf3d].
const EpisodeScreen({super.key, required this.wolf3d});
@override
State<EpisodeScreen> createState() => _EpisodeScreenState();
}
class _EpisodeScreenState extends State<EpisodeScreen> {
@override
void initState() {
super.initState();
widget.wolf3d.audio.playMenuMusic();
}
/// Persists the chosen episode and lets the engine present difficulty select.
void _selectEpisode(int index) {
widget.wolf3d.setActiveEpisode(index);
widget.wolf3d.clearActiveDifficulty();
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => GameScreen(wolf3d: widget.wolf3d),
),
);
}
@override
Widget build(BuildContext context) {
final List<Episode> episodes = widget.wolf3d.activeGame.episodes;
return Scaffold(
backgroundColor: Colors.teal,
floatingActionButtonLocation:
FloatingActionButtonLocation.miniCenterFloat,
floatingActionButton: Row(
children: [
ElevatedButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) {
return VgaGallery(images: widget.wolf3d.vgaImages);
},
),
);
},
child: Text('VGA Gallery'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) {
return SpriteGallery(
wolf3d: widget.wolf3d,
);
},
),
);
},
child: Text('Sprites'),
),
],
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'WHICH EPISODE TO PLAY?',
style: TextStyle(
color: Colors.red,
fontSize: 32,
fontWeight: FontWeight.bold,
fontFamily: 'Courier',
),
),
const SizedBox(height: 40),
ListView.builder(
shrinkWrap: true,
itemCount: episodes.length,
itemBuilder: (context, index) {
final Episode episode = episodes[index];
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 32.0,
),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueGrey[900],
foregroundColor: Colors.white,
minimumSize: const Size(300, 60),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
onPressed: () => _selectEpisode(index),
child: Text(
episode.name,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 18),
),
),
);
},
),
],
),
),
);
}
}
@@ -1,215 +0,0 @@
/// Active gameplay screen for the Flutter host.
library;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_renderer.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.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,
ascii,
glsl,
}
/// 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;
/// Creates a gameplay screen driven by [wolf3d].
const GameScreen({
required this.wolf3d,
super.key,
});
@override
State<GameScreen> createState() => _GameScreenState();
}
class _GameScreenState extends State<GameScreen> {
late final WolfEngine _engine;
_RendererMode _rendererMode = _RendererMode.software;
AsciiTheme _asciiTheme = AsciiThemes.blocks;
@override
void initState() {
super.initState();
_engine = widget.wolf3d.launchEngine(
onGameWon: () {
_engine.difficulty = null;
widget.wolf3d.clearActiveDifficulty();
Navigator.of(context).pop();
},
onQuit: () {
SystemNavigator.pop();
},
);
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: _engine.difficulty != null,
onPopInvokedWithResult: (didPop, _) {
if (!didPop && _engine.difficulty == null) {
widget.wolf3d.input.queueBackAction();
}
},
child: Scaffold(
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),
),
),
Positioned(
top: 16,
right: 16,
child: Text(
'TAB: ${_modeLabel(_rendererMode)} T: ${_asciiTheme.name} `: FPS ${_engine.showFpsCounter ? 'On' : 'Off'}',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
),
),
),
],
),
);
},
),
),
);
}
Widget _buildRenderer() {
// Keep all renderers behind the same engine so mode switching does not
// reset level state or audio playback.
switch (_rendererMode) {
case _RendererMode.software:
return WolfFlutterRenderer(
engine: _engine,
onKeyEvent: _handleRendererKeyEvent,
);
case _RendererMode.ascii:
return WolfAsciiRenderer(
engine: _engine,
theme: _asciiTheme,
onKeyEvent: _handleRendererKeyEvent,
);
case _RendererMode.glsl:
return WolfGlslRenderer(
engine: _engine,
onKeyEvent: _handleRendererKeyEvent,
onUnavailable: _onGlslUnavailable,
);
}
}
void _handleRendererKeyEvent(KeyEvent event) {
if (event is! KeyDownEvent) {
return;
}
if (event.logicalKey == LogicalKeyboardKey.tab) {
setState(_cycleRendererMode);
return;
}
if (event.logicalKey == LogicalKeyboardKey.backquote ||
event.character == '`') {
setState(_toggleFpsCounter);
return;
}
if (event.logicalKey == LogicalKeyboardKey.keyT ||
event.character == 't' ||
event.character == 'T') {
setState(_cycleAsciiTheme);
}
}
void _cycleRendererMode() {
switch (_rendererMode) {
case _RendererMode.software:
_rendererMode = _RendererMode.ascii;
break;
case _RendererMode.ascii:
_rendererMode = _RendererMode.glsl;
break;
case _RendererMode.glsl:
_rendererMode = _RendererMode.software;
break;
}
}
void _onGlslUnavailable() {
if (!mounted || _rendererMode != _RendererMode.glsl) {
return;
}
setState(() {
_rendererMode = _RendererMode.software;
});
}
void _toggleFpsCounter() {
_engine.showFpsCounter = !_engine.showFpsCounter;
}
void _cycleAsciiTheme() {
_asciiTheme = AsciiThemes.nextOf(_asciiTheme);
}
String _modeLabel(_RendererMode mode) {
switch (mode) {
case _RendererMode.software:
return 'Software';
case _RendererMode.ascii:
return 'ASCII';
case _RendererMode.glsl:
return 'GLSL';
}
}
}
@@ -1,77 +0,0 @@
/// Visual browser for decoded sprite assets and their inferred gameplay roles.
library;
import 'package:flutter/material.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
import 'package:wolf_3d_renderer/wolf_3d_asset_painter.dart';
/// Displays every sprite frame in the active game along with enemy metadata.
class SpriteGallery extends StatelessWidget {
/// Shared application facade used to access the active game's sprite set.
final Wolf3d wolf3d;
/// Creates the sprite gallery for [wolf3d].
const SpriteGallery({super.key, required this.wolf3d});
bool get isShareware => wolf3d.activeGame.version == GameVersion.shareware;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Sprite Gallery"),
automaticallyImplyLeading: true,
),
backgroundColor: Colors.black,
body: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 8,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: wolf3d.sprites.length,
itemBuilder: (context, index) {
String label = "Sprite Index: $index";
for (final enemy in EnemyType.values) {
// The gallery infers likely ownership from sprite index ranges so
// debugging art packs does not require cross-referencing source.
if (enemy.claimsSpriteIndex(index, isShareware: isShareware)) {
final EnemyAnimation? animation = enemy.getAnimationFromSprite(
index,
isShareware: isShareware,
);
label += "\n${enemy.name}";
if (animation != null) {
label += "\n${animation.name}";
}
break;
}
}
return Card(
color: Colors.blueGrey,
child: Column(
spacing: 8,
children: [
Text(
label,
style: const TextStyle(color: Colors.white, fontSize: 10),
textAlign: TextAlign.center,
),
Expanded(
child: AspectRatio(
aspectRatio: 4 / 3,
child: WolfAssetPainter.sprite(wolf3d.sprites[index]),
),
),
],
),
);
},
),
);
}
}
@@ -1,52 +0,0 @@
/// Visual browser for decoded VGA pictures and UI art.
library;
import 'package:flutter/material.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_renderer/wolf_3d_asset_painter.dart';
/// Shows each VGA image extracted from the currently selected game data set.
class VgaGallery extends StatelessWidget {
/// Raw VGA images decoded from the active asset pack.
final List<VgaImage> images;
/// Creates the gallery for [images].
const VgaGallery({super.key, required this.images});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("VGA Image Gallery")),
backgroundColor: Colors.black,
body: GridView.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 150,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: images.length,
itemBuilder: (context, index) {
return Card(
color: Colors.blueGrey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 8,
children: [
Text(
"Index: $index\n${images[index].width} x ${images[index].height}",
style: const TextStyle(color: Colors.white, fontSize: 12),
textAlign: TextAlign.center,
),
Expanded(
child: Center(
child: WolfAssetPainter.vga(images[index]),
),
),
],
),
);
},
),
);
}
}
+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,9 +7,21 @@
#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>
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);
g_autoptr(FlPluginRegistrar) window_manager_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin");
window_manager_plugin_register_with_registrar(window_manager_registrar);
}
@@ -4,6 +4,9 @@
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux
file_selector_linux
screen_retriever_linux
window_manager
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
+5 -2
View File
@@ -9,12 +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
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)
@@ -0,0 +1,157 @@
Wolf4SDL v1.7 (released 2011-05-15, revision 256)
- Added support for Mac OS X
(thanks to Chris Ballinger)
- Added support for .sd1 SOD game files as delivered by Steam
by changing --mission parameter slightly
(thanks to Pickle)
- Added --windowed-mouse parameter to start windowed mode with grabbed mouse
(thanks to Jared Breland)
- Rain and snow speed fix (thanks to Tricob)
- Floor/ceiling fix (thanks to Tricob / Adam Biser)
- Fixed moon out of screen bug (thanks to Tricob)
- Rain/snow leaking ceilings fix (thanks to Adam Biser / Tricob)
- Per-user configuration/savegame directories (~/.wolf4sdl) on
Linux like systems per default (thanks to Jared Breland)
- Added --configdir parameter
- Use SDL_DOUBLEBUF for vsync to avoid or at least reduce flickering
(thanks to Greg Ayrton for the hint, use --nodblbuf to disable it)
- Removed remainings of the frame rate counter on screen, when disabled
- Don't quit game when using TAB+E with --tedlevel
- Added --extravbls parameter
- Changed default for "extra VBLs" from 1 to 0
- Fixed missing umask parameter for open with O_CREAT
(bug reported by Daniel Fass)
- Fixed support for 1.0 shareware data files
(bug reported by Marcus Naylor)
- Fixed xtile and ytile not being valid in HitHorizWall and HitVertWall,
respectively. This caused problems with some tutorials.
- Removed unused HitHorizPWall and HitVertPWall.
Wolf4SDL v1.6 (released 2008-09-01, revision 233)
- Fixed songs not really supporting more than 64kb
- Try to recognize whether data files don't fit to program version
instead of just using them and probably crash
(can be disabled with --ignorenumchunks)
- Fizzle fade now works for resolutions up to 8191x4095
(thanks to Xilinx, Inc. for the list of maximum-length LFSR counters)
- Fixed demos being dependent on actual duration of GETGATLINGSND
(fixes second demo, which even rarely worked in vanilla Wolf3D)
- Fixed demos by disabling some bugfixes during recording and playback
(see PLAYDEMOLIKEORIGINAL define in version.h)
- Removed system menu on Windows in windowed mode to avoid ALT to open it
- Fixed palette issues occurring on some Windows systems by using the
"best" color depth reported by libSDL per default (also see --bits option)
- Fixed directional 3d sprites on architectures only allowing aligned memory
access (bug reported by Pickle)
- Fixed remaining status bar after end of demo in 320x240s resolutions
(bug reported by Pickle)
- Removed last busy waiting (fixes very unstable framerates on machines with
stricter schedulers like FreeBSD; thanks to Tron for making me notice)
- Fixed compiling of SOD on case sensitive file systems
(thanks to Michael)
Wolf4SDL v1.5 (released 2008-05-25, revision 215)
- Reduced minimum distance to back of moving pushwall to PLAYERSIZE
- Fixed pushwall rendering when player's eye is in the pushwall back tile
(bug reported by Pickle)
- Enable 'End game' menu item also when using --tedlevel
- Removed some unneccessary fade outs
(DrawPlayScreen does not call VW_FadeOut anymore!!)
- When using 'End game', 'View scores' does not directly show up anymore
- Fixed quickload/quicksave not working when started with --tedlevel (vanilla
bug). This now also only checks for save games once at startup (may speed
up entering the menu on Dreamcast)
- Fixed drawing moving pushwalls viewed at acute angles near the level border
- Fixed vanilla bug hiding bonus items on same tile as player, when he cannot
pick them up (e.g. medikit at 100% health) (thanks to Pickle for noticing)
- Added GP2X specific code by Pickle
- Reimplemented picture grabber to support all screen resolutions
(<TAB>+P in debug mode)
- Added --resf option to force to use unsupported resolutions >= 320x200
- Added support for resolutions being a multiple of 320x240
(thanks for your help, Pickle!)
- Fixed crash when cheat-hurting oneself to death (bug reported by Tricob)
- Cleaned up id_sd.cpp (kept PC speaker stuff for future reference)
- Added move buttons (invalidates config file, only hardcoded yet)
- Added joystick support to US_LineInput used for highscore names
and save games
- Added US_Printf and US_CPrintf (works just like printf)
- Fixed wrong surface locks/unlocks
- Added Visual C++ 6 support
- Removed some useless VW_WaitVBLs (Thanks to TexZK)
- Added some asserts in id_vl.cpp to check for screen access out of bounds
- Fixed BJ face popping up in fullsize mode sometimes
(Thanks to Andy_Nonymous)
- Rewrote page manager to support page >= 64kB
and to correctly handle sounds >= 4kB
- Improved SOD mission packs support (Thanks to fackue)
- Updated Code::Blocks search paths to ..\SDL-devel\
- Added version.h to Dev-C++ and Code::Blocks project file
- Fixed some files being read in text mode on MinGW
Wolf4SDL v1.4 (released 2008-03-10, revision 164)
- Added MinGW/MSYS compatibility
- Updated Code::Blocks project
- Updated Dev-C++ project and added a README-devcpp.txt
- Fixed some busy waiting situations
- Added directional 3D sprites support (USE_DIR3DSPR)
- Added support for Spear mission packs (by fackue)
- Added support for Wolf3D full v1.1 and shareware v1.0, v1.1 and v1.2
- Added shading support (USE_SHADING)
- Added object flags (see objflag_t enum in wl_def.h)
- Reintroduced version.h
- Increased MAXVISABLE from 50 to 250
- Added outside atmosphere features (USE_STARSKY, USE_RAIN, USE_SNOW)
- Added cloud sky support (USE_CLOUDSKY)
- Added support for SoD demo
- Fixed SoD on systems with case sensitive filenames
- Added DarkOne's/Adam's multi-textured floors/ceiling (USE_FLOORCEILINGTEX)
- Added parallax sky support (USE_PARALLAX define)
- Introduced level feature flags (USE_FEATUREFLAGS define)
- Added high resolution support (USE_HIRES define)
- Added support for music > 64 kB as supported by WDC
- Added --samplerate and --audiobuffer parameters
- Added support for GP2X (ARM processor, thanks to Pickle)
- Added support for Dreamcast (SH-4 processor, thanks to fackue)
- Added joystick support (up to 32 buttons)
Wolf4SDL v1.3 (released 2008-01-20, revision 113)
- Added parameter for SOD to disable copy protection quiz
- F12 now also grabs the mouse (for keyboards without scrolllock)
- Fixed out of bounds array access in key processing
Wolf4SDL v1.2 (released 2008-01-09, revision 108)
- Fixed fading for 'End Game'
- Corrected fading speed
- Added Spear of Destiny compile support
- Reimplemented palette file (Sorry...)
- Fixed end game crash, when player did not die yet
(Thanks to Agent87 for noticing this bug!)
- Added full size screen feature
- Added project files for Code::Blocks and Dev-C++
(Thanks to Codetech84!)
- Made it MinGW compatible
- Fixed demo fading issues
- Reformatted many source code files
- Resolved all warnings reported by VC++ 8 and GCC
- Fixed crash when starting the game with no sound >effects<
(Thanks to Agent87 for noticing this bug!)
- Always grab mouse when started in fullscreen
- Map left and right alt, shift and ctrl keys to the same keys
- Fix numpad keys with numlock off
- Fixed a buffer overflow causing a crash
Wolf4SDL v1.1 (released 2007-12-28, revision 70)
- Fixed Pause
- Fixed IN_Ack()
- Added command line parameters for windowed mode and screen resolution
- Reimplemented command line parameters (try --help)
- Improved scaled "Get Psyched" progress bar graphic
- Improved scaled screen borders
- Fixed "Fade in black screen" bug
- Avoid asserts when shutting down with an error
- Use software surfaces to reduce problems with palette on Windows
- Windows: Statically links to MSVCR80.DLL now to avoid missing files
Wolf4SDL v1.0 (released 2007-12-26, revision 53)
- Initial release
@@ -0,0 +1,224 @@
Wolf4SDL by Moritz "Ripper" Kroll (http://www.chaos-software.de.vu)
Original Wolfenstein 3D by id Software (http://www.idsoftware.com)
=============================================================================
Wolf4SDL is an open-source port of id Software's classic first-person shooter
Wolfenstein 3D to the cross-plattform multimedia library "Simple DirectMedia
Layer (SDL)" (http://www.libsdl.org). It is meant to keep the original feel
while taking advantage of some improvements mentioned in the list below.
Main features:
--------------
- Cross-plattform:
Supported operating systems are at least:
- Windows 98, Windows ME, Windows 2000, Windows XP, Windows Vista
(32 and 64 bit), Windows 7 (32 and 64 bit)
- Linux
- BSD variants
- Mac OS X (x86)
- KallistiOS (used for Dreamcast)
Only little endian platforms like x86, ARM and SH-4 are supported, yet.
- AdLib sounds and music:
This port includes the OPL2 emulator from MAME, so you can not only
hear the AdLib sounds but also music without any AdLib-compatible
soundcard in near to perfect quality!
- Multichannel digitized sounds:
Digitized sounds play on 8 channels! So in a fire fight you will
always hear, when a guard opens the door behind you ;)
- Higher screen resolutions:
Aside from the original 320x200 resolution, Wolf4SDL currently
supports any resolutions being multiples of 320x200 or 320x240,
the default being 640x400.
Unlike some other ports, Wolf4SDL does NOT apply any bilinear
or similar filtering, so the graphics are NOT blurred but
pixelated just as we love it.
- Fully playable with only a game controller:
Wolf4SDL can be played completely without a keyboard. At least two
buttons are required (shoot/YES and open door/NO), but five or more
are recommended (run, strafe, ESC).
Additional features:
--------------------
- Two additional view sizes:
Wolf4SDL supports one view size using the full width of the screen
and showing the status bar, like in Mac-enstein, and one view size
filling the whole screen (press TAB to see the status bar).
- (Nearly) unlimited sound and song lengths:
Mod developers are not restricted to 64kB for digitized sounds and
IMF songs anymore, so longer songs and digitized sounds with better
quality are possible.
- Resuming ingame music:
When you come back to the game from the menu or load a save game, the
music will be resumed where it was suspended rather than started from
the beginning.
- Freely movable pushwalls:
Moving pushwalls can be viewed from all sides, allowing mod developers
to place them with fewer restrictions. The player can also follow the
pushwall directly instead of having to wait until the pushwall has left
a whole tile.
- Optional integrated features for mod developers:
Wolf4SDL already contains the shading, directional 3D sprites,
floor and ceiling textures, high resolution textures/sprites,
parallax sky, cloud sky and outside atmosphere features, which
can be easily activated in version.h.
The following versions of Wolfenstein 3D data files are currently supported
by the source code (choose the version by commenting/uncommenting lines in
version.h as described in that file):
- Wolfenstein 3D v1.1 full Apogee
- Wolfenstein 3D v1.4 full Apogee (not tested)
- Wolfenstein 3D v1.4 full GT/ID/Activision
- Wolfenstein 3D v1.0 shareware Apogee
- Wolfenstein 3D v1.1 shareware Apogee
- Wolfenstein 3D v1.2 shareware Apogee
- Wolfenstein 3D v1.4 shareware
- Spear of Destiny full
- Spear of Destiny demo
- Spear of Destiny - Mission 2: Return to Danger (not tested)
- Spear of Destiny - Mission 3: Ultimate Challenge (not tested)
How to play:
------------
To play Wolfenstein 3D with Wolf4SDL, you just have to copy the original data
files (e.g. *.WL6) into the same directory as the Wolf4SDL executable.
Please make sure, that you use the correct version of the executable with the
according data files version as the differences are hardcoded into the binary!
On Windows SDL.dll and SDL_mixer.dll must also be copied into this directory.
They are also available at http://www.chaos-software.de.vu
If you play in windowed mode (--windowed parameter), press SCROLLLOCK or F12
to grab the mouse. Press it again to release the mouse.
Usage:
------
Wolf4SDL supports the following command line options:
--help This help page
--tedlevel <level> Starts the game in the given level
--baby Sets the difficulty to baby for tedlevel
--easy Sets the difficulty to easy for tedlevel
--normal Sets the difficulty to normal for tedlevel
--hard Sets the difficulty to hard for tedlevel
--nowait Skips intro screens
--windowed[-mouse] Starts the game in a window [and grabs mouse]
--res <width> <height> Sets the screen resolution
(must be multiple of 320x200 or 320x240)
--resf <w> <h> Sets any screen resolution >= 320x200
(which may result in graphic errors)
--bits <b> Sets the screen color depth
(Use this when you have palette/fading problem
or perhaps to optimize speed on old systems.
Allowed: 8, 16, 24, 32, default: "best" depth)
--nodblbuf Don't use SDL's double buffering
--extravbls <vbls> Sets a delay after each frame, which may help to
reduce flickering (SDL does not support vsync...)
(unit is currently 8 ms, default: 0)
--joystick <index> Use the index-th joystick if available
--joystickhat <index> Enables movement with the given coolie hat
--samplerate <rate> Sets the sound sample rate (given in Hz)
--audiobuffer <size> Sets the size of the audio buffer (-> sound latency)
(given in bytes)
--ignorenumchunks Ignores the number of chunks in VGAHEAD.*
(may be useful for some broken mods)
--configdir <dir> Directory where config file and save games are stored
(Windows default: current directory,
others: $HOME/.wolf4sdl)
For Spear of Destiny the following additional options are available:
--mission <mission> Mission number to play (1-3)
--goodtimes Disable copy protection quiz
Compiling from source code:
---------------------------
The current version of the source code is available in the svn repository at:
svn://tron.homeunix.org:3690/wolf3d/trunk
The following ways of compiling the source code are supported:
- Makefile (for Linux, BSD variants and MinGW/MSYS)
- Visual C++ 2008 (Wolf4SDL.VC9.sln and Wolf4SDL.VC9.vcproj)
- Visual C++ 2005 (Wolf4SDL.sln and Wolf4SDL.vcproj)
- Visual C++ 6 (Wolf4SDL.dsw and Wolf4SDL.dsp)
- Code::Blocks 8.02 (Wolf4SDL.cbp)
- Dev-C++ 5.0 Beta 9.2 (4.9.9.2) (Wolf4SDL.dev) (see README-devcpp.txt)
- Xcode (for Mac OS X, macosx/Wolf4SDL.xcodeproj/project.pbxproj)
- Special compiling for Dreamcast (see README-dc.txt)
- Special compiling for GP2X (see README-GP2X.txt)
To compile the source code you need the development libraries of
- SDL (http://www.libsdl.org/download-1.2.php) and
- SDL_mixer (http://www.libsdl.org/projects/SDL_mixer/)
and have to adjust the include and library paths in the projects accordingly.
Please note, that there is no official SDL_mixer development pack for MinGW,
yet, but you can get the needed files from a Dev-C++ package here:
http://sourceforge.net/project/showfiles.php?group_id=94270&package_id=151751
Just rename the file extension from ".devpack" to ".tar.bz2" and unpack it
with for example WinRAR. Then add the directories include/SDL and lib to the
according search paths in your project.
IMPORTANT: Do not forget to take care of version.h!
By default it compiles for "Wolfenstein 3D v1.4 full GT/ID/Activision"!
TODOs:
------
- Add PC speaker emulation
- Center non-ingame screens for resolutions being a multiple of 320x240
- Add support for any graphic resolution >= 320x200
Known bugs:
-----------
- None! ;D
Troubleshooting:
----------------
- If your frame rate is low, consider using the original screen resolution
(--res 320 200) or lowering the sound quality (--samplerate 22050)
Credits:
--------
- Special thanks to id Software! Without the source code we would still have
to pelt Wolfenstein 3D with hex editors and disassemblers ;D
- Special thanks to the MAME developer team for providing the source code
of the OPL2 emulator!
- Many thanks to "Der Tron" for hosting the svn repository, making Wolf4SDL
FreeBSD compatible, testing, bugfixing and cleaning up the code!
- Thanks to Chris for his improvements on Wolf4GW, on which Wolf4SDL bases.
- Thanks to Pickle for the GP2X support and help on 320x240 support
- Thanks to fackue for the Dreamcast support
- Thanks to Chris Ballinger for the Mac OS X support
- Thanks to Xilinx, Inc. for providing a list of maximum-length LFSR counters
used for higher resolutions of fizzle fade
Licenses:
---------
- The original source code of Wolfenstein 3D: license-id.txt
- The OPL2 emulator (fmopl.cpp): license-mame.txt
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,129 @@
LIMITED USE SOFTWARE LICENSE AGREEMENT
This Limited Use Software License Agreement (the "Agreement")
is a legal agreement between you, the end-user, and Id Software, Inc.
("ID"). By continuing the downloading of this Wolfenstein 3D
(the "Trademark") software material, which includes source code
(the "Source Code"), artwork data, music and software tools
(collectively, the "Software"), you are agreeing to be bound by the
terms of this Agreement. If you do not agree to the terms of this
Agreement, promptly destroy the Software you may have downloaded.
ID SOFTWARE LICENSE
Grant of License. ID grants to you the right to use one (1)
copy of the Software on a single computer. You have no ownership or
proprietary rights in or to the Software, or the Trademark. For purposes
of this section, "use" means loading the Software into RAM, as well as
installation on a hard disk or other storage device. The Software,
together with any archive copy thereof, shall be destroyed when no longer
used in accordance with this Agreement, or when the right to use the
Software is terminated. You agree that the Software will not be shipped,
transferred or exported into any country in violation of the U.S.
Export Administration Act (or any other law governing such matters) and
that you will not utilize, in any other manner, the Software in violation
of any applicable law.
Permitted Uses. For educational purposes only, you, the end-user,
may use portions of the Source Code, such as particular routines, to
develop your own software, but may not duplicate the Source Code, except
as noted in paragraph 4. The limited right referenced in the preceding
sentence is hereinafter referred to as "Educational Use." By so exercising
the Educational Use right you shall not obtain any ownership, copyright,
proprietary or other interest in or to the Source Code, or any portion of
the Source Code. You may dispose of your own software in your sole
discretion. With the exception of the Educational Use right, you may not
otherwise use the Software, or an portion of the Software, which includes
the Source Code, for commercial gain.
Prohibited Uses: Under no circumstances shall you, the end-user,
be permitted, allowed or authorized to commercially exploit the Software.
Neither you nor anyone at your direction shall do any of the following acts
with regard to the Software, or any portion thereof:
Rent;
Sell;
Lease;
Offer on a pay-per-play basis;
Distribute for money or any other consideration; or
In any other manner and through any medium whatsoever commercially
exploit or use for any commercial purpose.
Notwithstanding the foregoing prohibitions, you may commercially exploit the
software you develop by exercising the Educational Use right, referenced in
paragraph 2. hereinabove.
Copyright. The Software and all copyrights related thereto
(including all characters and other images generated by the Software
or depicted in the Software) are owned by ID and is protected by
United States copyright laws and international treaty provisions.
Id shall retain exclusive ownership and copyright in and to the Software
and all portions of the Software and you shall have no ownership or other
proprietary interest in such materials. You must treat the Software like
any other copyrighted material, except that you may either (a) make one
copy of the Software solely for back-up or archival purposes, or (b)
transfer the Software to a single hard disk provided you keep the original
solely for back-up or archival purposes. You may not otherwise reproduce,
copy or disclose to others, in whole or in any part, the Software. You
may not copy the written materials accompanying the Software. You agree
to use your best efforts to see that any user of the Software licensed
hereunder complies with this Agreement.
NO WARRANTIES. ID DISCLAIMS ALL WARRANTIES, BOTH EXPRESS IMPLIED,
INCLUDING BUT NOT LIMITED TO, IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS FOR A PARTICULAR PURPOSE WITH RESPECT TO THE SOFTWARE. THIS LIMITED
WARRANTY GIVES YOU SPECIFIC LEGAL RIGHTS. YOU MAY HAVE OTHER RIGHTS WHICH
VARY FROM JURISDICTION TO JURISDICTION. ID DOES NOT WARRANT THAT THE
OPERATION OF THE SOFTWARE WILL BE UNINTERRUPTED, ERROR FREE OR MEET YOUR
SPECIFIC REQUIREMENTS. THE WARRANTY SET FORTH ABOVE IS IN LIEU OF ALL OTHER
EXPRESS WARRANTIES WHETHER ORAL OR WRITTEN. THE AGENTS, EMPLOYEES,
DISTRIBUTORS, AND DEALERS OF ID ARE NOT AUTHORIZED TO MAKE MODIFICATIONS TO
THIS WARRANTY, OR ADDITIONAL WARRANTIES ON BEHALF OF ID.
Exclusive Remedies. The Software is being offered to you free of any
charge. You agree that you have no remedy against ID, its affiliates,
contractors, suppliers, and agents for loss or damage caused by any defect
or failure in the Software regardless of the form of action, whether in
contract, tort, includinegligence, strict liability or otherwise, with
regard to the Software. This Agreement shall be construed in accordance
with and governed by the laws of the State of Texas. Copyright and other
proprietary matters will be governed by United States laws and international
treaties. IN ANY CASE, ID SHALL NOT BE LIABLE FOR LOSS OF DATA, LOSS OF
PROFITS, LOST SAVINGS, SPECIAL, INCIDENTAL, CONSEQUENTIAL, INDIRECT OR OTHER
SIMILAR DAMAGES ARISING FROM BREACH OF WARRANTY, BREACH OF CONTRACT,
NEGLIGENCE, OR OTHER LEGAL THEORY EVEN IF ID OR ITS AGENT HAS BEEN ADVISED
OF THE POSSIBILITY OF SUCH DAMAGES, OR FOR ANY CLAIM BY ANY OTHER PARTY.
Some jurisdictions do not allow the exclusion or limitation of incidental or
consequential damages, so the above limitation or exclusion may not apply to
you.
General Provisions. Neither this Agreement nor any part or portion
hereof shall be assigned, sublicensed or otherwise transferred by you.
Should any provision of this Agreement be held to be void, invalid,
unenforceable or illegal by a court, the validity and enforceability of the
other provisions shall not be affected thereby. If any provision is
determined to be unenforceable, you agree to a modification of such
provision to provide for enforcement of the provision's intent, to the
extent permitted by applicable law. Failure of a party to enforce any
provision of this Agreement shall not constitute or be construed as a
waiver of such provision or of the right to enforce such provision. If
you fail to comply with any terms of this Agreement, YOUR LICENSE IS
AUTOMATICALLY TERMINATED and you agree to the issuance of an injunction
against you in favor of Id. You agree that Id shall not have to post
bond or other security to obtain an injunction against you to prohibit
you from violating Id's rights.
YOU ACKNOWLEDGE THAT YOU HAVE READ THIS AGREEMENT, THAT YOU
UNDERSTAND THIS AGREEMENT, AND UNDERSTAND THAT BY CONTINUING THE
DOWNLOADING OF THE SOFTWARE, YOU AGREE TO BE BOUND BY THIS AGREEMENT'S
TERMS AND CONDITIONS. YOU FURTHER AGREE THAT, EXCEPT FOR WRITTEN SEPARATE
AGREEMENTS BETWEEN ID AND YOU, THIS AGREEMENT IS A COMPLETE AND EXCLUSIVE
STATEMENT OF THE RIGHTS AND LIABILITIES OF THE PARTIES. THIS AGREEMENT
SUPERSEDES ALL PRIOR ORAL AGREEMENTS, PROPOSALS OR UNDERSTANDINGS, AND
ANY OTHER COMMUNICATIONS BETWEEN ID AND YOU RELATING TO THE SUBJECT MATTER
OF THIS AGREEMENT
@@ -0,0 +1,32 @@
Copyright (c) 1997-2005, Nicola Salmoria and the MAME team
All rights reserved.
Redistribution and use of this code or any derivative works are permitted
provided that the following conditions are met:
* Redistributions may not be sold, nor may they be used in a commercial
product or activity.
* Redistributions that are modified from the original source must include the
complete source code, including the source code for all components used by a
binary built from the modified sources. However, as a special exception, the
source code distributed need not include anything that is normally distributed
(in either source or binary form) with the major components (compiler, kernel,
and so on) of the operating system on which the executable runs, unless that
component itself accompanies the executable.
* Redistributions must reproduce the above copyright notice, this list of
conditions and the following disclaimer in the documentation and/or other
materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
+1
View File
@@ -13,3 +13,4 @@ flutter:
assets:
- assets/retail/
- assets/shareware/
- assets/sod/shareware/
+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)
@@ -16,6 +16,9 @@ enum DataVersion {
/// v1.4 Retail release (found on GOG and Steam).
version14Retail('b8ff4997461bafa5ef2a94c11f9de001'),
/// Spear of Destiny Shareware release (VSWAP.SDM).
spearOfDestinyShareware('35afda760bea840b547d686a930322dc'),
/// Default state if the file hash is unrecognized.
unknown('unknown')
;
@@ -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
@@ -13,20 +14,6 @@ import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
abstract class WLParser {
static const int _areaTileBase = 107;
// --- Original Song Lookup Tables ---
static const List<int> _sharewareMusicMap = [
2, 3, 4, 5, 2, 3, 4, 5, 6, 7, // Episode 1
];
static const List<int> _retailMusicMap = [
2, 3, 4, 5, 2, 3, 4, 5, 6, 7, // Ep 1
8, 9, 10, 11, 8, 9, 11, 10, 6, 12, // Ep 2
13, 14, 15, 16, 13, 14, 15, 16, 17, 18, // Ep 3
2, 3, 4, 5, 2, 3, 4, 5, 6, 7, // Ep 4
8, 9, 10, 11, 8, 9, 11, 10, 6, 12, // Ep 5
13, 14, 15, 16, 13, 14, 15, 16, 17, 19, // Ep 6
];
/// Asynchronously discovers the game version and loads all necessary files.
///
/// Provide a [fileFetcher] callback (e.g., Flutter's `rootBundle.load` or
@@ -59,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
@@ -126,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;
@@ -182,8 +169,6 @@ abstract class WLParser {
required DataVersion dataIdentity,
AssetRegistry? registryOverride,
}) {
final isShareware = version == GameVersion.shareware;
final audio = parseAudio(audioHed, audioT, version);
final vgaImages = parseVgaImages(vgaDict, vgaHead, vgaGraph);
@@ -204,7 +189,7 @@ abstract class WLParser {
walls: parseWalls(vswap),
sprites: parseSprites(vswap),
sounds: parseSounds(vswap).map((bytes) => PcmSound(bytes)).toList(),
episodes: parseEpisodes(mapHead, gameMaps, isShareware: isShareware),
episodes: parseEpisodes(mapHead, gameMaps, version: version),
vgaImages: vgaImages,
adLibSounds: audio.adLib,
music: audio.music,
@@ -438,13 +423,12 @@ abstract class WLParser {
static List<Episode> parseEpisodes(
ByteData mapHead,
ByteData gameMaps, {
bool isShareware = true,
required GameVersion version,
}) {
List<WolfLevel> allLevels = [];
int rlewTag = mapHead.getUint16(0, Endian.little);
// Select the correct music map based on the version
final activeMusicMap = isShareware ? _sharewareMusicMap : _retailMusicMap;
final isShareware = version == GameVersion.shareware;
final episodeNames = isShareware
? _sharewareEpisodeNames
: _retailEpisodeNames;
@@ -510,9 +494,9 @@ abstract class WLParser {
}
// --- ASSIGN MUSIC ---
int trackIndex = (i < activeMusicMap.length)
? activeMusicMap[i]
: activeMusicMap[i % activeMusicMap.length];
final episodeIndex = i ~/ 10;
final levelIndex = i % 10;
final levelMusic = Music.levelFor(version, episodeIndex, levelIndex);
allLevels.add(
WolfLevel(
@@ -520,7 +504,7 @@ abstract class WLParser {
wallGrid: wallGrid,
objectGrid: objectGrid,
areaGrid: areaGrid,
musicIndex: trackIndex,
music: levelMusic,
),
);
}
@@ -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);
}
@@ -57,64 +57,3 @@ class ImfMusic {
return ImfMusic(instructions);
}
}
typedef WolfMusicMap = List<int>;
/// Map indices to original sound effects as defined in the Wolfenstein 3D source.
abstract class WolfSound {
// --- Doors & Environment ---
static const int openDoor = 8;
static const int closeDoor = 9;
static const int pushWall = 46; // Secret sliding walls
// --- Weapons & Combat ---
static const int knifeAttack = 23;
static const int pistolFire = 24;
static const int machineGunFire = 26;
static const int gatlingFire = 32; // Historically SHOOTSND in the source
static const int naziFire = 58; // Enemy gunshots
// --- Pickups & Items ---
static const int getMachineGun = 30;
static const int getAmmo = 31;
static const int getGatling = 38;
static const int healthSmall = 33; // Dog food / Meals
static const int healthLarge = 34; // First Aid
static const int treasure1 = 35; // Cross
static const int treasure2 = 36; // Chalice
static const int treasure3 = 37; // Chest
static const int treasure4 = 45; // Crown
static const int extraLife = 44; // 1-Up
// --- Enemies: Standard ---
static const int guardHalt = 21; // "Halt!"
static const int dogBark = 41;
static const int dogDeath = 62;
static const int dogAttack = 68;
static const int deathScream1 = 29;
static const int deathScream2 = 22;
static const int deathScream3 = 25;
static const int ssSchutzstaffel = 51; // "Schutzstaffel!"
static const int ssMeinGott = 63; // SS Death
// --- Enemies: Bosses (Retail Episodes 1-6) ---
static const int bossActive = 49;
static const int mutti = 50; // Hans Grosse Death
static const int ahhhg = 52;
static const int eva = 54; // Dr. Schabbs Death
static const int gutenTag = 55; // Hitler Greeting
static const int leben = 56;
static const int scheist = 57; // Hitler Death
static const int schabbsHas = 64; // Dr. Schabbs
static const int hitlerHas = 65;
static const int spion = 66; // Otto Giftmacher
static const int neinSoVass = 67; // Gretel Grosse Death
static const int mechSteps = 70; // Mecha-Hitler walking
// --- UI & Progression ---
static const int levelDone = 40;
static const int endBonus1 = 42;
static const int endBonus2 = 43;
static const int noBonus = 47;
static const int percent100 = 48;
}
@@ -22,14 +22,14 @@ class WolfLevel {
/// zero-based and correspond to original AREATILE-derived sectors.
final SpriteMap areaGrid;
/// The index of the [ImfMusic] track to play while this level is active.
final int musicIndex;
/// The [Music] track to play while this level is active.
final Music music;
const WolfLevel({
required this.name,
required this.wallGrid,
required this.objectGrid,
required this.areaGrid,
required this.musicIndex,
required this.music,
});
}
@@ -23,7 +23,7 @@ class WolfensteinData {
///
/// Access the five sub-modules via:
/// ```dart
/// data.registry.sfx.resolve(SfxKey.pistolFire)
/// data.registry.sfx.resolve(SoundEffect.pistolFire)
/// data.registry.music.musicForLevel(episode, level)
/// data.registry.entities.resolve(EntityKey.guard)
/// data.registry.hud.faceForHealth(player.health)
@@ -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});
}
@@ -4,9 +4,11 @@ abstract class EngineAudio {
WolfensteinData? activeGame;
Future<void> debugSoundTest();
void playMenuMusic();
void playLevelMusic(WolfLevel level);
void playLevelMusic(Music music);
void stopMusic();
void playSoundEffect(int sfxId);
Future<void> stopAllAudio();
void playSoundEffect(SoundEffect effect);
void playSoundEffectId(int sfxId);
Future<void> init();
void dispose();
}
@@ -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 {}
}
@@ -0,0 +1,38 @@
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
class SilentAudio implements EngineAudio {
@override
WolfensteinData? activeGame;
@override
Future<void> init() async {
// No-op fallback backend.
}
@override
void playMenuMusic() {}
@override
void playLevelMusic(Music music) {}
@override
void stopMusic() {}
@override
Future<void> stopAllAudio() async {}
@override
void playSoundEffect(SoundEffect effect) {}
@override
void playSoundEffectId(int sfxId) {}
@override
void dispose() {}
@override
Future<void> debugSoundTest() async {
return Future.value(null);
}
}
@@ -1,39 +0,0 @@
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
class CliSilentAudio implements EngineAudio {
@override
WolfensteinData? activeGame;
@override
Future<void> init() async {
// No-op for CLI
}
@override
void playMenuMusic() {}
@override
void playLevelMusic(WolfLevel level) {
// Optional: Print a log so you know it's working!
// debugPrint("🎵 Playing music for: ${level.name} 🎵");
}
@override
void stopMusic() {}
@override
void playSoundEffect(int sfxId) {
// Optional: You could use the terminal 'bell' character here
// to actually make a system beep when a sound plays!
// stdout.write('\x07');
}
@override
void dispose() {}
@override
Future<void> debugSoundTest() async {
return Future.value(null);
}
}
@@ -6,6 +6,7 @@ class EngineInput {
final bool isMovingBackward;
final bool isTurningLeft;
final bool isTurningRight;
final bool isMapToggle;
final bool isFiring;
final bool isInteracting;
final bool isBack;
@@ -18,6 +19,7 @@ class EngineInput {
this.isMovingBackward = false,
this.isTurningLeft = false,
this.isTurningRight = false,
this.isMapToggle = false,
this.isFiring = false,
this.isInteracting = false,
this.isBack = false,
@@ -15,7 +15,7 @@ class DoorManager {
/// Callback used to trigger sound effects without tight coupling
/// to a specific audio engine implementation.
final void Function(int sfxId) onPlaySound;
final void Function(SoundEffect effect) onPlaySound;
static int _key(int x, int y) => ((y & 0xFFFF) << 16) | (x & 0xFFFF);
@@ -41,7 +41,7 @@ class DoorManager {
final newState = door.update(elapsed.inMilliseconds);
if (newState == DoorState.closing) {
onPlaySound(WolfSound.closeDoor);
onPlaySound(SoundEffect.closeDoor);
}
}
}
@@ -60,7 +60,7 @@ class DoorManager {
final int key = _key(targetX, targetY);
if (doors.containsKey(key) && doors[key]!.interact()) {
onPlaySound(WolfSound.openDoor);
onPlaySound(SoundEffect.openDoor);
log('[DEBUG] Player opened door at ($targetX, $targetY)');
return (x: targetX, y: targetY);
}
@@ -74,7 +74,7 @@ class DoorManager {
// AI only interacts if the door is currently fully closed (offset == 0).
if (doors.containsKey(key) && doors[key]!.offset == 0.0) {
if (doors[key]!.interact()) {
onPlaySound(WolfSound.openDoor);
onPlaySound(SoundEffect.openDoor);
}
}
}
@@ -23,7 +23,7 @@ class PushwallManager {
PushwallManager({this.onPlaySound});
/// Optional callback used to emit audio cues when pushwalls activate.
final void Function(int sfxId)? onPlaySound;
final void Function(SoundEffect effect)? onPlaySound;
final Map<String, Pushwall> pushwalls = {};
Pushwall? activePushwall;
@@ -127,7 +127,7 @@ class PushwallManager {
int checkY = targetY + pw.dirY;
if (wallGrid[checkY][checkX] == 0) {
activePushwall = pw;
onPlaySound?.call(WolfSound.pushWall);
onPlaySound?.call(SoundEffect.pushWall);
}
}
}
@@ -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';
@@ -22,6 +23,25 @@ class Player {
double damageFlash = 0.0; // 0.0 is none, 1.0 is maximum red
final double damageFlashFadeSpeed = 0.05; // How fast it fades per tick
// Bonus flash
double bonusFlash = 0.0; // 0.0 is none, 1.0 is maximum white
final double bonusFlashFadeSpeed = 0.05; // How fast it fades per tick
// Chaingun pickup face (classic GOTGATLINGPIC)
int _chaingunPickupFaceMsRemaining = 0;
static const int _chaingunPickupFaceDurationMs = 900;
// Additional classic face states.
bool _mutantDeathFaceActive = false;
bool _godModeFaceEnabled = false;
// 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;
// Inventory
bool hasGoldKey = false;
bool hasSilverKey = false;
@@ -43,16 +63,132 @@ class Player {
// 0.0 is resting, 500.0 is fully off-screen
double weaponAnimOffset = 0.0;
// How fast the weapon drops/raises per tick
final double switchSpeed = 30.0;
static const double _weaponSwitchTravel = 500.0;
static const double _weaponSwitchPhaseTics = 6.0;
static const double _ticRate = 70.0;
static const double _weaponSwitchUnitsPerSecond =
(_weaponSwitchTravel * _ticRate) / _weaponSwitchPhaseTics;
Player({required this.x, required this.y, required this.angle}) {
currentWeapon = weapons[WeaponType.pistol]!;
setHudFaceAnimationSeed(0);
}
// Helper getter to interface with the RaycasterPainter
Coordinate2D get position => Coordinate2D(x, y);
bool get isChaingunPickupFaceActive => _chaingunPickupFaceMsRemaining > 0;
bool get isMutantDeathFaceActive => _mutantDeathFaceActive;
bool get isGodModeFaceEnabled => _godModeFaceEnabled;
int get hudFaceFrame => _faceFrame;
void setHudFaceAnimationSeed(int seed) {
_faceSeed = seed;
_faceRng = math.Random(seed);
_faceFrame = 0;
_faceCountTics = 0.0;
_nextFaceChangeThreshold = _faceRng.nextInt(256);
_mutantDeathFaceActive = false;
_godModeFaceEnabled = false;
}
void setGodModeFaceEnabled(bool enabled) {
_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) {
@@ -62,12 +198,40 @@ class Player {
damageFlash = math.max(0.0, damageFlash - damageFlashFadeSpeed);
}
updateWeaponSwitch();
if (bonusFlash > 0.0) {
bonusFlash = math.max(0.0, bonusFlash - bonusFlashFadeSpeed);
}
if (_chaingunPickupFaceMsRemaining > 0) {
_chaingunPickupFaceMsRemaining = math.max(
0,
_chaingunPickupFaceMsRemaining - elapsed.inMilliseconds,
);
} else {
_faceCountTics += (elapsed.inMilliseconds * 70) / 1000.0;
if (_faceCountTics > _nextFaceChangeThreshold) {
int nextFrame = _faceRng.nextInt(4);
if (nextFrame == 3) {
nextFrame = 1;
}
_faceFrame = nextFrame;
_faceCountTics = 0.0;
_nextFaceChangeThreshold = _faceRng.nextInt(256);
}
}
updateWeaponSwitch(elapsed);
}
// --- Weapon Switching & Animation Logic ---
void updateWeaponSwitch() {
void updateWeaponSwitch(Duration elapsed) {
final double delta =
_weaponSwitchUnitsPerSecond * (elapsed.inMicroseconds / 1000000.0);
if (delta <= 0.0) {
return;
}
if (switchState == WeaponSwitchState.lowering) {
// If the map doesn't contain the pending weapon, stop immediately
if (weapons[pendingWeaponType] == null) {
@@ -75,9 +239,9 @@ class Player {
return;
}
weaponAnimOffset += switchSpeed;
if (weaponAnimOffset >= 500.0) {
weaponAnimOffset = 500.0;
weaponAnimOffset += delta;
if (weaponAnimOffset >= _weaponSwitchTravel) {
weaponAnimOffset = _weaponSwitchTravel;
// We already know it's not null now, but we can keep the
// fallback to pistol just to be extra safe.
@@ -86,7 +250,7 @@ class Player {
switchState = WeaponSwitchState.raising;
}
} else if (switchState == WeaponSwitchState.raising) {
weaponAnimOffset -= switchSpeed;
weaponAnimOffset -= delta;
if (weaponAnimOffset <= 0) {
weaponAnimOffset = 0.0;
switchState = WeaponSwitchState.idle;
@@ -109,12 +273,14 @@ class Player {
// --- Health & Damage ---
void takeDamage(int damage) {
void takeDamage(int damage, {EnemyType? attackerType}) {
health = math.max(0, health - damage);
// Spike the damage flash based on how much damage was taken
// A 10 damage hit gives a 0.5 flash, a 20 damage hit maxes it out at 1.0
damageFlash = math.min(1.0, damageFlash + (damage * 0.05));
_chaingunPickupFaceMsRemaining = 0;
_mutantDeathFaceActive = health <= 0 && attackerType == EnemyType.mutant;
if (health <= 0) {
log("[PLAYER] Died! Final Score: $score");
@@ -129,6 +295,18 @@ class Player {
log("[PLAYER] Healed for $amount ($health -> $newHealth)");
}
health = newHealth;
_chaingunPickupFaceMsRemaining = 0;
_mutantDeathFaceActive = false;
}
void triggerBonusFlash() {
bonusFlash = 1.0;
}
void triggerChaingunPickupFace({int? durationMs}) {
_chaingunPickupFaceMsRemaining = durationMs == null
? _chaingunPickupFaceDurationMs
: math.max(0, durationMs);
}
void addAmmo(int amount) {
@@ -147,7 +325,7 @@ class Player {
/// Attempts to collect [item] and returns the SFX to play.
///
/// Returns `null` when the item was not collected (for example: full health).
int? tryPickup(Collectible item) {
SoundEffect? tryPickup(Collectible item) {
final effect = item.tryCollect(
CollectiblePickupContext(
health: health,
@@ -202,7 +380,10 @@ class Player {
}
if (weaponType == WeaponType.machineGun) hasMachineGun = true;
if (weaponType == WeaponType.chainGun) hasChainGun = true;
if (weaponType == WeaponType.chainGun) {
hasChainGun = true;
triggerChaingunPickupFace();
}
log("[PLAYER] Collected ${weaponType.name}.");
}
@@ -211,7 +392,9 @@ class Player {
requestWeaponSwitch(weaponType);
}
return effect.pickupSfxId;
triggerBonusFlash();
return effect.pickupSoundEffect;
}
bool fire(int currentTime) {
@@ -0,0 +1,41 @@
import 'dart:math' as math;
/// Canonical player movement and rotation constants derived directly from the
/// original Wolfenstein 3D source code (WL_PLAY.C / WL_AGENT.C).
///
/// Source constants (keyboard, walking state):
/// BASEMOVE = 35, RUNMOVE = 70
/// MOVESCALE = 150, BACKMOVESCALE = 100
/// BASETURN = 35, RUNTURN = 70, ANGLESCALE = 20
/// TILEGLOBAL = 65536 (fixed-point units per tile), TICRATE = 70
abstract final class PlayerLocomotionConstants {
static const double basemove = 35.0;
static const double runmove = 70.0;
static const double movescale = 150.0;
static const double backmovescale = 100.0;
static const double baseturn = 35.0;
static const double runturn = 70.0;
static const double anglescale = 20.0;
static const double tileglobal = 65536.0;
static const double ticrate = 70.0;
/// Walking forward speed in tiles per second.
///
/// Derived from: `(BASEMOVE * MOVESCALE * TICRATE) / TILEGLOBAL`
static const double forwardTilesPerSecond =
(basemove * movescale * ticrate) / tileglobal;
/// Walking backward speed in tiles per second (intentionally slower than
/// forward in the original game).
///
/// Derived from: `(BASEMOVE * BACKMOVESCALE * TICRATE) / TILEGLOBAL`
static const double backwardTilesPerSecond =
(basemove * backmovescale * ticrate) / tileglobal;
/// Walking turn rate in radians per second.
///
/// Derived from: `(BASETURN / ANGLESCALE) * TICRATE` in degrees/sec,
/// then converted to radians.
static const double turnRadiansPerSecond =
(baseturn / anglescale) * ticrate * (math.pi / 180.0);
}
@@ -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,132 @@
/// Shared renderer settings and capability metadata.
library;
/// Engine-visible renderer modes used by host adapters and menu flow.
enum WolfRendererMode {
ascii('ASCII'),
sixel('SIXEL'),
software('SOFTWARE'),
hardware('HARDWARE')
;
const WolfRendererMode(this.label);
final String label;
}
/// Renderer-specific toggles shown by the Change View customization submenu.
enum WolfRendererOptionId {
asciiTheme,
hardwareEffects,
crtBloom,
fpsCounter,
}
/// Host-reported renderer capability set used to filter visible menu options.
class WolfRendererCapabilities {
const WolfRendererCapabilities({
required this.supportedModes,
this.supportsAsciiThemes = false,
this.supportsHardwareEffects = false,
this.supportsBloom = false,
this.supportsFpsCounter = false,
});
final Set<WolfRendererMode> supportedModes;
final bool supportsAsciiThemes;
final bool supportsHardwareEffects;
final bool supportsBloom;
final bool supportsFpsCounter;
bool supportsMode(WolfRendererMode mode) => supportedModes.contains(mode);
}
/// Engine-owned renderer settings that both menu flow and host shortcuts mutate.
class WolfRendererSettings {
const WolfRendererSettings({
this.mode = WolfRendererMode.software,
this.asciiThemeId = asciiThemeBlocks,
this.hardwareEffectsEnabled = false,
this.bloomEnabled = false,
this.fpsCounterEnabled = false,
});
static const String asciiThemeBlocks = 'blocks';
static const String asciiThemeQuadrant = 'quadrant';
static const List<String> asciiThemeIds = <String>[
asciiThemeBlocks,
asciiThemeQuadrant,
];
final WolfRendererMode mode;
final String asciiThemeId;
final bool hardwareEffectsEnabled;
final bool bloomEnabled;
final bool fpsCounterEnabled;
WolfRendererSettings copyWith({
WolfRendererMode? mode,
String? asciiThemeId,
bool? hardwareEffectsEnabled,
bool? bloomEnabled,
bool? fpsCounterEnabled,
}) {
return WolfRendererSettings(
mode: mode ?? this.mode,
asciiThemeId: asciiThemeId ?? this.asciiThemeId,
hardwareEffectsEnabled:
hardwareEffectsEnabled ?? this.hardwareEffectsEnabled,
bloomEnabled: bloomEnabled ?? this.bloomEnabled,
fpsCounterEnabled: fpsCounterEnabled ?? this.fpsCounterEnabled,
);
}
WolfRendererSettings cycleAsciiTheme() {
final int current = asciiThemeIds.indexOf(asciiThemeId);
final int next = current == -1 ? 0 : (current + 1) % asciiThemeIds.length;
return copyWith(asciiThemeId: asciiThemeIds[next]);
}
static String asciiThemeLabel(String id) {
switch (id) {
case asciiThemeQuadrant:
return 'CHARSET: QUADRANT';
case asciiThemeBlocks:
default:
return 'CHARSET: BLOCKS';
}
}
Map<String, Object> toJson() {
return <String, Object>{
'mode': mode.name,
'asciiThemeId': asciiThemeId,
'hardwareEffectsEnabled': hardwareEffectsEnabled,
'bloomEnabled': bloomEnabled,
'fpsCounterEnabled': fpsCounterEnabled,
};
}
static WolfRendererSettings fromJson(Map<String, Object?> json) {
final Object? modeRaw = json['mode'];
final WolfRendererMode mode = WolfRendererMode.values.firstWhere(
(candidate) => candidate.name == modeRaw,
orElse: () => WolfRendererMode.software,
);
final Object? themeRaw = json['asciiThemeId'];
final String asciiThemeId =
themeRaw is String && asciiThemeIds.contains(themeRaw)
? themeRaw
: asciiThemeBlocks;
return WolfRendererSettings(
mode: mode,
asciiThemeId: asciiThemeId,
hardwareEffectsEnabled: json['hardwareEffectsEnabled'] == true,
bloomEnabled: json['bloomEnabled'] == true,
fpsCounterEnabled: json['fpsCounterEnabled'] == true,
);
}
}
@@ -0,0 +1,48 @@
/// Platform-agnostic persistence contract for renderer settings.
library;
import 'dart:convert';
import 'package:wolf_3d_dart/src/engine/rendering/renderer_settings.dart';
/// Abstract contract that host adapters must implement to persist settings.
abstract class RendererSettingsPersistence {
/// Loads previously saved settings, returning `null` on first run or error.
Future<WolfRendererSettings?> load();
/// Saves [settings] asynchronously; failures should be silently absorbed.
Future<void> save(WolfRendererSettings settings);
}
/// Mixin that handles JSON encode/decode so adapters only need to implement
/// raw string read/write.
mixin JsonRendererSettingsPersistence on RendererSettingsPersistence {
Future<String?> readRaw();
Future<void> writeRaw(String json);
@override
Future<WolfRendererSettings?> load() async {
try {
final String? raw = await readRaw();
if (raw == null || raw.isEmpty) {
return null;
}
final Object? decoded = jsonDecode(raw);
if (decoded is! Map<String, Object?>) {
return null;
}
return WolfRendererSettings.fromJson(decoded);
} catch (_) {
return null;
}
}
@override
Future<void> save(WolfRendererSettings settings) async {
try {
await writeRaw(jsonEncode(settings.toJson()));
} catch (_) {
// Best-effort never break the game loop on persistence failure.
}
}
}
@@ -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;
File diff suppressed because it is too large Load Diff
@@ -29,7 +29,7 @@ class CollectiblePickupEffect {
final int ammoToAdd;
final int scoreToAdd;
final int extraLivesToAdd;
final int pickupSfxId;
final SoundEffect pickupSoundEffect;
final bool grantGoldKey;
final bool grantSilverKey;
final WeaponType? grantWeapon;
@@ -40,7 +40,7 @@ class CollectiblePickupEffect {
this.ammoToAdd = 0,
this.scoreToAdd = 0,
this.extraLivesToAdd = 0,
required this.pickupSfxId,
required this.pickupSoundEffect,
this.grantGoldKey = false,
this.grantSilverKey = false,
this.grantWeapon,
@@ -49,7 +49,7 @@ class CollectiblePickupEffect {
@override
String toString() {
return 'CollectiblePickupEffect(healthToRestore: $healthToRestore, ammoToAdd: $ammoToAdd, scoreToAdd: $scoreToAdd, extraLivesToAdd: $extraLivesToAdd, pickupSfxId: $pickupSfxId, grantGoldKey: $grantGoldKey, grantSilverKey: $grantSilverKey, grantWeapon: $grantWeapon, requestWeaponSwitch: $requestWeaponSwitch)';
return 'CollectiblePickupEffect(healthToRestore: $healthToRestore, ammoToAdd: $ammoToAdd, scoreToAdd: $scoreToAdd, extraLivesToAdd: $extraLivesToAdd, pickupSoundEffect: $pickupSoundEffect, grantGoldKey: $grantGoldKey, grantSilverKey: $grantSilverKey, grantWeapon: $grantWeapon, requestWeaponSwitch: $requestWeaponSwitch)';
}
}
@@ -81,6 +81,7 @@ abstract class Collectible extends Entity {
double y,
Difficulty difficulty, {
bool isSharewareMode = false,
AssetRegistry? registry,
}) {
return switch (objId) {
MapObject.goldKey || MapObject.silverKey => KeyCollectible(
@@ -123,7 +124,9 @@ class HealthCollectible extends Collectible {
final bool isFood = mapId == MapObject.food;
return CollectiblePickupEffect(
healthToRestore: isFood ? 10 : 25,
pickupSfxId: isFood ? WolfSound.healthSmall : WolfSound.healthLarge,
pickupSoundEffect: isFood
? SoundEffect.healthSmall
: SoundEffect.healthLarge,
);
}
}
@@ -149,7 +152,7 @@ class AmmoCollectible extends Collectible {
return CollectiblePickupEffect(
ammoToAdd: ammoAmount,
pickupSfxId: WolfSound.getAmmo,
pickupSoundEffect: SoundEffect.getAmmo,
requestWeaponSwitch: shouldAutoswitchToPistol ? WeaponType.pistol : null,
);
}
@@ -172,7 +175,7 @@ class WeaponCollectible extends Collectible {
if (mapId == MapObject.machineGun) {
return const CollectiblePickupEffect(
ammoToAdd: 6,
pickupSfxId: WolfSound.getMachineGun,
pickupSoundEffect: SoundEffect.getMachineGun,
grantWeapon: WeaponType.machineGun,
requestWeaponSwitch: WeaponType.machineGun,
);
@@ -181,7 +184,7 @@ class WeaponCollectible extends Collectible {
if (mapId == MapObject.chainGun) {
return const CollectiblePickupEffect(
ammoToAdd: 6,
pickupSfxId: WolfSound.getGatling,
pickupSoundEffect: SoundEffect.getChainGun,
grantWeapon: WeaponType.chainGun,
requestWeaponSwitch: WeaponType.chainGun,
);
@@ -203,7 +206,7 @@ class KeyCollectible extends Collectible {
if (mapId == MapObject.goldKey) {
if (context.hasGoldKey) return null;
return const CollectiblePickupEffect(
pickupSfxId: WolfSound.getAmmo,
pickupSoundEffect: SoundEffect.getAmmo,
grantGoldKey: true,
);
}
@@ -211,7 +214,7 @@ class KeyCollectible extends Collectible {
if (mapId == MapObject.silverKey) {
if (context.hasSilverKey) return null;
return const CollectiblePickupEffect(
pickupSfxId: WolfSound.getAmmo,
pickupSoundEffect: SoundEffect.getAmmo,
grantSilverKey: true,
);
}
@@ -243,18 +246,18 @@ class TreasureCollectible extends Collectible {
healthToRestore: 99,
ammoToAdd: 25,
extraLivesToAdd: 1,
pickupSfxId: WolfSound.extraLife,
pickupSoundEffect: SoundEffect.extraLife,
);
}
return CollectiblePickupEffect(
scoreToAdd: scoreValue,
pickupSfxId: switch (mapId) {
MapObject.cross => WolfSound.treasure1,
MapObject.chalice => WolfSound.treasure2,
MapObject.chest => WolfSound.treasure3,
MapObject.crown => WolfSound.treasure4,
_ => WolfSound.getAmmo,
pickupSoundEffect: switch (mapId) {
MapObject.cross => SoundEffect.treasure1,
MapObject.chalice => SoundEffect.treasure2,
MapObject.chest => SoundEffect.treasure3,
MapObject.crown => SoundEffect.treasure4,
_ => SoundEffect.getAmmo,
},
);
}
@@ -15,6 +15,7 @@ class DeadAardwolf extends Decorative {
double y,
Difficulty difficulty, {
bool isSharewareMode = false,
AssetRegistry? registry,
}) {
if (objId == 125) {
return DeadAardwolf(x: x, y: y);
@@ -21,6 +21,7 @@ class DeadGuard extends Decorative {
double y,
Difficulty difficulty, {
bool isSharewareMode = false,
AssetRegistry? registry,
}) {
if (objId == 124) {
return DeadGuard(x: x, y: y);
@@ -1,5 +1,5 @@
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/src/entities/entity.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
class Decorative extends Entity {
Decorative({
@@ -41,6 +41,7 @@ class Decorative extends Entity {
double y,
Difficulty difficulty, {
bool isSharewareMode = false,
AssetRegistry? registry,
}) {
// 2. Standard props (Table, Lamp, etc) use the tiered check
if (!MapObject.isDifficultyAllowed(objId, difficulty)) return null;
@@ -12,13 +12,13 @@ class HansGrosse extends Enemy {
throw UnimplementedError("Hans Grosse uses manual animation logic.");
@override
int get alertSoundId => WolfSound.bossActive;
SoundEffect get alertSound => SoundEffect.bossActive;
@override
int get attackSoundId => WolfSound.naziFire;
SoundEffect get attackSound => SoundEffect.enemyFire;
@override
int get deathSoundId => WolfSound.mutti;
SoundEffect get deathSound => SoundEffect.hansGrosseDeath;
@override
int get scoreValue => 5000;
@@ -45,6 +45,7 @@ class HansGrosse extends Enemy {
double y,
Difficulty difficulty, {
bool isSharewareMode = false,
AssetRegistry? registry,
}) {
if (objId == MapObject.bossHansGrosse) {
return HansGrosse(
@@ -85,7 +86,7 @@ class HansGrosse extends Enemy {
required bool Function(int area) isAreaConnectedToPlayer,
required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor,
required void Function(int sfxId) onPlaySound,
required void Function(SoundEffect effect) onPlaySound,
}) {
Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle;
@@ -102,7 +103,7 @@ class HansGrosse extends Enemy {
isAreaConnectedToPlayer: isAreaConnectedToPlayer,
baseReactionMs: 50,
)) {
onPlaySound(alertSoundId);
onPlaySound(alertSound);
}
double distance = position.distanceTo(playerPosition);
@@ -151,7 +152,7 @@ class HansGrosse extends Enemy {
setTics(10);
} else if (currentFrame == 2) {
spriteIndex = _baseSprite + 6; // Fire
onPlaySound(attackSoundId);
onPlaySound(attackSound);
onDamagePlayer(damage);
setTics(4);
} else if (currentFrame == 3) {
@@ -25,14 +25,45 @@ class Dog extends Enemy {
required super.y,
required super.angle,
required super.mapId,
super.animationSet,
Difficulty difficulty = Difficulty.medium,
}) : super(
spriteIndex: EnemyType.dog.animations.walking.start,
spriteIndex: (animationSet ?? EnemyType.dog.animations).walking.start,
state: EntityState.patrolling,
) {
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) {
EntityState.patrolling || EntityState.ambushing => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
_ => EnemyAnimation.idle,
};
}
@override
({Coordinate2D movement, double newAngle}) update({
required int elapsedMs,
@@ -45,7 +76,7 @@ class Dog extends Enemy {
required bool Function(int area) isAreaConnectedToPlayer,
required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor,
required void Function(int sfxId) onPlaySound,
required void Function(SoundEffect effect) onPlaySound,
}) {
final previousState = state;
Coordinate2D movement = const Coordinate2D(0, 0);
@@ -60,7 +91,7 @@ class Dog extends Enemy {
areaAt: areaAt,
isAreaConnectedToPlayer: isAreaConnectedToPlayer,
)) {
onPlaySound(alertSoundId);
onPlaySound(alertSound);
}
}
@@ -72,7 +103,7 @@ class Dog extends Enemy {
currentFrame++;
// Phase 2: The actual bite
if (currentFrame == 1) {
onPlaySound(attackSoundId);
onPlaySound(attackSound);
final bool attackSuccessful =
math.Random().nextDouble() < (180 / 256);
@@ -176,7 +207,9 @@ class Dog extends Enemy {
// A movement magnitude threshold prevents accepting near-zero floating-
// point residuals (e.g. cos(π/2) 6e-17) as valid movement.
final double minEffective = currentMoveSpeed * 0.5;
final double currentDistanceToPlayer = position.distanceTo(playerPosition);
final double currentDistanceToPlayer = position.distanceTo(
playerPosition,
);
int selectedCandidateIndex = -1;
for (int i = 0; i < candidateAngles.length; i++) {
@@ -192,7 +225,8 @@ class Dog extends Enemy {
tryOpenDoor: (x, y) {},
);
if (candidateMovement.x.abs() + candidateMovement.y.abs() < minEffective) {
if (candidateMovement.x.abs() + candidateMovement.y.abs() <
minEffective) {
continue;
}
@@ -311,15 +345,9 @@ class Dog extends Enemy {
diff -= 2 * math.pi;
}
EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling || EntityState.ambushing => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
_ => EnemyAnimation.idle,
};
final currentAnim = animationForState(state);
spriteIndex = type.getSpriteFromAnimation(
spriteIndex = spriteForAnimation(
animation: currentAnim,
elapsedMs: elapsedMs,
lastActionTime: lastActionTime,
@@ -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';
@@ -24,11 +25,14 @@ abstract class Enemy extends Entity {
required super.x,
required super.y,
required super.spriteIndex,
EnemyAnimationMap? animationSet,
super.angle,
super.state,
super.mapId,
super.lastActionTime,
});
}) : _animationSetOverride = animationSet;
EnemyAnimationMap? _animationSetOverride;
/// The current "Tic" count remaining for the active animation frame.
int _ticCount = 0;
@@ -55,16 +59,76 @@ abstract class Enemy extends Entity {
EnemyType get type;
/// The sound played when this enemy notices the player or hears combat.
int get alertSoundId => type.alertSoundId;
SoundEffect get alertSound => type.alertSound;
/// The sound played when this enemy performs its attack animation.
int get attackSoundId => type.attackSoundId;
SoundEffect get attackSound => type.attackSound;
/// The score awarded when this enemy is killed.
int get scoreValue => type.scoreValue;
/// The sound played once when this enemy starts dying.
int get deathSoundId => type.deathSoundId;
SoundEffect get deathSound => type.deathSound;
EnemyAnimationMap get animationSet =>
_animationSetOverride ?? type.animations;
EnemyAnimation animationForState(EntityState state) {
return switch (state) {
EntityState.patrolling => EnemyAnimation.walking,
EntityState.ambushing => EnemyAnimation.idle,
EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
_ => EnemyAnimation.idle,
};
}
void setAnimationSet(
EnemyAnimationMap set, {
bool alignSpriteToState = false,
}) {
_animationSetOverride = set;
if (alignSpriteToState) {
final anim = animationForState(state);
spriteIndex = animationSet.getRange(anim).start;
}
}
int spriteForAnimation({
required EnemyAnimation animation,
required int elapsedMs,
required int lastActionTime,
double angleDiff = 0,
int? walkFrameOverride,
}) {
final range = animationSet.getRange(animation);
int octant = ((angleDiff + (math.pi / 8)) / (math.pi / 4)).floor() % 8;
if (octant < 0) octant += 8;
return switch (animation) {
EnemyAnimation.idle => range.start + octant,
EnemyAnimation.walking => () {
int framesPerAngle = range.length ~/ 8;
if (framesPerAngle < 1) framesPerAngle = 1;
int frame = walkFrameOverride ?? (elapsedMs ~/ 150) % framesPerAngle;
return range.start + (frame * 8) + octant;
}(),
EnemyAnimation.attacking => () {
int time = elapsedMs - lastActionTime;
int mappedFrame = (time ~/ 150).clamp(0, range.length - 1);
return range.start + mappedFrame;
}(),
EnemyAnimation.pain => range.start,
EnemyAnimation.dying => () {
int time = elapsedMs - lastActionTime;
int mappedFrame = (time ~/ 150).clamp(0, range.length - 1);
return range.start + mappedFrame;
}(),
EnemyAnimation.dead => range.start,
};
}
/// Ensures enemies drop only one item (like ammo or a key) upon death.
bool hasDroppedItem = false;
@@ -83,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
@@ -412,11 +548,19 @@ abstract class Enemy extends Entity {
// is moving mostly north and clips a wall tile beside an open door, the
// Y (northward) slide is preferred over the X (sideways) slide.
if (intendedMovement.y.abs() >= intendedMovement.x.abs()) {
if (canMoveY) return normalizeTiny(Coordinate2D(0, intendedMovement.y));
if (canMoveX) return normalizeTiny(Coordinate2D(intendedMovement.x, 0));
if (canMoveY) {
return normalizeTiny(Coordinate2D(0, intendedMovement.y));
}
if (canMoveX) {
return normalizeTiny(Coordinate2D(intendedMovement.x, 0));
}
} else {
if (canMoveX) return normalizeTiny(Coordinate2D(intendedMovement.x, 0));
if (canMoveY) return normalizeTiny(Coordinate2D(0, intendedMovement.y));
if (canMoveX) {
return normalizeTiny(Coordinate2D(intendedMovement.x, 0));
}
if (canMoveY) {
return normalizeTiny(Coordinate2D(0, intendedMovement.y));
}
}
return const Coordinate2D(0, 0);
}
@@ -534,7 +678,7 @@ abstract class Enemy extends Entity {
required bool Function(int area) isAreaConnectedToPlayer,
required void Function(int x, int y) tryOpenDoor,
required void Function(int damage) onDamagePlayer,
required void Function(int sfxId) onPlaySound,
required void Function(SoundEffect effect) onPlaySound,
});
/// Factory method to spawn the correct [Enemy] subclass based on a Map ID.
@@ -547,6 +691,7 @@ abstract class Enemy extends Entity {
double y,
Difficulty difficulty, {
bool isSharewareMode = false,
AssetRegistry? registry,
}) {
// Filter out decorative bodies or player starts
if (objId == MapObject.deadGuard || objId == MapObject.deadAardwolf) {
@@ -564,20 +709,33 @@ abstract class Enemy extends Entity {
EnemyType? matchedType;
int? normalizedId;
EnemyAnimationMap? matchedAnimationSet;
// Find which enemy type claims this ID for the current difficulty
for (final type in EnemyType.values) {
if (isSharewareMode && !type.existsInShareware) continue;
final animationSet = _resolveAnimationSet(type, registry);
if (animationSet == null) {
continue;
}
if (registry == null && isSharewareMode && !type.existsInShareware) {
continue;
}
normalizedId = type.mapData.getNormalizedId(objId, difficulty);
if (normalizedId != null) {
matchedType = type;
matchedAnimationSet = animationSet;
break;
}
}
// If no type claimed it, or the difficulty was too low, abort spawn
if (matchedType == null || normalizedId == null) return null;
if (matchedType == null ||
normalizedId == null ||
matchedAnimationSet == null) {
return null;
}
// Resolve spawn orientation using the NORMALIZED ID (0-7 offset from base)
// This prevents offset math bugs (like the Mutant's 18-ID shift) from breaking facing directions.
@@ -598,13 +756,14 @@ abstract class Enemy extends Entity {
}
// Return the specific instance
return switch (matchedType) {
final spawned = switch (matchedType) {
EnemyType.guard => Guard(
x: x,
y: y,
angle: spawnAngle,
mapId: objId,
difficulty: difficulty,
animationSet: matchedAnimationSet,
),
EnemyType.dog => Dog(
x: x,
@@ -612,6 +771,7 @@ abstract class Enemy extends Entity {
angle: spawnAngle,
mapId: objId,
difficulty: difficulty,
animationSet: matchedAnimationSet,
),
EnemyType.ss => SS(
x: x,
@@ -619,6 +779,7 @@ abstract class Enemy extends Entity {
angle: spawnAngle,
mapId: objId,
difficulty: difficulty,
animationSet: matchedAnimationSet,
),
EnemyType.mutant => Mutant(
x: x,
@@ -626,6 +787,7 @@ abstract class Enemy extends Entity {
angle: spawnAngle,
mapId: objId,
difficulty: difficulty,
animationSet: matchedAnimationSet,
),
EnemyType.officer => Officer(
x: x,
@@ -633,7 +795,33 @@ abstract class Enemy extends Entity {
angle: spawnAngle,
mapId: objId,
difficulty: difficulty,
animationSet: matchedAnimationSet,
),
}..state = spawnState;
};
spawned
..state = spawnState
..setAnimationSet(matchedAnimationSet, alignSpriteToState: true);
return spawned;
}
static EnemyAnimationMap? _resolveAnimationSet(
EnemyType type,
AssetRegistry? registry,
) {
if (registry == null) {
return type.animations;
}
final key = switch (type) {
EnemyType.guard => EntityKey.guard,
EnemyType.dog => EntityKey.dog,
EnemyType.ss => EntityKey.ss,
EnemyType.mutant => EntityKey.mutant,
EnemyType.officer => EntityKey.officer,
};
return registry.entities.resolve(key)?.animations;
}
}
@@ -11,9 +11,9 @@ enum EnemyType {
guard(
mapData: EnemyMapData(MapObject.guardStart),
scoreValue: 100,
alertSoundId: WolfSound.guardHalt,
attackSoundId: WolfSound.naziFire,
deathSoundId: WolfSound.deathScream1,
alertSound: SoundEffect.guardHalt,
attackSound: SoundEffect.enemyFire,
deathSound: SoundEffect.deathScream1,
animations: EnemyAnimationMap(
idle: SpriteFrameRange(50, 57),
walking: SpriteFrameRange(58, 89),
@@ -28,9 +28,9 @@ enum EnemyType {
dog(
mapData: EnemyMapData(MapObject.dogStart),
scoreValue: 200,
alertSoundId: WolfSound.dogBark,
attackSoundId: WolfSound.dogAttack,
deathSoundId: WolfSound.dogDeath,
alertSound: SoundEffect.dogBark,
attackSound: SoundEffect.dogAttack,
deathSound: SoundEffect.dogDeath,
animations: EnemyAnimationMap(
// Dogs don't have true idle sprites, so map idle to the first walk frame safely
idle: SpriteFrameRange(99, 106),
@@ -51,9 +51,9 @@ enum EnemyType {
ss(
mapData: EnemyMapData(MapObject.ssStart),
scoreValue: 500,
alertSoundId: WolfSound.ssSchutzstaffel,
attackSoundId: WolfSound.naziFire,
deathSoundId: WolfSound.ssMeinGott,
alertSound: SoundEffect.ssAlert,
attackSound: SoundEffect.enemyFire,
deathSound: SoundEffect.ssDeath,
animations: EnemyAnimationMap(
idle: SpriteFrameRange(138, 145),
walking: SpriteFrameRange(146, 177),
@@ -68,9 +68,9 @@ enum EnemyType {
mutant(
mapData: EnemyMapData(MapObject.mutantStart, tierOffset: 18),
scoreValue: 700,
alertSoundId: WolfSound.guardHalt,
attackSoundId: WolfSound.naziFire,
deathSoundId: WolfSound.deathScream2,
alertSound: SoundEffect.guardHalt,
attackSound: SoundEffect.enemyFire,
deathSound: SoundEffect.deathScream2,
animations: EnemyAnimationMap(
idle: SpriteFrameRange(187, 194),
walking: SpriteFrameRange(195, 226),
@@ -86,9 +86,9 @@ enum EnemyType {
officer(
mapData: EnemyMapData(MapObject.officerStart),
scoreValue: 400,
alertSoundId: WolfSound.guardHalt,
attackSoundId: WolfSound.naziFire,
deathSoundId: WolfSound.deathScream3,
alertSound: SoundEffect.guardHalt,
attackSound: SoundEffect.enemyFire,
deathSound: SoundEffect.deathScream3,
animations: EnemyAnimationMap(
idle: SpriteFrameRange(238, 245),
walking: SpriteFrameRange(246, 277),
@@ -111,13 +111,13 @@ enum EnemyType {
final int scoreValue;
/// The sound played when this enemy first becomes alerted.
final int alertSoundId;
final SoundEffect alertSound;
/// The sound played when this enemy attacks.
final int attackSoundId;
final SoundEffect attackSound;
/// The sound played when this enemy enters its death animation.
final int deathSoundId;
final SoundEffect deathSound;
/// If false, this enemy type will be ignored when loading shareware data.
final bool existsInShareware;
@@ -126,9 +126,9 @@ enum EnemyType {
required this.mapData,
required this.animations,
required this.scoreValue,
required this.alertSoundId,
required this.attackSoundId,
required this.deathSoundId,
required this.alertSound,
required this.attackSound,
required this.deathSound,
this.existsInShareware = true,
});
@@ -18,9 +18,10 @@ class Guard extends Enemy {
required super.y,
required super.angle,
required super.mapId,
super.animationSet,
Difficulty difficulty = Difficulty.medium,
}) : super(
spriteIndex: EnemyType.guard.animations.idle.start,
spriteIndex: (animationSet ?? EnemyType.guard.animations).idle.start,
state: EntityState.idle,
) {
health = type.hitPointsFor(difficulty);
@@ -38,7 +39,7 @@ class Guard extends Enemy {
required bool Function(int area) isAreaConnectedToPlayer,
required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor,
required void Function(int sfxId) onPlaySound,
required void Function(SoundEffect effect) onPlaySound,
}) {
Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle;
@@ -52,7 +53,7 @@ class Guard extends Enemy {
areaAt: areaAt,
isAreaConnectedToPlayer: isAreaConnectedToPlayer,
)) {
onPlaySound(alertSoundId);
onPlaySound(alertSound);
}
// 2. Discrete AI Logic (Decisions happen every 10 tics)
@@ -62,7 +63,7 @@ class Guard extends Enemy {
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
currentFrame++;
if (currentFrame == 1) {
onPlaySound(attackSoundId);
onPlaySound(attackSound);
if (tryRollRangedHit(
distance: distance,
playerPosition: playerPosition,
@@ -150,14 +151,15 @@ class Guard extends Enemy {
}
EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling || EntityState.ambushing => EnemyAnimation.walking,
EntityState.patrolling => EnemyAnimation.walking,
EntityState.ambushing => EnemyAnimation.idle,
EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
_ => EnemyAnimation.idle,
};
spriteIndex = type.getSpriteFromAnimation(
spriteIndex = spriteForAnimation(
animation: currentAnim,
elapsedMs: elapsedMs,
lastActionTime: lastActionTime,
@@ -16,9 +16,10 @@ class Mutant extends Enemy {
required super.y,
required super.angle,
required super.mapId,
super.animationSet,
Difficulty difficulty = Difficulty.medium,
}) : super(
spriteIndex: EnemyType.mutant.animations.idle.start,
spriteIndex: (animationSet ?? EnemyType.mutant.animations).idle.start,
state: EntityState.idle,
) {
health = type.hitPointsFor(difficulty);
@@ -36,7 +37,7 @@ class Mutant extends Enemy {
required bool Function(int area) isAreaConnectedToPlayer,
required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor,
required void Function(int sfxId) onPlaySound,
required void Function(SoundEffect effect) onPlaySound,
}) {
Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle;
@@ -48,7 +49,7 @@ class Mutant extends Enemy {
areaAt: areaAt,
isAreaConnectedToPlayer: isAreaConnectedToPlayer,
)) {
onPlaySound(alertSoundId);
onPlaySound(alertSound);
}
double distance = position.distanceTo(playerPosition);
@@ -65,14 +66,15 @@ class Mutant extends Enemy {
}
EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling || EntityState.ambushing => EnemyAnimation.walking,
EntityState.patrolling => EnemyAnimation.walking,
EntityState.ambushing => EnemyAnimation.idle,
EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
_ => EnemyAnimation.idle,
};
spriteIndex = type.getSpriteFromAnimation(
spriteIndex = spriteForAnimation(
animation: currentAnim,
elapsedMs: elapsedMs,
lastActionTime: lastActionTime,
@@ -122,7 +124,7 @@ class Mutant extends Enemy {
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
currentFrame++;
if (currentFrame == 1) {
onPlaySound(attackSoundId);
onPlaySound(attackSound);
if (tryRollRangedHit(
distance: distance,
playerPosition: playerPosition,
@@ -16,9 +16,10 @@ class Officer extends Enemy {
required super.y,
required super.angle,
required super.mapId,
super.animationSet,
Difficulty difficulty = Difficulty.medium,
}) : super(
spriteIndex: EnemyType.officer.animations.idle.start,
spriteIndex: (animationSet ?? EnemyType.officer.animations).idle.start,
state: EntityState.idle,
) {
health = type.hitPointsFor(difficulty);
@@ -36,7 +37,7 @@ class Officer extends Enemy {
required bool Function(int area) isAreaConnectedToPlayer,
required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor,
required void Function(int sfxId) onPlaySound,
required void Function(SoundEffect effect) onPlaySound,
}) {
Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle;
@@ -48,7 +49,7 @@ class Officer extends Enemy {
areaAt: areaAt,
isAreaConnectedToPlayer: isAreaConnectedToPlayer,
)) {
onPlaySound(alertSoundId);
onPlaySound(alertSound);
}
double distance = position.distanceTo(playerPosition);
@@ -65,14 +66,15 @@ class Officer extends Enemy {
}
EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling || EntityState.ambushing => EnemyAnimation.walking,
EntityState.patrolling => EnemyAnimation.walking,
EntityState.ambushing => EnemyAnimation.idle,
EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
_ => EnemyAnimation.idle,
};
spriteIndex = type.getSpriteFromAnimation(
spriteIndex = spriteForAnimation(
animation: currentAnim,
elapsedMs: elapsedMs,
lastActionTime: lastActionTime,
@@ -122,7 +124,7 @@ class Officer extends Enemy {
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
currentFrame++;
if (currentFrame == 1) {
onPlaySound(attackSoundId);
onPlaySound(attackSound);
if (tryRollRangedHit(
distance: distance,
playerPosition: playerPosition,
@@ -16,9 +16,10 @@ class SS extends Enemy {
required super.y,
required super.angle,
required super.mapId,
super.animationSet,
Difficulty difficulty = Difficulty.medium,
}) : super(
spriteIndex: EnemyType.ss.animations.idle.start,
spriteIndex: (animationSet ?? EnemyType.ss.animations).idle.start,
state: EntityState.idle,
) {
health = type.hitPointsFor(difficulty);
@@ -35,7 +36,7 @@ class SS extends Enemy {
required bool Function(int area) isAreaConnectedToPlayer,
required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor,
required void Function(int sfxId) onPlaySound,
required void Function(SoundEffect effect) onPlaySound,
}) {
Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle;
@@ -47,7 +48,7 @@ class SS extends Enemy {
areaAt: areaAt,
isAreaConnectedToPlayer: isAreaConnectedToPlayer,
)) {
onPlaySound(alertSoundId);
onPlaySound(alertSound);
}
double distance = position.distanceTo(playerPosition);
@@ -64,14 +65,15 @@ class SS extends Enemy {
}
EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling || EntityState.ambushing => EnemyAnimation.walking,
EntityState.patrolling => EnemyAnimation.walking,
EntityState.ambushing => EnemyAnimation.idle,
EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
_ => EnemyAnimation.idle,
};
spriteIndex = type.getSpriteFromAnimation(
spriteIndex = spriteForAnimation(
animation: currentAnim,
elapsedMs: elapsedMs,
lastActionTime: lastActionTime,
@@ -121,7 +123,7 @@ class SS extends Enemy {
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
currentFrame++;
if (currentFrame == 1) {
onPlaySound(attackSoundId);
onPlaySound(attackSound);
if (tryRollRangedHit(
distance: distance,
playerPosition: playerPosition,
@@ -140,7 +142,7 @@ class SS extends Enemy {
if (math.Random().nextDouble() > 0.5) {
// 50% chance to burst
currentFrame = 1;
onPlaySound(attackSoundId);
onPlaySound(attackSound);
if (tryRollRangedHit(
distance: distance,
playerPosition: playerPosition,
@@ -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) {
@@ -15,6 +15,7 @@ typedef EntitySpawner =
double y,
Difficulty difficulty, {
bool isSharewareMode,
AssetRegistry? registry,
});
/// The central factory for instantiating all dynamic objects in a Wolf3D level.
@@ -52,6 +53,7 @@ abstract class EntityRegistry {
Difficulty difficulty,
int maxSprites, {
bool isSharewareMode = false,
AssetRegistry? registry,
}) {
if (objId == 0) return null;
@@ -62,6 +64,7 @@ abstract class EntityRegistry {
y,
difficulty,
isSharewareMode: isSharewareMode,
registry: registry,
);
if (entity != null) return entity;
}
@@ -0,0 +1,3 @@
library;
export 'cli_game_loop_stub.dart' if (dart.library.io) 'cli_game_loop_io.dart';
@@ -17,6 +17,8 @@ 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(
@@ -24,11 +26,10 @@ class CliGameLoop {
'engine.input',
'CliGameLoop requires a CliInput instance.',
),
primaryRenderer = AsciiRenderer(
primaryRenderer = SixelRenderer(),
secondaryRenderer = AsciiRenderer(
mode: AsciiRendererMode.terminalAnsi,
),
secondaryRenderer = SixelRenderer() {
) {
_renderer = primaryRenderer;
}
@@ -37,6 +38,8 @@ class CliGameLoop {
final CliRendererBackend secondaryRenderer;
final CliInput input;
final void Function(int code) onExit;
final RendererSettingsPersistence? persistence;
final WolfRendererSettings? initialSettings;
final Stopwatch _stopwatch = Stopwatch();
final Stream<List<int>> _stdinStream = stdin.asBroadcastStream();
@@ -44,6 +47,7 @@ class CliGameLoop {
StreamSubscription<List<int>>? _stdinSubscription;
Timer? _timer;
bool _isRunning = false;
bool _isSixelAvailable = false;
Duration _lastTick = Duration.zero;
/// Starts terminal probing, enters raw input mode, and begins the frame timer.
@@ -57,8 +61,33 @@ class CliGameLoop {
sixel.isSixelSupported = await SixelRenderer.checkTerminalSixelSupport(
inputStream: _stdinStream,
);
_isSixelAvailable = sixel.isSixelSupported;
} else {
_isSixelAvailable = false;
}
final Set<WolfRendererMode> supportedModes = <WolfRendererMode>{
WolfRendererMode.ascii,
if (_isSixelAvailable) WolfRendererMode.sixel,
};
engine.setRendererCapabilities(
WolfRendererCapabilities(
supportedModes: supportedModes,
supportsAsciiThemes: true,
supportsFpsCounter: true,
),
);
if (initialSettings != null) {
engine.updateRendererSettings(initialSettings!);
} else if (_isSixelAvailable) {
engine.updateRendererSettings(
engine.rendererSettings.copyWith(mode: WolfRendererMode.sixel),
);
}
_syncRendererFromEngine();
if (stdin.hasTerminal) {
try {
stdin.echoMode = false;
@@ -68,7 +97,8 @@ class CliGameLoop {
}
}
stdout.write('\x1b[?25l\x1b[2J');
// Disable Sixel scrolling mode so frames overwrite in-place.
stdout.write('\x1b[?80l\x1b[?25l\x1b[2J');
_stdinSubscription = _stdinStream.listen(_handleInput);
_stopwatch.start();
@@ -101,7 +131,8 @@ class CliGameLoop {
}
if (stdout.hasTerminal) {
stdout.write('\x1b[0m\x1b[?25h');
// Restore scrolling Sixel mode and cursor visibility.
stdout.write('\x1b[0m\x1b[?80h\x1b[?25h');
}
_isRunning = false;
@@ -115,42 +146,56 @@ class CliGameLoop {
return;
}
if (bytes.contains(9)) {
// Tab swaps between renderers so renderer debugging stays available
// without restarting the process.
_renderer = identical(_renderer, secondaryRenderer)
? primaryRenderer
: secondaryRenderer;
if (input.matchesRendererToggleShortcut(bytes)) {
engine.cycleRendererMode();
_syncRendererFromEngine();
unawaited(persistence?.save(engine.rendererSettings));
stdout.write('\x1b[2J\x1b[H');
return;
}
if (bytes.contains(116) || bytes.contains(84)) {
_cycleAsciiTheme();
if (input.matchesAsciiThemeCycleShortcut(bytes)) {
engine.cycleAsciiTheme();
_syncRendererFromEngine();
unawaited(persistence?.save(engine.rendererSettings));
return;
}
if (input.matchesFpsToggleShortcut(bytes)) {
engine.toggleFpsCounter();
_syncRendererFromEngine();
unawaited(persistence?.save(engine.rendererSettings));
return;
}
input.handleKey(bytes);
}
void _cycleAsciiTheme() {
final List<AsciiRenderer> asciiRenderers = <AsciiRenderer>[
if (primaryRenderer is AsciiRenderer) primaryRenderer as AsciiRenderer,
if (secondaryRenderer is AsciiRenderer)
secondaryRenderer as AsciiRenderer,
];
if (asciiRenderers.isEmpty) {
return;
void _syncRendererFromEngine() {
final CliRendererBackend previousRenderer = _renderer;
final WolfRendererMode mode = engine.rendererSettings.mode;
if (mode == WolfRendererMode.sixel && _isSixelAvailable) {
_renderer = primaryRenderer;
} else {
_renderer = secondaryRenderer;
}
final AsciiTheme nextTheme = AsciiThemes.nextOf(
asciiRenderers.first.activeTheme,
);
for (final renderer in asciiRenderers) {
renderer.activeTheme = nextTheme;
final AsciiTheme theme =
engine.rendererSettings.asciiThemeId ==
WolfRendererSettings.asciiThemeQuadrant
? AsciiThemes.quadrant
: AsciiThemes.blocks;
if (primaryRenderer is AsciiRenderer) {
(primaryRenderer as AsciiRenderer).activeTheme = theme;
}
if (secondaryRenderer is AsciiRenderer) {
(secondaryRenderer as AsciiRenderer).activeTheme = theme;
}
if (stdout.hasTerminal) {
engine.showFpsCounter = engine.rendererSettings.fpsCounterEnabled;
if (!identical(previousRenderer, _renderer) && stdout.hasTerminal) {
// Clear stale frame content when switching backend output modes.
stdout.write('\x1b[2J\x1b[H');
}
}
@@ -160,6 +205,9 @@ class CliGameLoop {
return;
}
// Apply renderer changes made via in-game menus or settings updates.
_syncRendererFromEngine();
if (stdout.hasTerminal) {
final int cols = stdout.terminalColumns;
final int rows = stdout.terminalLines;
@@ -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),
);
}
@@ -6,6 +6,70 @@ import 'package:wolf_3d_dart/wolf_3d_entities.dart';
/// Buffers one-frame terminal key presses for consumption by the engine loop.
class CliInput extends Wolf3dInput {
/// Keyboard shortcut used by the CLI host to cycle renderer modes.
String rendererToggleKey = 'r';
/// 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);
/// Human-friendly label for [asciiThemeCycleKey] shown in CLI hints.
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);
/// Returns true when [bytes] triggers the ASCII-theme shortcut.
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) {
return 'KEY';
}
if (trimmed == ' ') {
return 'SPACE';
}
return trimmed.toUpperCase();
}
bool _matchesShortcut(List<int> bytes, String key) {
final String trimmed = key.trim();
if (trimmed.isEmpty) {
return false;
}
if (trimmed == ' ') {
return bytes.length == 1 && bytes[0] == 32;
}
if (bytes.length != 1) {
return false;
}
final int expected = trimmed.codeUnitAt(0);
final int actual = bytes[0];
return actual == expected ||
actual ==
(expected >= 97 && expected <= 122 ? expected - 32 : expected) ||
actual == (expected >= 65 && expected <= 90 ? expected + 32 : expected);
}
// Raw stdin arrives asynchronously, so presses are staged here until the
// next engine frame snapshots them into the active state.
bool _pForward = false;
@@ -15,6 +79,7 @@ class CliInput extends Wolf3dInput {
bool _pFire = false;
bool _pInteract = false;
bool _pBack = false;
bool _pMapToggle = false;
WeaponType? _pWeapon;
/// Queues a raw terminal key sequence for the next engine frame.
@@ -40,6 +105,12 @@ class CliInput extends Wolf3dInput {
return;
}
// Tab toggles fullscreen map overlay.
if (bytes.length == 1 && bytes[0] == 9) {
_pMapToggle = true;
return;
}
String char = String.fromCharCodes(bytes).toLowerCase();
if (char == 'w') _pForward = true;
@@ -65,6 +136,7 @@ class CliInput extends Wolf3dInput {
isMovingBackward = _pBackward;
isTurningLeft = _pLeft;
isTurningRight = _pRight;
isMapToggle = _pMapToggle;
isFiring = _pFire;
isInteracting = _pInteract;
isBack = _pBack;
@@ -73,6 +145,7 @@ class CliInput extends Wolf3dInput {
// Reset the pending buffer so each keypress behaves like a frame impulse.
_pForward = _pBackward = _pLeft = _pRight = _pFire = _pInteract = false;
_pBack = false;
_pMapToggle = false;
_pWeapon = null;
}
}
@@ -6,6 +6,7 @@ abstract class Wolf3dInput {
bool isMovingBackward = false;
bool isTurningLeft = false;
bool isTurningRight = false;
bool isMapToggle = false;
bool isInteracting = false;
bool isBack = false;
double? menuTapX;
@@ -23,6 +24,7 @@ abstract class Wolf3dInput {
isMovingBackward: isMovingBackward,
isTurningLeft: isTurningLeft,
isTurningRight: isTurningRight,
isMapToggle: isMapToggle,
isFiring: isFiring,
isInteracting: isInteracting,
isBack: isBack,
@@ -37,6 +39,7 @@ enum WolfInputAction {
backward,
turnLeft,
turnRight,
mapToggle,
fire,
interact,
back,
@@ -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 }

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