Compare commits
78 Commits
536a10d99e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
6eb28ffcac
|
|||
|
b8917272f7
|
|||
|
d63d742695
|
|||
|
d393ca98ec
|
|||
|
5c309c2240
|
|||
|
9f3651b122
|
|||
|
ccc23e728c
|
|||
|
62dca47d1d
|
|||
|
a08af99b6f
|
|||
|
8a9be477e4
|
|||
|
ce4dd8d61d
|
|||
|
b980174905
|
|||
|
3114700683
|
|||
|
6784d2dd16
|
|||
|
6158a92fb0
|
|||
|
1394c20134
|
|||
|
6441592534
|
|||
|
88050dbc7d
|
|||
|
70b4fc3fe0
|
|||
|
569a3386a8
|
|||
|
5ef59d9980
|
|||
|
5a2681e89b
|
|||
|
cbe2633ceb
|
|||
|
3a7ec50abf
|
|||
|
ae3b0deb04
|
|||
|
a7353e45b3
|
|||
|
f4d6db2db0
|
|||
|
fdf84b3a9d
|
|||
|
ea6825341e
|
|||
|
b88475882b
|
|||
|
26c738b702
|
|||
|
dcfb2e8e02
|
|||
|
c4c8e4149a
|
|||
|
f05a861998
|
|||
|
3b1f8c80d1
|
|||
|
1ed63d5f9b
|
|||
|
85fddd3df5
|
|||
|
de8bff1964
|
|||
|
db06f5f5cb
|
|||
|
1a93b7d4a2
|
|||
|
7cb3f25c74
|
|||
|
a66ccf52c5
|
|||
|
827b8c779e
|
|||
|
400ce4f680
|
|||
|
8ed460b03e
|
|||
|
604923618a
|
|||
|
7941c2902c
|
|||
|
b0f6e865b4
|
|||
|
85583214ba
|
|||
|
35cfe8d88c
|
|||
|
0c74abcb7e
|
|||
|
1165e0bc44
|
|||
|
d63b316f1b
|
|||
|
a84c677845
|
|||
|
528d6276b1
|
|||
|
3270338f44
|
|||
|
45e5302eac
|
|||
|
2598218a4d
|
|||
|
1e5222368a
|
|||
|
5e19f3c098
|
|||
|
4bac9d519b
|
|||
|
10417d26ba
|
|||
|
8cca66e966
|
|||
|
03dd871a46
|
|||
|
ed1e480555
|
|||
|
4d5b30f007
|
|||
|
297f6f0260
|
|||
|
27e15e60db
|
|||
|
436f498778
|
|||
|
b23c02f716
|
|||
|
9733516693
|
|||
|
862191d245
|
|||
|
c81eb6750d
|
|||
|
10eaef9690
|
|||
|
abca679a99
|
|||
|
cbbcd3223a
|
|||
|
e060aef3f1
|
|||
|
9b053e1c02
|
Vendored
+4
@@ -15,6 +15,10 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"type": "dart",
|
"type": "dart",
|
||||||
"program": "apps/wolf_3d_cli/bin/main.dart",
|
"program": "apps/wolf_3d_cli/bin/main.dart",
|
||||||
|
"args": [
|
||||||
|
"--data-directory",
|
||||||
|
"${workspaceFolder}/packages/wolf_3d_assets/assets"
|
||||||
|
],
|
||||||
"console": "externalTerminal"
|
"console": "externalTerminal"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Vendored
+5
-1
@@ -1,3 +1,7 @@
|
|||||||
{
|
{
|
||||||
"cmake.ignoreCMakeListsMissing": true
|
"cmake.ignoreCMakeListsMissing": true,
|
||||||
|
"chat.tools.terminal.autoApprove": {
|
||||||
|
"flutter": true,
|
||||||
|
"dart": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,85 @@
|
|||||||
# wolf_dart
|
# 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`
|
`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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -6,10 +6,12 @@ library;
|
|||||||
|
|
||||||
import 'dart:io';
|
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.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_host.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_input.dart';
|
import 'package:wolf_3d_dart/wolf_3d_input.dart';
|
||||||
|
|
||||||
/// Restores terminal state before exiting the process with [code].
|
/// Restores terminal state before exiting the process with [code].
|
||||||
@@ -20,39 +22,79 @@ void exitCleanly(int code) {
|
|||||||
exit(code);
|
exit(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Launches the CLI renderer against the bundled retail asset set.
|
/// Launches the CLI renderer using discoverable or user-provided game data.
|
||||||
void main() async {
|
void main(List<String> arguments) async {
|
||||||
stdout.write("Discovering game data...");
|
final argParser = ArgParser()
|
||||||
// Resolve the asset package relative to this executable so the CLI can run
|
..addOption(
|
||||||
// from the repo without additional configuration.
|
'data-directory',
|
||||||
final scriptUri = Platform.script;
|
abbr: 'd',
|
||||||
|
valueHelp: 'path',
|
||||||
final targetUri = scriptUri.resolve(
|
help: 'Directory containing Wolf3D data files.',
|
||||||
'../../../packages/wolf_3d_assets/assets/retail',
|
)
|
||||||
|
..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(
|
final availableGames = await WolfensteinLoader.discover(
|
||||||
directoryPath: targetPath,
|
directoryPath: dataDirectory,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (availableGames.isEmpty) {
|
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(
|
stderr.writeln(
|
||||||
'Please provide valid game data files before starting the CLI host.',
|
'Please provide valid game data files before starting the CLI host.',
|
||||||
);
|
);
|
||||||
|
}
|
||||||
exitCleanly(1);
|
exitCleanly(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
CliGameLoop? gameLoop;
|
CliGameLoop? gameLoop;
|
||||||
|
late final WolfEngine engine;
|
||||||
|
|
||||||
void stopAndExit(int code) {
|
void stopAndExit(int code) {
|
||||||
gameLoop?.stop();
|
gameLoop?.stop();
|
||||||
|
engine.audio.dispose();
|
||||||
exitCleanly(code);
|
exitCleanly(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
final engine = WolfEngine(
|
engine = WolfEngine(
|
||||||
availableGames: availableGames.values.toList(growable: false),
|
availableGames: availableGames.values.toList(growable: false),
|
||||||
startingEpisode: 0,
|
startingEpisode: 0,
|
||||||
frameBuffer: FrameBuffer(
|
frameBuffer: FrameBuffer(
|
||||||
@@ -60,14 +102,25 @@ void main() async {
|
|||||||
stdout.terminalLines,
|
stdout.terminalLines,
|
||||||
),
|
),
|
||||||
input: CliInput(),
|
input: CliInput(),
|
||||||
|
engineAudio: NativeSubprocessAudio(),
|
||||||
onGameWon: () => stopAndExit(0),
|
onGameWon: () => stopAndExit(0),
|
||||||
|
onQuit: () => stopAndExit(0),
|
||||||
|
saveGamePersistence: DefaultSaveGamePersistence(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await engine.audio.init();
|
||||||
engine.init();
|
engine.init();
|
||||||
|
|
||||||
|
final persistence = DefaultRendererSettingsPersistence(
|
||||||
|
hostKey: rendererSettingsHostCli,
|
||||||
|
);
|
||||||
|
final WolfRendererSettings? saved = await persistence.load();
|
||||||
|
|
||||||
gameLoop = CliGameLoop(
|
gameLoop = CliGameLoop(
|
||||||
engine: engine,
|
engine: engine,
|
||||||
onExit: stopAndExit,
|
onExit: stopAndExit,
|
||||||
|
persistence: persistence,
|
||||||
|
initialSettings: saved,
|
||||||
);
|
);
|
||||||
|
|
||||||
await gameLoop.start();
|
await gameLoop.start();
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ environment:
|
|||||||
resolution: workspace
|
resolution: workspace
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
args: ^2.7.0
|
||||||
wolf_3d_dart:
|
wolf_3d_dart:
|
||||||
wolf_3d_assets:
|
|
||||||
|
|||||||
@@ -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 won’t 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;
|
||||||
|
}
|
||||||
@@ -1,80 +1,27 @@
|
|||||||
/// Flutter entry point for the GUI host application.
|
import 'package:flutter/foundation.dart';
|
||||||
///
|
|
||||||
/// The GUI bootstraps bundled and discoverable game data through [Wolf3d]
|
|
||||||
/// before presenting the game-selection flow.
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
|
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
|
||||||
import '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.
|
/// Creates the application shell after loading available Wolf3D data sets.
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
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(
|
runApp(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
home: wolf3d.availableGames.isEmpty
|
home: Wolf3dGuiApp(engine: wolf3d),
|
||||||
? const _NoGameDataScreen()
|
|
||||||
: GameScreen(wolf3d: 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,212 +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();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@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]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <audioplayers_linux/audioplayers_linux_plugin.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) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
|
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
|
||||||
audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar);
|
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
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
audioplayers_linux
|
audioplayers_linux
|
||||||
|
file_selector_linux
|
||||||
|
screen_retriever_linux
|
||||||
|
window_manager
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ environment:
|
|||||||
resolution: workspace
|
resolution: workspace
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
crypto: ^3.0.6
|
||||||
|
file_selector: ^1.0.3
|
||||||
|
wolf_3d_flutter:
|
||||||
wolf_3d_dart:
|
wolf_3d_dart:
|
||||||
wolf_3d_renderer: any
|
wolf_3d_assets:
|
||||||
wolf_3d_flutter: any
|
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
path: ^1.9.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||||
Binary file not shown.
Binary file not shown.
@@ -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.
BIN
Binary file not shown.
Binary file not shown.
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.
|
||||||
@@ -13,3 +13,4 @@ flutter:
|
|||||||
assets:
|
assets:
|
||||||
- assets/retail/
|
- assets/retail/
|
||||||
- assets/shareware/
|
- assets/shareware/
|
||||||
|
- assets/sod/shareware/
|
||||||
|
|||||||
@@ -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).
|
/// v1.4 Retail release (found on GOG and Steam).
|
||||||
version14Retail('b8ff4997461bafa5ef2a94c11f9de001'),
|
version14Retail('b8ff4997461bafa5ef2a94c11f9de001'),
|
||||||
|
|
||||||
|
/// Spear of Destiny Shareware release (VSWAP.SDM).
|
||||||
|
spearOfDestinyShareware('35afda760bea840b547d686a930322dc'),
|
||||||
|
|
||||||
/// Default state if the file hash is unrecognized.
|
/// Default state if the file hash is unrecognized.
|
||||||
unknown('unknown')
|
unknown('unknown')
|
||||||
;
|
;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import 'dart:developer';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
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/src/data/wl_parser.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_data_types.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();
|
final vswapBytes = await vswapFile.readAsBytes();
|
||||||
|
|
||||||
// 2. Generate Checksum and Resolve Identity
|
// 2. Generate Checksum and Resolve Identity
|
||||||
final hash = md5.convert(vswapBytes).toString();
|
final hash = md5HexLower(vswapBytes);
|
||||||
final identity = DataVersion.fromChecksum(hash);
|
final identity = DataVersion.fromChecksum(hash);
|
||||||
|
|
||||||
log('--- Found ${version.name} ---');
|
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:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:crypto/crypto.dart' show md5;
|
|
||||||
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||||
|
|
||||||
|
import 'md5_hash.dart';
|
||||||
|
|
||||||
/// The primary parser for Wolfenstein 3D data formats.
|
/// The primary parser for Wolfenstein 3D data formats.
|
||||||
///
|
///
|
||||||
/// This abstract class serves as the extraction and decompression engine for
|
/// 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 {
|
abstract class WLParser {
|
||||||
static const int _areaTileBase = 107;
|
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.
|
/// Asynchronously discovers the game version and loads all necessary files.
|
||||||
///
|
///
|
||||||
/// Provide a [fileFetcher] callback (e.g., Flutter's `rootBundle.load` or
|
/// Provide a [fileFetcher] callback (e.g., Flutter's `rootBundle.load` or
|
||||||
@@ -59,7 +46,7 @@ abstract class WLParser {
|
|||||||
vswap.offsetInBytes,
|
vswap.offsetInBytes,
|
||||||
vswap.lengthInBytes,
|
vswap.lengthInBytes,
|
||||||
);
|
);
|
||||||
final vswapHash = md5.convert(vswapBytes).toString();
|
final vswapHash = md5HexLower(vswapBytes);
|
||||||
final dataIdentity = DataVersion.fromChecksum(vswapHash);
|
final dataIdentity = DataVersion.fromChecksum(vswapHash);
|
||||||
|
|
||||||
// 3. Load other required files
|
// 3. Load other required files
|
||||||
@@ -126,7 +113,7 @@ abstract class WLParser {
|
|||||||
vswap.offsetInBytes,
|
vswap.offsetInBytes,
|
||||||
vswap.lengthInBytes,
|
vswap.lengthInBytes,
|
||||||
);
|
);
|
||||||
final vswapHash = md5.convert(vswapBytes).toString();
|
final vswapHash = md5HexLower(vswapBytes);
|
||||||
final dataIdentity = DataVersion.fromChecksum(vswapHash);
|
final dataIdentity = DataVersion.fromChecksum(vswapHash);
|
||||||
|
|
||||||
ByteData gameMapsData;
|
ByteData gameMapsData;
|
||||||
@@ -182,8 +169,6 @@ abstract class WLParser {
|
|||||||
required DataVersion dataIdentity,
|
required DataVersion dataIdentity,
|
||||||
AssetRegistry? registryOverride,
|
AssetRegistry? registryOverride,
|
||||||
}) {
|
}) {
|
||||||
final isShareware = version == GameVersion.shareware;
|
|
||||||
|
|
||||||
final audio = parseAudio(audioHed, audioT, version);
|
final audio = parseAudio(audioHed, audioT, version);
|
||||||
final vgaImages = parseVgaImages(vgaDict, vgaHead, vgaGraph);
|
final vgaImages = parseVgaImages(vgaDict, vgaHead, vgaGraph);
|
||||||
|
|
||||||
@@ -204,7 +189,7 @@ abstract class WLParser {
|
|||||||
walls: parseWalls(vswap),
|
walls: parseWalls(vswap),
|
||||||
sprites: parseSprites(vswap),
|
sprites: parseSprites(vswap),
|
||||||
sounds: parseSounds(vswap).map((bytes) => PcmSound(bytes)).toList(),
|
sounds: parseSounds(vswap).map((bytes) => PcmSound(bytes)).toList(),
|
||||||
episodes: parseEpisodes(mapHead, gameMaps, isShareware: isShareware),
|
episodes: parseEpisodes(mapHead, gameMaps, version: version),
|
||||||
vgaImages: vgaImages,
|
vgaImages: vgaImages,
|
||||||
adLibSounds: audio.adLib,
|
adLibSounds: audio.adLib,
|
||||||
music: audio.music,
|
music: audio.music,
|
||||||
@@ -438,13 +423,12 @@ abstract class WLParser {
|
|||||||
static List<Episode> parseEpisodes(
|
static List<Episode> parseEpisodes(
|
||||||
ByteData mapHead,
|
ByteData mapHead,
|
||||||
ByteData gameMaps, {
|
ByteData gameMaps, {
|
||||||
bool isShareware = true,
|
required GameVersion version,
|
||||||
}) {
|
}) {
|
||||||
List<WolfLevel> allLevels = [];
|
List<WolfLevel> allLevels = [];
|
||||||
int rlewTag = mapHead.getUint16(0, Endian.little);
|
int rlewTag = mapHead.getUint16(0, Endian.little);
|
||||||
|
|
||||||
// Select the correct music map based on the version
|
final isShareware = version == GameVersion.shareware;
|
||||||
final activeMusicMap = isShareware ? _sharewareMusicMap : _retailMusicMap;
|
|
||||||
final episodeNames = isShareware
|
final episodeNames = isShareware
|
||||||
? _sharewareEpisodeNames
|
? _sharewareEpisodeNames
|
||||||
: _retailEpisodeNames;
|
: _retailEpisodeNames;
|
||||||
@@ -510,9 +494,9 @@ abstract class WLParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- ASSIGN MUSIC ---
|
// --- ASSIGN MUSIC ---
|
||||||
int trackIndex = (i < activeMusicMap.length)
|
final episodeIndex = i ~/ 10;
|
||||||
? activeMusicMap[i]
|
final levelIndex = i % 10;
|
||||||
: activeMusicMap[i % activeMusicMap.length];
|
final levelMusic = Music.levelFor(version, episodeIndex, levelIndex);
|
||||||
|
|
||||||
allLevels.add(
|
allLevels.add(
|
||||||
WolfLevel(
|
WolfLevel(
|
||||||
@@ -520,7 +504,7 @@ abstract class WLParser {
|
|||||||
wallGrid: wallGrid,
|
wallGrid: wallGrid,
|
||||||
objectGrid: objectGrid,
|
objectGrid: objectGrid,
|
||||||
areaGrid: areaGrid,
|
areaGrid: areaGrid,
|
||||||
musicIndex: trackIndex,
|
music: levelMusic,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:wolf_3d_dart/src/data/io/discovery_stub.dart'
|
||||||
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
if (dart.library.io) 'package:wolf_3d_dart/src/data/io/discovery_io.dart'
|
||||||
|
|
||||||
import 'io/discovery_stub.dart'
|
|
||||||
if (dart.library.io) 'io/discovery_io.dart'
|
|
||||||
as platform;
|
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.
|
/// The main entry point for loading Wolfenstein 3D data.
|
||||||
///
|
///
|
||||||
@@ -76,7 +75,7 @@ class WolfensteinLoader {
|
|||||||
vswap.offsetInBytes,
|
vswap.offsetInBytes,
|
||||||
vswap.lengthInBytes,
|
vswap.lengthInBytes,
|
||||||
);
|
);
|
||||||
final hash = md5.convert(vswapBytes).toString();
|
final hash = md5HexLower(vswapBytes);
|
||||||
final dataIdentity = DataVersion.fromChecksum(hash);
|
final dataIdentity = DataVersion.fromChecksum(hash);
|
||||||
|
|
||||||
// 3. Pass-through to parser with the detected identity and optional override.
|
// 3. Pass-through to parser with the detected identity and optional override.
|
||||||
|
|||||||
@@ -262,6 +262,19 @@ abstract class ColorPalette {
|
|||||||
0xFF890099,
|
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) {
|
static int findClosestPaletteIndex(int argb) {
|
||||||
final int targetR = (argb >> 16) & 0xFF;
|
final int targetR = (argb >> 16) & 0xFF;
|
||||||
final int targetG = (argb >> 8) & 0xFF;
|
final int targetG = (argb >> 8) & 0xFF;
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
/// Supported game releases and their associated file extensions.
|
/// Supported game releases and their associated file extensions.
|
||||||
enum GameVersion {
|
enum GameVersion {
|
||||||
/// Wolfenstein 3D Shareware (.WL1)
|
/// Wolfenstein 3D Shareware (.WL1)
|
||||||
shareware("WL1"),
|
shareware("WL1", "Wolf3D Shareware"),
|
||||||
|
|
||||||
/// Wolfenstein 3D Full Retail (.WL6)
|
/// Wolfenstein 3D Full Retail (.WL6)
|
||||||
retail("WL6"),
|
retail("WL6", "Wolf3D Retail"),
|
||||||
|
|
||||||
/// Spear of Destiny Full Version (.SOD)
|
/// Spear of Destiny Full Version (.SOD)
|
||||||
spearOfDestiny("SOD"),
|
spearOfDestiny("SOD", "Spear of Destiny"),
|
||||||
|
|
||||||
/// Spear of Destiny Demo (.SDM)
|
/// Spear of Destiny Demo (.SDM)
|
||||||
spearOfDestinyDemo("SDM")
|
spearOfDestinyDemo("SDM", "Spear of Destiny Demo")
|
||||||
;
|
;
|
||||||
|
|
||||||
final String fileExtension;
|
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);
|
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.
|
/// zero-based and correspond to original AREATILE-derived sectors.
|
||||||
final SpriteMap areaGrid;
|
final SpriteMap areaGrid;
|
||||||
|
|
||||||
/// The index of the [ImfMusic] track to play while this level is active.
|
/// The [Music] track to play while this level is active.
|
||||||
final int musicIndex;
|
final Music music;
|
||||||
|
|
||||||
const WolfLevel({
|
const WolfLevel({
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.wallGrid,
|
required this.wallGrid,
|
||||||
required this.objectGrid,
|
required this.objectGrid,
|
||||||
required this.areaGrid,
|
required this.areaGrid,
|
||||||
required this.musicIndex,
|
required this.music,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class WolfensteinData {
|
|||||||
///
|
///
|
||||||
/// Access the five sub-modules via:
|
/// Access the five sub-modules via:
|
||||||
/// ```dart
|
/// ```dart
|
||||||
/// data.registry.sfx.resolve(SfxKey.pistolFire)
|
/// data.registry.sfx.resolve(SoundEffect.pistolFire)
|
||||||
/// data.registry.music.musicForLevel(episode, level)
|
/// data.registry.music.musicForLevel(episode, level)
|
||||||
/// data.registry.entities.resolve(EntityKey.guard)
|
/// data.registry.entities.resolve(EntityKey.guard)
|
||||||
/// data.registry.hud.faceForHealth(player.health)
|
/// 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;
|
WolfensteinData? activeGame;
|
||||||
Future<void> debugSoundTest();
|
Future<void> debugSoundTest();
|
||||||
void playMenuMusic();
|
void playMenuMusic();
|
||||||
void playLevelMusic(WolfLevel level);
|
void playLevelMusic(Music music);
|
||||||
void stopMusic();
|
void stopMusic();
|
||||||
void playSoundEffect(int sfxId);
|
Future<void> stopAllAudio();
|
||||||
|
void playSoundEffect(SoundEffect effect);
|
||||||
|
void playSoundEffectId(int sfxId);
|
||||||
Future<void> init();
|
Future<void> init();
|
||||||
void dispose();
|
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 isMovingBackward;
|
||||||
final bool isTurningLeft;
|
final bool isTurningLeft;
|
||||||
final bool isTurningRight;
|
final bool isTurningRight;
|
||||||
|
final bool isMapToggle;
|
||||||
final bool isFiring;
|
final bool isFiring;
|
||||||
final bool isInteracting;
|
final bool isInteracting;
|
||||||
final bool isBack;
|
final bool isBack;
|
||||||
@@ -18,6 +19,7 @@ class EngineInput {
|
|||||||
this.isMovingBackward = false,
|
this.isMovingBackward = false,
|
||||||
this.isTurningLeft = false,
|
this.isTurningLeft = false,
|
||||||
this.isTurningRight = false,
|
this.isTurningRight = false,
|
||||||
|
this.isMapToggle = false,
|
||||||
this.isFiring = false,
|
this.isFiring = false,
|
||||||
this.isInteracting = false,
|
this.isInteracting = false,
|
||||||
this.isBack = false,
|
this.isBack = false,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class DoorManager {
|
|||||||
|
|
||||||
/// Callback used to trigger sound effects without tight coupling
|
/// Callback used to trigger sound effects without tight coupling
|
||||||
/// to a specific audio engine implementation.
|
/// 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);
|
static int _key(int x, int y) => ((y & 0xFFFF) << 16) | (x & 0xFFFF);
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ class DoorManager {
|
|||||||
final newState = door.update(elapsed.inMilliseconds);
|
final newState = door.update(elapsed.inMilliseconds);
|
||||||
|
|
||||||
if (newState == DoorState.closing) {
|
if (newState == DoorState.closing) {
|
||||||
onPlaySound(WolfSound.closeDoor);
|
onPlaySound(SoundEffect.closeDoor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@ class DoorManager {
|
|||||||
|
|
||||||
final int key = _key(targetX, targetY);
|
final int key = _key(targetX, targetY);
|
||||||
if (doors.containsKey(key) && doors[key]!.interact()) {
|
if (doors.containsKey(key) && doors[key]!.interact()) {
|
||||||
onPlaySound(WolfSound.openDoor);
|
onPlaySound(SoundEffect.openDoor);
|
||||||
log('[DEBUG] Player opened door at ($targetX, $targetY)');
|
log('[DEBUG] Player opened door at ($targetX, $targetY)');
|
||||||
return (x: targetX, y: targetY);
|
return (x: targetX, y: targetY);
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@ class DoorManager {
|
|||||||
// AI only interacts if the door is currently fully closed (offset == 0).
|
// AI only interacts if the door is currently fully closed (offset == 0).
|
||||||
if (doors.containsKey(key) && doors[key]!.offset == 0.0) {
|
if (doors.containsKey(key) && doors[key]!.offset == 0.0) {
|
||||||
if (doors[key]!.interact()) {
|
if (doors[key]!.interact()) {
|
||||||
onPlaySound(WolfSound.openDoor);
|
onPlaySound(SoundEffect.openDoor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class PushwallManager {
|
|||||||
PushwallManager({this.onPlaySound});
|
PushwallManager({this.onPlaySound});
|
||||||
|
|
||||||
/// Optional callback used to emit audio cues when pushwalls activate.
|
/// 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 = {};
|
final Map<String, Pushwall> pushwalls = {};
|
||||||
Pushwall? activePushwall;
|
Pushwall? activePushwall;
|
||||||
@@ -127,7 +127,7 @@ class PushwallManager {
|
|||||||
int checkY = targetY + pw.dirY;
|
int checkY = targetY + pw.dirY;
|
||||||
if (wallGrid[checkY][checkX] == 0) {
|
if (wallGrid[checkY][checkX] == 0) {
|
||||||
activePushwall = pw;
|
activePushwall = pw;
|
||||||
onPlaySound?.call(WolfSound.pushWall);
|
onPlaySound?.call(SoundEffect.pushWall);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'dart:math' as math;
|
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_data_types.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_entities.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
|
double damageFlash = 0.0; // 0.0 is none, 1.0 is maximum red
|
||||||
final double damageFlashFadeSpeed = 0.05; // How fast it fades per tick
|
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
|
// Inventory
|
||||||
bool hasGoldKey = false;
|
bool hasGoldKey = false;
|
||||||
bool hasSilverKey = false;
|
bool hasSilverKey = false;
|
||||||
@@ -43,16 +63,132 @@ class Player {
|
|||||||
// 0.0 is resting, 500.0 is fully off-screen
|
// 0.0 is resting, 500.0 is fully off-screen
|
||||||
double weaponAnimOffset = 0.0;
|
double weaponAnimOffset = 0.0;
|
||||||
|
|
||||||
// How fast the weapon drops/raises per tick
|
static const double _weaponSwitchTravel = 500.0;
|
||||||
final double switchSpeed = 30.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}) {
|
Player({required this.x, required this.y, required this.angle}) {
|
||||||
currentWeapon = weapons[WeaponType.pistol]!;
|
currentWeapon = weapons[WeaponType.pistol]!;
|
||||||
|
setHudFaceAnimationSeed(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper getter to interface with the RaycasterPainter
|
// Helper getter to interface with the RaycasterPainter
|
||||||
Coordinate2D get position => Coordinate2D(x, y);
|
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 ---
|
// --- General Update ---
|
||||||
|
|
||||||
void tick(Duration elapsed) {
|
void tick(Duration elapsed) {
|
||||||
@@ -62,12 +198,40 @@ class Player {
|
|||||||
damageFlash = math.max(0.0, damageFlash - damageFlashFadeSpeed);
|
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 ---
|
// --- 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 (switchState == WeaponSwitchState.lowering) {
|
||||||
// If the map doesn't contain the pending weapon, stop immediately
|
// If the map doesn't contain the pending weapon, stop immediately
|
||||||
if (weapons[pendingWeaponType] == null) {
|
if (weapons[pendingWeaponType] == null) {
|
||||||
@@ -75,9 +239,9 @@ class Player {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
weaponAnimOffset += switchSpeed;
|
weaponAnimOffset += delta;
|
||||||
if (weaponAnimOffset >= 500.0) {
|
if (weaponAnimOffset >= _weaponSwitchTravel) {
|
||||||
weaponAnimOffset = 500.0;
|
weaponAnimOffset = _weaponSwitchTravel;
|
||||||
|
|
||||||
// We already know it's not null now, but we can keep the
|
// We already know it's not null now, but we can keep the
|
||||||
// fallback to pistol just to be extra safe.
|
// fallback to pistol just to be extra safe.
|
||||||
@@ -86,7 +250,7 @@ class Player {
|
|||||||
switchState = WeaponSwitchState.raising;
|
switchState = WeaponSwitchState.raising;
|
||||||
}
|
}
|
||||||
} else if (switchState == WeaponSwitchState.raising) {
|
} else if (switchState == WeaponSwitchState.raising) {
|
||||||
weaponAnimOffset -= switchSpeed;
|
weaponAnimOffset -= delta;
|
||||||
if (weaponAnimOffset <= 0) {
|
if (weaponAnimOffset <= 0) {
|
||||||
weaponAnimOffset = 0.0;
|
weaponAnimOffset = 0.0;
|
||||||
switchState = WeaponSwitchState.idle;
|
switchState = WeaponSwitchState.idle;
|
||||||
@@ -109,12 +273,14 @@ class Player {
|
|||||||
|
|
||||||
// --- Health & Damage ---
|
// --- Health & Damage ---
|
||||||
|
|
||||||
void takeDamage(int damage) {
|
void takeDamage(int damage, {EnemyType? attackerType}) {
|
||||||
health = math.max(0, health - damage);
|
health = math.max(0, health - damage);
|
||||||
|
|
||||||
// Spike the damage flash based on how much damage was taken
|
// 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
|
// 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));
|
damageFlash = math.min(1.0, damageFlash + (damage * 0.05));
|
||||||
|
_chaingunPickupFaceMsRemaining = 0;
|
||||||
|
_mutantDeathFaceActive = health <= 0 && attackerType == EnemyType.mutant;
|
||||||
|
|
||||||
if (health <= 0) {
|
if (health <= 0) {
|
||||||
log("[PLAYER] Died! Final Score: $score");
|
log("[PLAYER] Died! Final Score: $score");
|
||||||
@@ -129,6 +295,18 @@ class Player {
|
|||||||
log("[PLAYER] Healed for $amount ($health -> $newHealth)");
|
log("[PLAYER] Healed for $amount ($health -> $newHealth)");
|
||||||
}
|
}
|
||||||
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) {
|
void addAmmo(int amount) {
|
||||||
@@ -147,7 +325,7 @@ class Player {
|
|||||||
/// Attempts to collect [item] and returns the SFX to play.
|
/// Attempts to collect [item] and returns the SFX to play.
|
||||||
///
|
///
|
||||||
/// Returns `null` when the item was not collected (for example: full health).
|
/// Returns `null` when the item was not collected (for example: full health).
|
||||||
int? tryPickup(Collectible item) {
|
SoundEffect? tryPickup(Collectible item) {
|
||||||
final effect = item.tryCollect(
|
final effect = item.tryCollect(
|
||||||
CollectiblePickupContext(
|
CollectiblePickupContext(
|
||||||
health: health,
|
health: health,
|
||||||
@@ -202,7 +380,10 @@ class Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (weaponType == WeaponType.machineGun) hasMachineGun = true;
|
if (weaponType == WeaponType.machineGun) hasMachineGun = true;
|
||||||
if (weaponType == WeaponType.chainGun) hasChainGun = true;
|
if (weaponType == WeaponType.chainGun) {
|
||||||
|
hasChainGun = true;
|
||||||
|
triggerChaingunPickupFace();
|
||||||
|
}
|
||||||
|
|
||||||
log("[PLAYER] Collected ${weaponType.name}.");
|
log("[PLAYER] Collected ${weaponType.name}.");
|
||||||
}
|
}
|
||||||
@@ -211,7 +392,9 @@ class Player {
|
|||||||
requestWeaponSwitch(weaponType);
|
requestWeaponSwitch(weaponType);
|
||||||
}
|
}
|
||||||
|
|
||||||
return effect.pickupSfxId;
|
triggerBonusFlash();
|
||||||
|
|
||||||
|
return effect.pickupSoundEffect;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool fire(int currentTime) {
|
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);
|
||||||
|
}
|
||||||
+5
@@ -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';
|
||||||
+103
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -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 ammoToAdd;
|
||||||
final int scoreToAdd;
|
final int scoreToAdd;
|
||||||
final int extraLivesToAdd;
|
final int extraLivesToAdd;
|
||||||
final int pickupSfxId;
|
final SoundEffect pickupSoundEffect;
|
||||||
final bool grantGoldKey;
|
final bool grantGoldKey;
|
||||||
final bool grantSilverKey;
|
final bool grantSilverKey;
|
||||||
final WeaponType? grantWeapon;
|
final WeaponType? grantWeapon;
|
||||||
@@ -40,7 +40,7 @@ class CollectiblePickupEffect {
|
|||||||
this.ammoToAdd = 0,
|
this.ammoToAdd = 0,
|
||||||
this.scoreToAdd = 0,
|
this.scoreToAdd = 0,
|
||||||
this.extraLivesToAdd = 0,
|
this.extraLivesToAdd = 0,
|
||||||
required this.pickupSfxId,
|
required this.pickupSoundEffect,
|
||||||
this.grantGoldKey = false,
|
this.grantGoldKey = false,
|
||||||
this.grantSilverKey = false,
|
this.grantSilverKey = false,
|
||||||
this.grantWeapon,
|
this.grantWeapon,
|
||||||
@@ -49,7 +49,7 @@ class CollectiblePickupEffect {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
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,
|
double y,
|
||||||
Difficulty difficulty, {
|
Difficulty difficulty, {
|
||||||
bool isSharewareMode = false,
|
bool isSharewareMode = false,
|
||||||
|
AssetRegistry? registry,
|
||||||
}) {
|
}) {
|
||||||
return switch (objId) {
|
return switch (objId) {
|
||||||
MapObject.goldKey || MapObject.silverKey => KeyCollectible(
|
MapObject.goldKey || MapObject.silverKey => KeyCollectible(
|
||||||
@@ -123,7 +124,9 @@ class HealthCollectible extends Collectible {
|
|||||||
final bool isFood = mapId == MapObject.food;
|
final bool isFood = mapId == MapObject.food;
|
||||||
return CollectiblePickupEffect(
|
return CollectiblePickupEffect(
|
||||||
healthToRestore: isFood ? 10 : 25,
|
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(
|
return CollectiblePickupEffect(
|
||||||
ammoToAdd: ammoAmount,
|
ammoToAdd: ammoAmount,
|
||||||
pickupSfxId: WolfSound.getAmmo,
|
pickupSoundEffect: SoundEffect.getAmmo,
|
||||||
requestWeaponSwitch: shouldAutoswitchToPistol ? WeaponType.pistol : null,
|
requestWeaponSwitch: shouldAutoswitchToPistol ? WeaponType.pistol : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -172,7 +175,7 @@ class WeaponCollectible extends Collectible {
|
|||||||
if (mapId == MapObject.machineGun) {
|
if (mapId == MapObject.machineGun) {
|
||||||
return const CollectiblePickupEffect(
|
return const CollectiblePickupEffect(
|
||||||
ammoToAdd: 6,
|
ammoToAdd: 6,
|
||||||
pickupSfxId: WolfSound.getMachineGun,
|
pickupSoundEffect: SoundEffect.getMachineGun,
|
||||||
grantWeapon: WeaponType.machineGun,
|
grantWeapon: WeaponType.machineGun,
|
||||||
requestWeaponSwitch: WeaponType.machineGun,
|
requestWeaponSwitch: WeaponType.machineGun,
|
||||||
);
|
);
|
||||||
@@ -181,7 +184,7 @@ class WeaponCollectible extends Collectible {
|
|||||||
if (mapId == MapObject.chainGun) {
|
if (mapId == MapObject.chainGun) {
|
||||||
return const CollectiblePickupEffect(
|
return const CollectiblePickupEffect(
|
||||||
ammoToAdd: 6,
|
ammoToAdd: 6,
|
||||||
pickupSfxId: WolfSound.getGatling,
|
pickupSoundEffect: SoundEffect.getChainGun,
|
||||||
grantWeapon: WeaponType.chainGun,
|
grantWeapon: WeaponType.chainGun,
|
||||||
requestWeaponSwitch: WeaponType.chainGun,
|
requestWeaponSwitch: WeaponType.chainGun,
|
||||||
);
|
);
|
||||||
@@ -203,7 +206,7 @@ class KeyCollectible extends Collectible {
|
|||||||
if (mapId == MapObject.goldKey) {
|
if (mapId == MapObject.goldKey) {
|
||||||
if (context.hasGoldKey) return null;
|
if (context.hasGoldKey) return null;
|
||||||
return const CollectiblePickupEffect(
|
return const CollectiblePickupEffect(
|
||||||
pickupSfxId: WolfSound.getAmmo,
|
pickupSoundEffect: SoundEffect.getAmmo,
|
||||||
grantGoldKey: true,
|
grantGoldKey: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -211,7 +214,7 @@ class KeyCollectible extends Collectible {
|
|||||||
if (mapId == MapObject.silverKey) {
|
if (mapId == MapObject.silverKey) {
|
||||||
if (context.hasSilverKey) return null;
|
if (context.hasSilverKey) return null;
|
||||||
return const CollectiblePickupEffect(
|
return const CollectiblePickupEffect(
|
||||||
pickupSfxId: WolfSound.getAmmo,
|
pickupSoundEffect: SoundEffect.getAmmo,
|
||||||
grantSilverKey: true,
|
grantSilverKey: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -243,18 +246,18 @@ class TreasureCollectible extends Collectible {
|
|||||||
healthToRestore: 99,
|
healthToRestore: 99,
|
||||||
ammoToAdd: 25,
|
ammoToAdd: 25,
|
||||||
extraLivesToAdd: 1,
|
extraLivesToAdd: 1,
|
||||||
pickupSfxId: WolfSound.extraLife,
|
pickupSoundEffect: SoundEffect.extraLife,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return CollectiblePickupEffect(
|
return CollectiblePickupEffect(
|
||||||
scoreToAdd: scoreValue,
|
scoreToAdd: scoreValue,
|
||||||
pickupSfxId: switch (mapId) {
|
pickupSoundEffect: switch (mapId) {
|
||||||
MapObject.cross => WolfSound.treasure1,
|
MapObject.cross => SoundEffect.treasure1,
|
||||||
MapObject.chalice => WolfSound.treasure2,
|
MapObject.chalice => SoundEffect.treasure2,
|
||||||
MapObject.chest => WolfSound.treasure3,
|
MapObject.chest => SoundEffect.treasure3,
|
||||||
MapObject.crown => WolfSound.treasure4,
|
MapObject.crown => SoundEffect.treasure4,
|
||||||
_ => WolfSound.getAmmo,
|
_ => SoundEffect.getAmmo,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class DeadAardwolf extends Decorative {
|
|||||||
double y,
|
double y,
|
||||||
Difficulty difficulty, {
|
Difficulty difficulty, {
|
||||||
bool isSharewareMode = false,
|
bool isSharewareMode = false,
|
||||||
|
AssetRegistry? registry,
|
||||||
}) {
|
}) {
|
||||||
if (objId == 125) {
|
if (objId == 125) {
|
||||||
return DeadAardwolf(x: x, y: y);
|
return DeadAardwolf(x: x, y: y);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class DeadGuard extends Decorative {
|
|||||||
double y,
|
double y,
|
||||||
Difficulty difficulty, {
|
Difficulty difficulty, {
|
||||||
bool isSharewareMode = false,
|
bool isSharewareMode = false,
|
||||||
|
AssetRegistry? registry,
|
||||||
}) {
|
}) {
|
||||||
if (objId == 124) {
|
if (objId == 124) {
|
||||||
return DeadGuard(x: x, y: y);
|
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/src/entities/entity.dart';
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||||
|
|
||||||
class Decorative extends Entity {
|
class Decorative extends Entity {
|
||||||
Decorative({
|
Decorative({
|
||||||
@@ -41,6 +41,7 @@ class Decorative extends Entity {
|
|||||||
double y,
|
double y,
|
||||||
Difficulty difficulty, {
|
Difficulty difficulty, {
|
||||||
bool isSharewareMode = false,
|
bool isSharewareMode = false,
|
||||||
|
AssetRegistry? registry,
|
||||||
}) {
|
}) {
|
||||||
// 2. Standard props (Table, Lamp, etc) use the tiered check
|
// 2. Standard props (Table, Lamp, etc) use the tiered check
|
||||||
if (!MapObject.isDifficultyAllowed(objId, difficulty)) return null;
|
if (!MapObject.isDifficultyAllowed(objId, difficulty)) return null;
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ class HansGrosse extends Enemy {
|
|||||||
throw UnimplementedError("Hans Grosse uses manual animation logic.");
|
throw UnimplementedError("Hans Grosse uses manual animation logic.");
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get alertSoundId => WolfSound.bossActive;
|
SoundEffect get alertSound => SoundEffect.bossActive;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get attackSoundId => WolfSound.naziFire;
|
SoundEffect get attackSound => SoundEffect.enemyFire;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get deathSoundId => WolfSound.mutti;
|
SoundEffect get deathSound => SoundEffect.hansGrosseDeath;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get scoreValue => 5000;
|
int get scoreValue => 5000;
|
||||||
@@ -45,6 +45,7 @@ class HansGrosse extends Enemy {
|
|||||||
double y,
|
double y,
|
||||||
Difficulty difficulty, {
|
Difficulty difficulty, {
|
||||||
bool isSharewareMode = false,
|
bool isSharewareMode = false,
|
||||||
|
AssetRegistry? registry,
|
||||||
}) {
|
}) {
|
||||||
if (objId == MapObject.bossHansGrosse) {
|
if (objId == MapObject.bossHansGrosse) {
|
||||||
return HansGrosse(
|
return HansGrosse(
|
||||||
@@ -85,7 +86,7 @@ class HansGrosse extends Enemy {
|
|||||||
required bool Function(int area) isAreaConnectedToPlayer,
|
required bool Function(int area) isAreaConnectedToPlayer,
|
||||||
required void Function(int damage) onDamagePlayer,
|
required void Function(int damage) onDamagePlayer,
|
||||||
required void Function(int x, int y) tryOpenDoor,
|
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);
|
Coordinate2D movement = const Coordinate2D(0, 0);
|
||||||
double newAngle = angle;
|
double newAngle = angle;
|
||||||
@@ -102,7 +103,7 @@ class HansGrosse extends Enemy {
|
|||||||
isAreaConnectedToPlayer: isAreaConnectedToPlayer,
|
isAreaConnectedToPlayer: isAreaConnectedToPlayer,
|
||||||
baseReactionMs: 50,
|
baseReactionMs: 50,
|
||||||
)) {
|
)) {
|
||||||
onPlaySound(alertSoundId);
|
onPlaySound(alertSound);
|
||||||
}
|
}
|
||||||
|
|
||||||
double distance = position.distanceTo(playerPosition);
|
double distance = position.distanceTo(playerPosition);
|
||||||
@@ -151,7 +152,7 @@ class HansGrosse extends Enemy {
|
|||||||
setTics(10);
|
setTics(10);
|
||||||
} else if (currentFrame == 2) {
|
} else if (currentFrame == 2) {
|
||||||
spriteIndex = _baseSprite + 6; // Fire
|
spriteIndex = _baseSprite + 6; // Fire
|
||||||
onPlaySound(attackSoundId);
|
onPlaySound(attackSound);
|
||||||
onDamagePlayer(damage);
|
onDamagePlayer(damage);
|
||||||
setTics(4);
|
setTics(4);
|
||||||
} else if (currentFrame == 3) {
|
} else if (currentFrame == 3) {
|
||||||
|
|||||||
@@ -25,14 +25,45 @@ class Dog extends Enemy {
|
|||||||
required super.y,
|
required super.y,
|
||||||
required super.angle,
|
required super.angle,
|
||||||
required super.mapId,
|
required super.mapId,
|
||||||
|
super.animationSet,
|
||||||
Difficulty difficulty = Difficulty.medium,
|
Difficulty difficulty = Difficulty.medium,
|
||||||
}) : super(
|
}) : super(
|
||||||
spriteIndex: EnemyType.dog.animations.walking.start,
|
spriteIndex: (animationSet ?? EnemyType.dog.animations).walking.start,
|
||||||
state: EntityState.patrolling,
|
state: EntityState.patrolling,
|
||||||
) {
|
) {
|
||||||
health = type.hitPointsFor(difficulty);
|
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
|
@override
|
||||||
({Coordinate2D movement, double newAngle}) update({
|
({Coordinate2D movement, double newAngle}) update({
|
||||||
required int elapsedMs,
|
required int elapsedMs,
|
||||||
@@ -45,7 +76,7 @@ class Dog extends Enemy {
|
|||||||
required bool Function(int area) isAreaConnectedToPlayer,
|
required bool Function(int area) isAreaConnectedToPlayer,
|
||||||
required void Function(int damage) onDamagePlayer,
|
required void Function(int damage) onDamagePlayer,
|
||||||
required void Function(int x, int y) tryOpenDoor,
|
required void Function(int x, int y) tryOpenDoor,
|
||||||
required void Function(int sfxId) onPlaySound,
|
required void Function(SoundEffect effect) onPlaySound,
|
||||||
}) {
|
}) {
|
||||||
final previousState = state;
|
final previousState = state;
|
||||||
Coordinate2D movement = const Coordinate2D(0, 0);
|
Coordinate2D movement = const Coordinate2D(0, 0);
|
||||||
@@ -60,7 +91,7 @@ class Dog extends Enemy {
|
|||||||
areaAt: areaAt,
|
areaAt: areaAt,
|
||||||
isAreaConnectedToPlayer: isAreaConnectedToPlayer,
|
isAreaConnectedToPlayer: isAreaConnectedToPlayer,
|
||||||
)) {
|
)) {
|
||||||
onPlaySound(alertSoundId);
|
onPlaySound(alertSound);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +103,7 @@ class Dog extends Enemy {
|
|||||||
currentFrame++;
|
currentFrame++;
|
||||||
// Phase 2: The actual bite
|
// Phase 2: The actual bite
|
||||||
if (currentFrame == 1) {
|
if (currentFrame == 1) {
|
||||||
onPlaySound(attackSoundId);
|
onPlaySound(attackSound);
|
||||||
final bool attackSuccessful =
|
final bool attackSuccessful =
|
||||||
math.Random().nextDouble() < (180 / 256);
|
math.Random().nextDouble() < (180 / 256);
|
||||||
|
|
||||||
@@ -176,7 +207,9 @@ class Dog extends Enemy {
|
|||||||
// A movement magnitude threshold prevents accepting near-zero floating-
|
// A movement magnitude threshold prevents accepting near-zero floating-
|
||||||
// point residuals (e.g. cos(π/2) ≈ 6e-17) as valid movement.
|
// point residuals (e.g. cos(π/2) ≈ 6e-17) as valid movement.
|
||||||
final double minEffective = currentMoveSpeed * 0.5;
|
final double minEffective = currentMoveSpeed * 0.5;
|
||||||
final double currentDistanceToPlayer = position.distanceTo(playerPosition);
|
final double currentDistanceToPlayer = position.distanceTo(
|
||||||
|
playerPosition,
|
||||||
|
);
|
||||||
|
|
||||||
int selectedCandidateIndex = -1;
|
int selectedCandidateIndex = -1;
|
||||||
for (int i = 0; i < candidateAngles.length; i++) {
|
for (int i = 0; i < candidateAngles.length; i++) {
|
||||||
@@ -192,7 +225,8 @@ class Dog extends Enemy {
|
|||||||
tryOpenDoor: (x, y) {},
|
tryOpenDoor: (x, y) {},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (candidateMovement.x.abs() + candidateMovement.y.abs() < minEffective) {
|
if (candidateMovement.x.abs() + candidateMovement.y.abs() <
|
||||||
|
minEffective) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,15 +345,9 @@ class Dog extends Enemy {
|
|||||||
diff -= 2 * math.pi;
|
diff -= 2 * math.pi;
|
||||||
}
|
}
|
||||||
|
|
||||||
EnemyAnimation currentAnim = switch (state) {
|
final currentAnim = animationForState(state);
|
||||||
EntityState.patrolling || EntityState.ambushing => EnemyAnimation.walking,
|
|
||||||
EntityState.attacking => EnemyAnimation.attacking,
|
|
||||||
EntityState.pain => EnemyAnimation.pain,
|
|
||||||
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
|
|
||||||
_ => EnemyAnimation.idle,
|
|
||||||
};
|
|
||||||
|
|
||||||
spriteIndex = type.getSpriteFromAnimation(
|
spriteIndex = spriteForAnimation(
|
||||||
animation: currentAnim,
|
animation: currentAnim,
|
||||||
elapsedMs: elapsedMs,
|
elapsedMs: elapsedMs,
|
||||||
lastActionTime: lastActionTime,
|
lastActionTime: lastActionTime,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'dart:math' as math;
|
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/dog.dart';
|
||||||
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_type.dart';
|
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_type.dart';
|
||||||
import 'package:wolf_3d_dart/src/entities/entities/enemies/guard.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.x,
|
||||||
required super.y,
|
required super.y,
|
||||||
required super.spriteIndex,
|
required super.spriteIndex,
|
||||||
|
EnemyAnimationMap? animationSet,
|
||||||
super.angle,
|
super.angle,
|
||||||
super.state,
|
super.state,
|
||||||
super.mapId,
|
super.mapId,
|
||||||
super.lastActionTime,
|
super.lastActionTime,
|
||||||
});
|
}) : _animationSetOverride = animationSet;
|
||||||
|
|
||||||
|
EnemyAnimationMap? _animationSetOverride;
|
||||||
|
|
||||||
/// The current "Tic" count remaining for the active animation frame.
|
/// The current "Tic" count remaining for the active animation frame.
|
||||||
int _ticCount = 0;
|
int _ticCount = 0;
|
||||||
@@ -55,16 +59,76 @@ abstract class Enemy extends Entity {
|
|||||||
EnemyType get type;
|
EnemyType get type;
|
||||||
|
|
||||||
/// The sound played when this enemy notices the player or hears combat.
|
/// 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.
|
/// 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.
|
/// The score awarded when this enemy is killed.
|
||||||
int get scoreValue => type.scoreValue;
|
int get scoreValue => type.scoreValue;
|
||||||
|
|
||||||
/// The sound played once when this enemy starts dying.
|
/// 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.
|
/// Ensures enemies drop only one item (like ammo or a key) upon death.
|
||||||
bool hasDroppedItem = false;
|
bool hasDroppedItem = false;
|
||||||
@@ -83,6 +147,78 @@ abstract class Enemy extends Entity {
|
|||||||
int _patrolDirX = 0;
|
int _patrolDirX = 0;
|
||||||
int _patrolDirY = 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.
|
/// 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
|
/// 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
|
// is moving mostly north and clips a wall tile beside an open door, the
|
||||||
// Y (northward) slide is preferred over the X (sideways) slide.
|
// Y (northward) slide is preferred over the X (sideways) slide.
|
||||||
if (intendedMovement.y.abs() >= intendedMovement.x.abs()) {
|
if (intendedMovement.y.abs() >= intendedMovement.x.abs()) {
|
||||||
if (canMoveY) return normalizeTiny(Coordinate2D(0, intendedMovement.y));
|
if (canMoveY) {
|
||||||
if (canMoveX) return normalizeTiny(Coordinate2D(intendedMovement.x, 0));
|
return normalizeTiny(Coordinate2D(0, intendedMovement.y));
|
||||||
|
}
|
||||||
|
if (canMoveX) {
|
||||||
|
return normalizeTiny(Coordinate2D(intendedMovement.x, 0));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (canMoveX) return normalizeTiny(Coordinate2D(intendedMovement.x, 0));
|
if (canMoveX) {
|
||||||
if (canMoveY) return normalizeTiny(Coordinate2D(0, intendedMovement.y));
|
return normalizeTiny(Coordinate2D(intendedMovement.x, 0));
|
||||||
|
}
|
||||||
|
if (canMoveY) {
|
||||||
|
return normalizeTiny(Coordinate2D(0, intendedMovement.y));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return const Coordinate2D(0, 0);
|
return const Coordinate2D(0, 0);
|
||||||
}
|
}
|
||||||
@@ -534,7 +678,7 @@ abstract class Enemy extends Entity {
|
|||||||
required bool Function(int area) isAreaConnectedToPlayer,
|
required bool Function(int area) isAreaConnectedToPlayer,
|
||||||
required void Function(int x, int y) tryOpenDoor,
|
required void Function(int x, int y) tryOpenDoor,
|
||||||
required void Function(int damage) onDamagePlayer,
|
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.
|
/// Factory method to spawn the correct [Enemy] subclass based on a Map ID.
|
||||||
@@ -547,6 +691,7 @@ abstract class Enemy extends Entity {
|
|||||||
double y,
|
double y,
|
||||||
Difficulty difficulty, {
|
Difficulty difficulty, {
|
||||||
bool isSharewareMode = false,
|
bool isSharewareMode = false,
|
||||||
|
AssetRegistry? registry,
|
||||||
}) {
|
}) {
|
||||||
// Filter out decorative bodies or player starts
|
// Filter out decorative bodies or player starts
|
||||||
if (objId == MapObject.deadGuard || objId == MapObject.deadAardwolf) {
|
if (objId == MapObject.deadGuard || objId == MapObject.deadAardwolf) {
|
||||||
@@ -564,20 +709,33 @@ abstract class Enemy extends Entity {
|
|||||||
|
|
||||||
EnemyType? matchedType;
|
EnemyType? matchedType;
|
||||||
int? normalizedId;
|
int? normalizedId;
|
||||||
|
EnemyAnimationMap? matchedAnimationSet;
|
||||||
|
|
||||||
// Find which enemy type claims this ID for the current difficulty
|
// Find which enemy type claims this ID for the current difficulty
|
||||||
for (final type in EnemyType.values) {
|
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);
|
normalizedId = type.mapData.getNormalizedId(objId, difficulty);
|
||||||
if (normalizedId != null) {
|
if (normalizedId != null) {
|
||||||
matchedType = type;
|
matchedType = type;
|
||||||
|
matchedAnimationSet = animationSet;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no type claimed it, or the difficulty was too low, abort spawn
|
// 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)
|
// 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.
|
// 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 the specific instance
|
||||||
return switch (matchedType) {
|
final spawned = switch (matchedType) {
|
||||||
EnemyType.guard => Guard(
|
EnemyType.guard => Guard(
|
||||||
x: x,
|
x: x,
|
||||||
y: y,
|
y: y,
|
||||||
angle: spawnAngle,
|
angle: spawnAngle,
|
||||||
mapId: objId,
|
mapId: objId,
|
||||||
difficulty: difficulty,
|
difficulty: difficulty,
|
||||||
|
animationSet: matchedAnimationSet,
|
||||||
),
|
),
|
||||||
EnemyType.dog => Dog(
|
EnemyType.dog => Dog(
|
||||||
x: x,
|
x: x,
|
||||||
@@ -612,6 +771,7 @@ abstract class Enemy extends Entity {
|
|||||||
angle: spawnAngle,
|
angle: spawnAngle,
|
||||||
mapId: objId,
|
mapId: objId,
|
||||||
difficulty: difficulty,
|
difficulty: difficulty,
|
||||||
|
animationSet: matchedAnimationSet,
|
||||||
),
|
),
|
||||||
EnemyType.ss => SS(
|
EnemyType.ss => SS(
|
||||||
x: x,
|
x: x,
|
||||||
@@ -619,6 +779,7 @@ abstract class Enemy extends Entity {
|
|||||||
angle: spawnAngle,
|
angle: spawnAngle,
|
||||||
mapId: objId,
|
mapId: objId,
|
||||||
difficulty: difficulty,
|
difficulty: difficulty,
|
||||||
|
animationSet: matchedAnimationSet,
|
||||||
),
|
),
|
||||||
EnemyType.mutant => Mutant(
|
EnemyType.mutant => Mutant(
|
||||||
x: x,
|
x: x,
|
||||||
@@ -626,6 +787,7 @@ abstract class Enemy extends Entity {
|
|||||||
angle: spawnAngle,
|
angle: spawnAngle,
|
||||||
mapId: objId,
|
mapId: objId,
|
||||||
difficulty: difficulty,
|
difficulty: difficulty,
|
||||||
|
animationSet: matchedAnimationSet,
|
||||||
),
|
),
|
||||||
EnemyType.officer => Officer(
|
EnemyType.officer => Officer(
|
||||||
x: x,
|
x: x,
|
||||||
@@ -633,7 +795,33 @@ abstract class Enemy extends Entity {
|
|||||||
angle: spawnAngle,
|
angle: spawnAngle,
|
||||||
mapId: objId,
|
mapId: objId,
|
||||||
difficulty: difficulty,
|
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(
|
guard(
|
||||||
mapData: EnemyMapData(MapObject.guardStart),
|
mapData: EnemyMapData(MapObject.guardStart),
|
||||||
scoreValue: 100,
|
scoreValue: 100,
|
||||||
alertSoundId: WolfSound.guardHalt,
|
alertSound: SoundEffect.guardHalt,
|
||||||
attackSoundId: WolfSound.naziFire,
|
attackSound: SoundEffect.enemyFire,
|
||||||
deathSoundId: WolfSound.deathScream1,
|
deathSound: SoundEffect.deathScream1,
|
||||||
animations: EnemyAnimationMap(
|
animations: EnemyAnimationMap(
|
||||||
idle: SpriteFrameRange(50, 57),
|
idle: SpriteFrameRange(50, 57),
|
||||||
walking: SpriteFrameRange(58, 89),
|
walking: SpriteFrameRange(58, 89),
|
||||||
@@ -28,9 +28,9 @@ enum EnemyType {
|
|||||||
dog(
|
dog(
|
||||||
mapData: EnemyMapData(MapObject.dogStart),
|
mapData: EnemyMapData(MapObject.dogStart),
|
||||||
scoreValue: 200,
|
scoreValue: 200,
|
||||||
alertSoundId: WolfSound.dogBark,
|
alertSound: SoundEffect.dogBark,
|
||||||
attackSoundId: WolfSound.dogAttack,
|
attackSound: SoundEffect.dogAttack,
|
||||||
deathSoundId: WolfSound.dogDeath,
|
deathSound: SoundEffect.dogDeath,
|
||||||
animations: EnemyAnimationMap(
|
animations: EnemyAnimationMap(
|
||||||
// Dogs don't have true idle sprites, so map idle to the first walk frame safely
|
// Dogs don't have true idle sprites, so map idle to the first walk frame safely
|
||||||
idle: SpriteFrameRange(99, 106),
|
idle: SpriteFrameRange(99, 106),
|
||||||
@@ -51,9 +51,9 @@ enum EnemyType {
|
|||||||
ss(
|
ss(
|
||||||
mapData: EnemyMapData(MapObject.ssStart),
|
mapData: EnemyMapData(MapObject.ssStart),
|
||||||
scoreValue: 500,
|
scoreValue: 500,
|
||||||
alertSoundId: WolfSound.ssSchutzstaffel,
|
alertSound: SoundEffect.ssAlert,
|
||||||
attackSoundId: WolfSound.naziFire,
|
attackSound: SoundEffect.enemyFire,
|
||||||
deathSoundId: WolfSound.ssMeinGott,
|
deathSound: SoundEffect.ssDeath,
|
||||||
animations: EnemyAnimationMap(
|
animations: EnemyAnimationMap(
|
||||||
idle: SpriteFrameRange(138, 145),
|
idle: SpriteFrameRange(138, 145),
|
||||||
walking: SpriteFrameRange(146, 177),
|
walking: SpriteFrameRange(146, 177),
|
||||||
@@ -68,9 +68,9 @@ enum EnemyType {
|
|||||||
mutant(
|
mutant(
|
||||||
mapData: EnemyMapData(MapObject.mutantStart, tierOffset: 18),
|
mapData: EnemyMapData(MapObject.mutantStart, tierOffset: 18),
|
||||||
scoreValue: 700,
|
scoreValue: 700,
|
||||||
alertSoundId: WolfSound.guardHalt,
|
alertSound: SoundEffect.guardHalt,
|
||||||
attackSoundId: WolfSound.naziFire,
|
attackSound: SoundEffect.enemyFire,
|
||||||
deathSoundId: WolfSound.deathScream2,
|
deathSound: SoundEffect.deathScream2,
|
||||||
animations: EnemyAnimationMap(
|
animations: EnemyAnimationMap(
|
||||||
idle: SpriteFrameRange(187, 194),
|
idle: SpriteFrameRange(187, 194),
|
||||||
walking: SpriteFrameRange(195, 226),
|
walking: SpriteFrameRange(195, 226),
|
||||||
@@ -86,9 +86,9 @@ enum EnemyType {
|
|||||||
officer(
|
officer(
|
||||||
mapData: EnemyMapData(MapObject.officerStart),
|
mapData: EnemyMapData(MapObject.officerStart),
|
||||||
scoreValue: 400,
|
scoreValue: 400,
|
||||||
alertSoundId: WolfSound.guardHalt,
|
alertSound: SoundEffect.guardHalt,
|
||||||
attackSoundId: WolfSound.naziFire,
|
attackSound: SoundEffect.enemyFire,
|
||||||
deathSoundId: WolfSound.deathScream3,
|
deathSound: SoundEffect.deathScream3,
|
||||||
animations: EnemyAnimationMap(
|
animations: EnemyAnimationMap(
|
||||||
idle: SpriteFrameRange(238, 245),
|
idle: SpriteFrameRange(238, 245),
|
||||||
walking: SpriteFrameRange(246, 277),
|
walking: SpriteFrameRange(246, 277),
|
||||||
@@ -111,13 +111,13 @@ enum EnemyType {
|
|||||||
final int scoreValue;
|
final int scoreValue;
|
||||||
|
|
||||||
/// The sound played when this enemy first becomes alerted.
|
/// The sound played when this enemy first becomes alerted.
|
||||||
final int alertSoundId;
|
final SoundEffect alertSound;
|
||||||
|
|
||||||
/// The sound played when this enemy attacks.
|
/// The sound played when this enemy attacks.
|
||||||
final int attackSoundId;
|
final SoundEffect attackSound;
|
||||||
|
|
||||||
/// The sound played when this enemy enters its death animation.
|
/// 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.
|
/// If false, this enemy type will be ignored when loading shareware data.
|
||||||
final bool existsInShareware;
|
final bool existsInShareware;
|
||||||
@@ -126,9 +126,9 @@ enum EnemyType {
|
|||||||
required this.mapData,
|
required this.mapData,
|
||||||
required this.animations,
|
required this.animations,
|
||||||
required this.scoreValue,
|
required this.scoreValue,
|
||||||
required this.alertSoundId,
|
required this.alertSound,
|
||||||
required this.attackSoundId,
|
required this.attackSound,
|
||||||
required this.deathSoundId,
|
required this.deathSound,
|
||||||
this.existsInShareware = true,
|
this.existsInShareware = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ class Guard extends Enemy {
|
|||||||
required super.y,
|
required super.y,
|
||||||
required super.angle,
|
required super.angle,
|
||||||
required super.mapId,
|
required super.mapId,
|
||||||
|
super.animationSet,
|
||||||
Difficulty difficulty = Difficulty.medium,
|
Difficulty difficulty = Difficulty.medium,
|
||||||
}) : super(
|
}) : super(
|
||||||
spriteIndex: EnemyType.guard.animations.idle.start,
|
spriteIndex: (animationSet ?? EnemyType.guard.animations).idle.start,
|
||||||
state: EntityState.idle,
|
state: EntityState.idle,
|
||||||
) {
|
) {
|
||||||
health = type.hitPointsFor(difficulty);
|
health = type.hitPointsFor(difficulty);
|
||||||
@@ -38,7 +39,7 @@ class Guard extends Enemy {
|
|||||||
required bool Function(int area) isAreaConnectedToPlayer,
|
required bool Function(int area) isAreaConnectedToPlayer,
|
||||||
required void Function(int damage) onDamagePlayer,
|
required void Function(int damage) onDamagePlayer,
|
||||||
required void Function(int x, int y) tryOpenDoor,
|
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);
|
Coordinate2D movement = const Coordinate2D(0, 0);
|
||||||
double newAngle = angle;
|
double newAngle = angle;
|
||||||
@@ -52,7 +53,7 @@ class Guard extends Enemy {
|
|||||||
areaAt: areaAt,
|
areaAt: areaAt,
|
||||||
isAreaConnectedToPlayer: isAreaConnectedToPlayer,
|
isAreaConnectedToPlayer: isAreaConnectedToPlayer,
|
||||||
)) {
|
)) {
|
||||||
onPlaySound(alertSoundId);
|
onPlaySound(alertSound);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Discrete AI Logic (Decisions happen every 10 tics)
|
// 2. Discrete AI Logic (Decisions happen every 10 tics)
|
||||||
@@ -62,7 +63,7 @@ class Guard extends Enemy {
|
|||||||
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
|
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
|
||||||
currentFrame++;
|
currentFrame++;
|
||||||
if (currentFrame == 1) {
|
if (currentFrame == 1) {
|
||||||
onPlaySound(attackSoundId);
|
onPlaySound(attackSound);
|
||||||
if (tryRollRangedHit(
|
if (tryRollRangedHit(
|
||||||
distance: distance,
|
distance: distance,
|
||||||
playerPosition: playerPosition,
|
playerPosition: playerPosition,
|
||||||
@@ -150,14 +151,15 @@ class Guard extends Enemy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
EnemyAnimation currentAnim = switch (state) {
|
EnemyAnimation currentAnim = switch (state) {
|
||||||
EntityState.patrolling || EntityState.ambushing => EnemyAnimation.walking,
|
EntityState.patrolling => EnemyAnimation.walking,
|
||||||
|
EntityState.ambushing => EnemyAnimation.idle,
|
||||||
EntityState.attacking => EnemyAnimation.attacking,
|
EntityState.attacking => EnemyAnimation.attacking,
|
||||||
EntityState.pain => EnemyAnimation.pain,
|
EntityState.pain => EnemyAnimation.pain,
|
||||||
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
|
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
|
||||||
_ => EnemyAnimation.idle,
|
_ => EnemyAnimation.idle,
|
||||||
};
|
};
|
||||||
|
|
||||||
spriteIndex = type.getSpriteFromAnimation(
|
spriteIndex = spriteForAnimation(
|
||||||
animation: currentAnim,
|
animation: currentAnim,
|
||||||
elapsedMs: elapsedMs,
|
elapsedMs: elapsedMs,
|
||||||
lastActionTime: lastActionTime,
|
lastActionTime: lastActionTime,
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ class Mutant extends Enemy {
|
|||||||
required super.y,
|
required super.y,
|
||||||
required super.angle,
|
required super.angle,
|
||||||
required super.mapId,
|
required super.mapId,
|
||||||
|
super.animationSet,
|
||||||
Difficulty difficulty = Difficulty.medium,
|
Difficulty difficulty = Difficulty.medium,
|
||||||
}) : super(
|
}) : super(
|
||||||
spriteIndex: EnemyType.mutant.animations.idle.start,
|
spriteIndex: (animationSet ?? EnemyType.mutant.animations).idle.start,
|
||||||
state: EntityState.idle,
|
state: EntityState.idle,
|
||||||
) {
|
) {
|
||||||
health = type.hitPointsFor(difficulty);
|
health = type.hitPointsFor(difficulty);
|
||||||
@@ -36,7 +37,7 @@ class Mutant extends Enemy {
|
|||||||
required bool Function(int area) isAreaConnectedToPlayer,
|
required bool Function(int area) isAreaConnectedToPlayer,
|
||||||
required void Function(int damage) onDamagePlayer,
|
required void Function(int damage) onDamagePlayer,
|
||||||
required void Function(int x, int y) tryOpenDoor,
|
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);
|
Coordinate2D movement = const Coordinate2D(0, 0);
|
||||||
double newAngle = angle;
|
double newAngle = angle;
|
||||||
@@ -48,7 +49,7 @@ class Mutant extends Enemy {
|
|||||||
areaAt: areaAt,
|
areaAt: areaAt,
|
||||||
isAreaConnectedToPlayer: isAreaConnectedToPlayer,
|
isAreaConnectedToPlayer: isAreaConnectedToPlayer,
|
||||||
)) {
|
)) {
|
||||||
onPlaySound(alertSoundId);
|
onPlaySound(alertSound);
|
||||||
}
|
}
|
||||||
|
|
||||||
double distance = position.distanceTo(playerPosition);
|
double distance = position.distanceTo(playerPosition);
|
||||||
@@ -65,14 +66,15 @@ class Mutant extends Enemy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
EnemyAnimation currentAnim = switch (state) {
|
EnemyAnimation currentAnim = switch (state) {
|
||||||
EntityState.patrolling || EntityState.ambushing => EnemyAnimation.walking,
|
EntityState.patrolling => EnemyAnimation.walking,
|
||||||
|
EntityState.ambushing => EnemyAnimation.idle,
|
||||||
EntityState.attacking => EnemyAnimation.attacking,
|
EntityState.attacking => EnemyAnimation.attacking,
|
||||||
EntityState.pain => EnemyAnimation.pain,
|
EntityState.pain => EnemyAnimation.pain,
|
||||||
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
|
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
|
||||||
_ => EnemyAnimation.idle,
|
_ => EnemyAnimation.idle,
|
||||||
};
|
};
|
||||||
|
|
||||||
spriteIndex = type.getSpriteFromAnimation(
|
spriteIndex = spriteForAnimation(
|
||||||
animation: currentAnim,
|
animation: currentAnim,
|
||||||
elapsedMs: elapsedMs,
|
elapsedMs: elapsedMs,
|
||||||
lastActionTime: lastActionTime,
|
lastActionTime: lastActionTime,
|
||||||
@@ -122,7 +124,7 @@ class Mutant extends Enemy {
|
|||||||
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
|
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
|
||||||
currentFrame++;
|
currentFrame++;
|
||||||
if (currentFrame == 1) {
|
if (currentFrame == 1) {
|
||||||
onPlaySound(attackSoundId);
|
onPlaySound(attackSound);
|
||||||
if (tryRollRangedHit(
|
if (tryRollRangedHit(
|
||||||
distance: distance,
|
distance: distance,
|
||||||
playerPosition: playerPosition,
|
playerPosition: playerPosition,
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ class Officer extends Enemy {
|
|||||||
required super.y,
|
required super.y,
|
||||||
required super.angle,
|
required super.angle,
|
||||||
required super.mapId,
|
required super.mapId,
|
||||||
|
super.animationSet,
|
||||||
Difficulty difficulty = Difficulty.medium,
|
Difficulty difficulty = Difficulty.medium,
|
||||||
}) : super(
|
}) : super(
|
||||||
spriteIndex: EnemyType.officer.animations.idle.start,
|
spriteIndex: (animationSet ?? EnemyType.officer.animations).idle.start,
|
||||||
state: EntityState.idle,
|
state: EntityState.idle,
|
||||||
) {
|
) {
|
||||||
health = type.hitPointsFor(difficulty);
|
health = type.hitPointsFor(difficulty);
|
||||||
@@ -36,7 +37,7 @@ class Officer extends Enemy {
|
|||||||
required bool Function(int area) isAreaConnectedToPlayer,
|
required bool Function(int area) isAreaConnectedToPlayer,
|
||||||
required void Function(int damage) onDamagePlayer,
|
required void Function(int damage) onDamagePlayer,
|
||||||
required void Function(int x, int y) tryOpenDoor,
|
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);
|
Coordinate2D movement = const Coordinate2D(0, 0);
|
||||||
double newAngle = angle;
|
double newAngle = angle;
|
||||||
@@ -48,7 +49,7 @@ class Officer extends Enemy {
|
|||||||
areaAt: areaAt,
|
areaAt: areaAt,
|
||||||
isAreaConnectedToPlayer: isAreaConnectedToPlayer,
|
isAreaConnectedToPlayer: isAreaConnectedToPlayer,
|
||||||
)) {
|
)) {
|
||||||
onPlaySound(alertSoundId);
|
onPlaySound(alertSound);
|
||||||
}
|
}
|
||||||
|
|
||||||
double distance = position.distanceTo(playerPosition);
|
double distance = position.distanceTo(playerPosition);
|
||||||
@@ -65,14 +66,15 @@ class Officer extends Enemy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
EnemyAnimation currentAnim = switch (state) {
|
EnemyAnimation currentAnim = switch (state) {
|
||||||
EntityState.patrolling || EntityState.ambushing => EnemyAnimation.walking,
|
EntityState.patrolling => EnemyAnimation.walking,
|
||||||
|
EntityState.ambushing => EnemyAnimation.idle,
|
||||||
EntityState.attacking => EnemyAnimation.attacking,
|
EntityState.attacking => EnemyAnimation.attacking,
|
||||||
EntityState.pain => EnemyAnimation.pain,
|
EntityState.pain => EnemyAnimation.pain,
|
||||||
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
|
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
|
||||||
_ => EnemyAnimation.idle,
|
_ => EnemyAnimation.idle,
|
||||||
};
|
};
|
||||||
|
|
||||||
spriteIndex = type.getSpriteFromAnimation(
|
spriteIndex = spriteForAnimation(
|
||||||
animation: currentAnim,
|
animation: currentAnim,
|
||||||
elapsedMs: elapsedMs,
|
elapsedMs: elapsedMs,
|
||||||
lastActionTime: lastActionTime,
|
lastActionTime: lastActionTime,
|
||||||
@@ -122,7 +124,7 @@ class Officer extends Enemy {
|
|||||||
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
|
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
|
||||||
currentFrame++;
|
currentFrame++;
|
||||||
if (currentFrame == 1) {
|
if (currentFrame == 1) {
|
||||||
onPlaySound(attackSoundId);
|
onPlaySound(attackSound);
|
||||||
if (tryRollRangedHit(
|
if (tryRollRangedHit(
|
||||||
distance: distance,
|
distance: distance,
|
||||||
playerPosition: playerPosition,
|
playerPosition: playerPosition,
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ class SS extends Enemy {
|
|||||||
required super.y,
|
required super.y,
|
||||||
required super.angle,
|
required super.angle,
|
||||||
required super.mapId,
|
required super.mapId,
|
||||||
|
super.animationSet,
|
||||||
Difficulty difficulty = Difficulty.medium,
|
Difficulty difficulty = Difficulty.medium,
|
||||||
}) : super(
|
}) : super(
|
||||||
spriteIndex: EnemyType.ss.animations.idle.start,
|
spriteIndex: (animationSet ?? EnemyType.ss.animations).idle.start,
|
||||||
state: EntityState.idle,
|
state: EntityState.idle,
|
||||||
) {
|
) {
|
||||||
health = type.hitPointsFor(difficulty);
|
health = type.hitPointsFor(difficulty);
|
||||||
@@ -35,7 +36,7 @@ class SS extends Enemy {
|
|||||||
required bool Function(int area) isAreaConnectedToPlayer,
|
required bool Function(int area) isAreaConnectedToPlayer,
|
||||||
required void Function(int damage) onDamagePlayer,
|
required void Function(int damage) onDamagePlayer,
|
||||||
required void Function(int x, int y) tryOpenDoor,
|
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);
|
Coordinate2D movement = const Coordinate2D(0, 0);
|
||||||
double newAngle = angle;
|
double newAngle = angle;
|
||||||
@@ -47,7 +48,7 @@ class SS extends Enemy {
|
|||||||
areaAt: areaAt,
|
areaAt: areaAt,
|
||||||
isAreaConnectedToPlayer: isAreaConnectedToPlayer,
|
isAreaConnectedToPlayer: isAreaConnectedToPlayer,
|
||||||
)) {
|
)) {
|
||||||
onPlaySound(alertSoundId);
|
onPlaySound(alertSound);
|
||||||
}
|
}
|
||||||
|
|
||||||
double distance = position.distanceTo(playerPosition);
|
double distance = position.distanceTo(playerPosition);
|
||||||
@@ -64,14 +65,15 @@ class SS extends Enemy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
EnemyAnimation currentAnim = switch (state) {
|
EnemyAnimation currentAnim = switch (state) {
|
||||||
EntityState.patrolling || EntityState.ambushing => EnemyAnimation.walking,
|
EntityState.patrolling => EnemyAnimation.walking,
|
||||||
|
EntityState.ambushing => EnemyAnimation.idle,
|
||||||
EntityState.attacking => EnemyAnimation.attacking,
|
EntityState.attacking => EnemyAnimation.attacking,
|
||||||
EntityState.pain => EnemyAnimation.pain,
|
EntityState.pain => EnemyAnimation.pain,
|
||||||
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
|
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
|
||||||
_ => EnemyAnimation.idle,
|
_ => EnemyAnimation.idle,
|
||||||
};
|
};
|
||||||
|
|
||||||
spriteIndex = type.getSpriteFromAnimation(
|
spriteIndex = spriteForAnimation(
|
||||||
animation: currentAnim,
|
animation: currentAnim,
|
||||||
elapsedMs: elapsedMs,
|
elapsedMs: elapsedMs,
|
||||||
lastActionTime: lastActionTime,
|
lastActionTime: lastActionTime,
|
||||||
@@ -121,7 +123,7 @@ class SS extends Enemy {
|
|||||||
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
|
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
|
||||||
currentFrame++;
|
currentFrame++;
|
||||||
if (currentFrame == 1) {
|
if (currentFrame == 1) {
|
||||||
onPlaySound(attackSoundId);
|
onPlaySound(attackSound);
|
||||||
if (tryRollRangedHit(
|
if (tryRollRangedHit(
|
||||||
distance: distance,
|
distance: distance,
|
||||||
playerPosition: playerPosition,
|
playerPosition: playerPosition,
|
||||||
@@ -140,7 +142,7 @@ class SS extends Enemy {
|
|||||||
if (math.Random().nextDouble() > 0.5) {
|
if (math.Random().nextDouble() > 0.5) {
|
||||||
// 50% chance to burst
|
// 50% chance to burst
|
||||||
currentFrame = 1;
|
currentFrame = 1;
|
||||||
onPlaySound(attackSoundId);
|
onPlaySound(attackSound);
|
||||||
if (tryRollRangedHit(
|
if (tryRollRangedHit(
|
||||||
distance: distance,
|
distance: distance,
|
||||||
playerPosition: playerPosition,
|
playerPosition: playerPosition,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'dart:math' as math;
|
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/entities/enemies/enemy.dart';
|
||||||
import 'package:wolf_3d_dart/src/entities/entity.dart';
|
import 'package:wolf_3d_dart/src/entities/entity.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||||
@@ -54,6 +55,23 @@ abstract class Weapon {
|
|||||||
_triggerReleased = true;
|
_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}) {
|
bool fire(int currentTime, {required int currentAmmo}) {
|
||||||
if (state == WeaponState.idle && currentAmmo > 0) {
|
if (state == WeaponState.idle && currentAmmo > 0) {
|
||||||
if (!isAutomatic && !_triggerReleased) {
|
if (!isAutomatic && !_triggerReleased) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ typedef EntitySpawner =
|
|||||||
double y,
|
double y,
|
||||||
Difficulty difficulty, {
|
Difficulty difficulty, {
|
||||||
bool isSharewareMode,
|
bool isSharewareMode,
|
||||||
|
AssetRegistry? registry,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The central factory for instantiating all dynamic objects in a Wolf3D level.
|
/// The central factory for instantiating all dynamic objects in a Wolf3D level.
|
||||||
@@ -52,6 +53,7 @@ abstract class EntityRegistry {
|
|||||||
Difficulty difficulty,
|
Difficulty difficulty,
|
||||||
int maxSprites, {
|
int maxSprites, {
|
||||||
bool isSharewareMode = false,
|
bool isSharewareMode = false,
|
||||||
|
AssetRegistry? registry,
|
||||||
}) {
|
}) {
|
||||||
if (objId == 0) return null;
|
if (objId == 0) return null;
|
||||||
|
|
||||||
@@ -62,6 +64,7 @@ abstract class EntityRegistry {
|
|||||||
y,
|
y,
|
||||||
difficulty,
|
difficulty,
|
||||||
isSharewareMode: isSharewareMode,
|
isSharewareMode: isSharewareMode,
|
||||||
|
registry: registry,
|
||||||
);
|
);
|
||||||
if (entity != null) return entity;
|
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';
|
||||||
+76
-28
@@ -17,6 +17,8 @@ class CliGameLoop {
|
|||||||
CliGameLoop({
|
CliGameLoop({
|
||||||
required this.engine,
|
required this.engine,
|
||||||
required this.onExit,
|
required this.onExit,
|
||||||
|
this.persistence,
|
||||||
|
this.initialSettings,
|
||||||
}) : input = engine.input is CliInput
|
}) : input = engine.input is CliInput
|
||||||
? engine.input as CliInput
|
? engine.input as CliInput
|
||||||
: throw ArgumentError.value(
|
: throw ArgumentError.value(
|
||||||
@@ -24,11 +26,10 @@ class CliGameLoop {
|
|||||||
'engine.input',
|
'engine.input',
|
||||||
'CliGameLoop requires a CliInput instance.',
|
'CliGameLoop requires a CliInput instance.',
|
||||||
),
|
),
|
||||||
|
primaryRenderer = SixelRenderer(),
|
||||||
primaryRenderer = AsciiRenderer(
|
secondaryRenderer = AsciiRenderer(
|
||||||
mode: AsciiRendererMode.terminalAnsi,
|
mode: AsciiRendererMode.terminalAnsi,
|
||||||
),
|
) {
|
||||||
secondaryRenderer = SixelRenderer() {
|
|
||||||
_renderer = primaryRenderer;
|
_renderer = primaryRenderer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +38,8 @@ class CliGameLoop {
|
|||||||
final CliRendererBackend secondaryRenderer;
|
final CliRendererBackend secondaryRenderer;
|
||||||
final CliInput input;
|
final CliInput input;
|
||||||
final void Function(int code) onExit;
|
final void Function(int code) onExit;
|
||||||
|
final RendererSettingsPersistence? persistence;
|
||||||
|
final WolfRendererSettings? initialSettings;
|
||||||
|
|
||||||
final Stopwatch _stopwatch = Stopwatch();
|
final Stopwatch _stopwatch = Stopwatch();
|
||||||
final Stream<List<int>> _stdinStream = stdin.asBroadcastStream();
|
final Stream<List<int>> _stdinStream = stdin.asBroadcastStream();
|
||||||
@@ -44,6 +47,7 @@ class CliGameLoop {
|
|||||||
StreamSubscription<List<int>>? _stdinSubscription;
|
StreamSubscription<List<int>>? _stdinSubscription;
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
bool _isRunning = false;
|
bool _isRunning = false;
|
||||||
|
bool _isSixelAvailable = false;
|
||||||
Duration _lastTick = Duration.zero;
|
Duration _lastTick = Duration.zero;
|
||||||
|
|
||||||
/// Starts terminal probing, enters raw input mode, and begins the frame timer.
|
/// Starts terminal probing, enters raw input mode, and begins the frame timer.
|
||||||
@@ -57,8 +61,33 @@ class CliGameLoop {
|
|||||||
sixel.isSixelSupported = await SixelRenderer.checkTerminalSixelSupport(
|
sixel.isSixelSupported = await SixelRenderer.checkTerminalSixelSupport(
|
||||||
inputStream: _stdinStream,
|
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) {
|
if (stdin.hasTerminal) {
|
||||||
try {
|
try {
|
||||||
stdin.echoMode = false;
|
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);
|
_stdinSubscription = _stdinStream.listen(_handleInput);
|
||||||
_stopwatch.start();
|
_stopwatch.start();
|
||||||
@@ -101,7 +131,8 @@ class CliGameLoop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (stdout.hasTerminal) {
|
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;
|
_isRunning = false;
|
||||||
@@ -115,42 +146,56 @@ class CliGameLoop {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bytes.contains(9)) {
|
if (input.matchesRendererToggleShortcut(bytes)) {
|
||||||
// Tab swaps between renderers so renderer debugging stays available
|
engine.cycleRendererMode();
|
||||||
// without restarting the process.
|
_syncRendererFromEngine();
|
||||||
_renderer = identical(_renderer, secondaryRenderer)
|
unawaited(persistence?.save(engine.rendererSettings));
|
||||||
? primaryRenderer
|
|
||||||
: secondaryRenderer;
|
|
||||||
stdout.write('\x1b[2J\x1b[H');
|
stdout.write('\x1b[2J\x1b[H');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bytes.contains(116) || bytes.contains(84)) {
|
if (input.matchesAsciiThemeCycleShortcut(bytes)) {
|
||||||
_cycleAsciiTheme();
|
engine.cycleAsciiTheme();
|
||||||
|
_syncRendererFromEngine();
|
||||||
|
unawaited(persistence?.save(engine.rendererSettings));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.matchesFpsToggleShortcut(bytes)) {
|
||||||
|
engine.toggleFpsCounter();
|
||||||
|
_syncRendererFromEngine();
|
||||||
|
unawaited(persistence?.save(engine.rendererSettings));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
input.handleKey(bytes);
|
input.handleKey(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _cycleAsciiTheme() {
|
void _syncRendererFromEngine() {
|
||||||
final List<AsciiRenderer> asciiRenderers = <AsciiRenderer>[
|
final CliRendererBackend previousRenderer = _renderer;
|
||||||
if (primaryRenderer is AsciiRenderer) primaryRenderer as AsciiRenderer,
|
final WolfRendererMode mode = engine.rendererSettings.mode;
|
||||||
if (secondaryRenderer is AsciiRenderer)
|
if (mode == WolfRendererMode.sixel && _isSixelAvailable) {
|
||||||
secondaryRenderer as AsciiRenderer,
|
_renderer = primaryRenderer;
|
||||||
];
|
} else {
|
||||||
if (asciiRenderers.isEmpty) {
|
_renderer = secondaryRenderer;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final AsciiTheme nextTheme = AsciiThemes.nextOf(
|
final AsciiTheme theme =
|
||||||
asciiRenderers.first.activeTheme,
|
engine.rendererSettings.asciiThemeId ==
|
||||||
);
|
WolfRendererSettings.asciiThemeQuadrant
|
||||||
for (final renderer in asciiRenderers) {
|
? AsciiThemes.quadrant
|
||||||
renderer.activeTheme = nextTheme;
|
: 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');
|
stdout.write('\x1b[2J\x1b[H');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,6 +205,9 @@ class CliGameLoop {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply renderer changes made via in-game menus or settings updates.
|
||||||
|
_syncRendererFromEngine();
|
||||||
|
|
||||||
if (stdout.hasTerminal) {
|
if (stdout.hasTerminal) {
|
||||||
final int cols = stdout.terminalColumns;
|
final int cols = stdout.terminalColumns;
|
||||||
final int rows = stdout.terminalLines;
|
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.
|
/// Buffers one-frame terminal key presses for consumption by the engine loop.
|
||||||
class CliInput extends Wolf3dInput {
|
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
|
// Raw stdin arrives asynchronously, so presses are staged here until the
|
||||||
// next engine frame snapshots them into the active state.
|
// next engine frame snapshots them into the active state.
|
||||||
bool _pForward = false;
|
bool _pForward = false;
|
||||||
@@ -15,6 +79,7 @@ class CliInput extends Wolf3dInput {
|
|||||||
bool _pFire = false;
|
bool _pFire = false;
|
||||||
bool _pInteract = false;
|
bool _pInteract = false;
|
||||||
bool _pBack = false;
|
bool _pBack = false;
|
||||||
|
bool _pMapToggle = false;
|
||||||
WeaponType? _pWeapon;
|
WeaponType? _pWeapon;
|
||||||
|
|
||||||
/// Queues a raw terminal key sequence for the next engine frame.
|
/// Queues a raw terminal key sequence for the next engine frame.
|
||||||
@@ -40,6 +105,12 @@ class CliInput extends Wolf3dInput {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tab toggles fullscreen map overlay.
|
||||||
|
if (bytes.length == 1 && bytes[0] == 9) {
|
||||||
|
_pMapToggle = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
String char = String.fromCharCodes(bytes).toLowerCase();
|
String char = String.fromCharCodes(bytes).toLowerCase();
|
||||||
|
|
||||||
if (char == 'w') _pForward = true;
|
if (char == 'w') _pForward = true;
|
||||||
@@ -65,6 +136,7 @@ class CliInput extends Wolf3dInput {
|
|||||||
isMovingBackward = _pBackward;
|
isMovingBackward = _pBackward;
|
||||||
isTurningLeft = _pLeft;
|
isTurningLeft = _pLeft;
|
||||||
isTurningRight = _pRight;
|
isTurningRight = _pRight;
|
||||||
|
isMapToggle = _pMapToggle;
|
||||||
isFiring = _pFire;
|
isFiring = _pFire;
|
||||||
isInteracting = _pInteract;
|
isInteracting = _pInteract;
|
||||||
isBack = _pBack;
|
isBack = _pBack;
|
||||||
@@ -73,6 +145,7 @@ class CliInput extends Wolf3dInput {
|
|||||||
// Reset the pending buffer so each keypress behaves like a frame impulse.
|
// Reset the pending buffer so each keypress behaves like a frame impulse.
|
||||||
_pForward = _pBackward = _pLeft = _pRight = _pFire = _pInteract = false;
|
_pForward = _pBackward = _pLeft = _pRight = _pFire = _pInteract = false;
|
||||||
_pBack = false;
|
_pBack = false;
|
||||||
|
_pMapToggle = false;
|
||||||
_pWeapon = null;
|
_pWeapon = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ abstract class Wolf3dInput {
|
|||||||
bool isMovingBackward = false;
|
bool isMovingBackward = false;
|
||||||
bool isTurningLeft = false;
|
bool isTurningLeft = false;
|
||||||
bool isTurningRight = false;
|
bool isTurningRight = false;
|
||||||
|
bool isMapToggle = false;
|
||||||
bool isInteracting = false;
|
bool isInteracting = false;
|
||||||
bool isBack = false;
|
bool isBack = false;
|
||||||
double? menuTapX;
|
double? menuTapX;
|
||||||
@@ -23,6 +24,7 @@ abstract class Wolf3dInput {
|
|||||||
isMovingBackward: isMovingBackward,
|
isMovingBackward: isMovingBackward,
|
||||||
isTurningLeft: isTurningLeft,
|
isTurningLeft: isTurningLeft,
|
||||||
isTurningRight: isTurningRight,
|
isTurningRight: isTurningRight,
|
||||||
|
isMapToggle: isMapToggle,
|
||||||
isFiring: isFiring,
|
isFiring: isFiring,
|
||||||
isInteracting: isInteracting,
|
isInteracting: isInteracting,
|
||||||
isBack: isBack,
|
isBack: isBack,
|
||||||
@@ -37,6 +39,7 @@ enum WolfInputAction {
|
|||||||
backward,
|
backward,
|
||||||
turnLeft,
|
turnLeft,
|
||||||
turnRight,
|
turnRight,
|
||||||
|
mapToggle,
|
||||||
fire,
|
fire,
|
||||||
interact,
|
interact,
|
||||||
back,
|
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
Reference in New Issue
Block a user