Compare commits
37 Commits
de8bff1964
..
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
|
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"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,12 +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_cli/cli_renderer_settings_persistence.dart';
|
import 'package:wolf_3d_dart/wolf_3d_audio.dart';
|
||||||
import 'package:wolf_3d_cli/cli_save_game_persistence.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].
|
||||||
@@ -22,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(
|
||||||
@@ -62,14 +102,18 @@ void main() async {
|
|||||||
stdout.terminalLines,
|
stdout.terminalLines,
|
||||||
),
|
),
|
||||||
input: CliInput(),
|
input: CliInput(),
|
||||||
|
engineAudio: NativeSubprocessAudio(),
|
||||||
onGameWon: () => stopAndExit(0),
|
onGameWon: () => stopAndExit(0),
|
||||||
onQuit: () => stopAndExit(0),
|
onQuit: () => stopAndExit(0),
|
||||||
saveGamePersistence: CliSaveGamePersistence(),
|
saveGamePersistence: DefaultSaveGamePersistence(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await engine.audio.init();
|
||||||
engine.init();
|
engine.init();
|
||||||
|
|
||||||
final persistence = CliRendererSettingsPersistence();
|
final persistence = DefaultRendererSettingsPersistence(
|
||||||
|
hostKey: rendererSettingsHostCli,
|
||||||
|
);
|
||||||
final WolfRendererSettings? saved = await persistence.load();
|
final WolfRendererSettings? saved = await persistence.load();
|
||||||
|
|
||||||
gameLoop = CliGameLoop(
|
gameLoop = CliGameLoop(
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
/// CLI host adapter for persisting renderer settings to a local file.
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
|
||||||
|
|
||||||
/// Persists [WolfRendererSettings] as JSON to a local file.
|
|
||||||
///
|
|
||||||
/// The default path is `~/.wolf3d_cli_settings.json`.
|
|
||||||
/// An alternative [filePath] can be supplied at construction time.
|
|
||||||
class CliRendererSettingsPersistence extends RendererSettingsPersistence
|
|
||||||
with JsonRendererSettingsPersistence {
|
|
||||||
CliRendererSettingsPersistence({String? filePath})
|
|
||||||
: _filePath =
|
|
||||||
filePath ??
|
|
||||||
'${Platform.environment['HOME'] ?? '.'}/.wolf3d_cli_settings.json';
|
|
||||||
|
|
||||||
final String _filePath;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<String?> readRaw() async {
|
|
||||||
try {
|
|
||||||
final File f = File(_filePath);
|
|
||||||
if (!f.existsSync()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return await f.readAsString();
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> writeRaw(String json) async {
|
|
||||||
try {
|
|
||||||
await File(_filePath).writeAsString(json, flush: true);
|
|
||||||
} catch (_) {
|
|
||||||
// Best-effort; never crash the loop on a write failure.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
library;
|
|
||||||
|
|
||||||
import 'dart:io';
|
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
|
||||||
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
|
||||||
|
|
||||||
/// CLI host adapter for slot-based game save persistence.
|
|
||||||
///
|
|
||||||
/// Files are stored under `~/.wolf3d_saves` by default and named
|
|
||||||
/// `SAVEGAM{slot}.{ext}` where `{ext}` follows the active game version.
|
|
||||||
class CliSaveGamePersistence implements SaveGamePersistence {
|
|
||||||
CliSaveGamePersistence({String? directoryPath})
|
|
||||||
: _directoryPath =
|
|
||||||
directoryPath ??
|
|
||||||
'${Platform.environment['HOME'] ?? '.'}/.wolf3d_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<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}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,103 +1,27 @@
|
|||||||
/// Flutter entry point for the GUI host application.
|
|
||||||
///
|
|
||||||
/// The GUI bootstraps bundled and discoverable game data through [Wolf3d]
|
|
||||||
/// before presenting the game-selection flow.
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
|
||||||
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
|
import 'package:wolf_3d_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();
|
||||||
|
|
||||||
if (_supportsDesktopWindowing) {
|
final seededGames = await loadPackagedGames();
|
||||||
await windowManager.ensureInitialized();
|
|
||||||
}
|
|
||||||
|
|
||||||
final Wolf3d wolf3d = await Wolf3d().init();
|
final Wolf3dFlutterEngine wolf3d = await Wolf3dFlutterEngine(
|
||||||
|
debug: kDebugMode,
|
||||||
|
).init(seededGames: seededGames);
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
wolf3d.enableMenuHeaderBandDebugLogging(prefix: '[wolf_3d_gui]');
|
||||||
|
}
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
darkTheme: ThemeData.dark(useMaterial3: true),
|
home: Wolf3dGuiApp(engine: wolf3d),
|
||||||
theme: ThemeData.light(useMaterial3: true),
|
|
||||||
themeMode: ThemeMode.system,
|
|
||||||
home: wolf3d.availableGames.isEmpty
|
|
||||||
? const _NoGameDataScreen()
|
|
||||||
: GameScreen(wolf3d: wolf3d),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether desktop window-management APIs should be initialized for this host.
|
|
||||||
bool get _supportsDesktopWindowing {
|
|
||||||
if (kIsWeb) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return switch (defaultTargetPlatform) {
|
|
||||||
TargetPlatform.linux ||
|
|
||||||
TargetPlatform.windows ||
|
|
||||||
TargetPlatform.macOS => true,
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class _NoGameDataScreen extends StatelessWidget {
|
|
||||||
const _NoGameDataScreen();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: const Color(0xFF140000),
|
|
||||||
body: Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 640),
|
|
||||||
child: DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFF590002),
|
|
||||||
border: Border.all(color: const Color(0xFFB00000), width: 2),
|
|
||||||
),
|
|
||||||
child: const Padding(
|
|
||||||
padding: EdgeInsets.all(20),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'WOLF3D DATA NOT FOUND',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Color(0xFFFFF700),
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No game files were discovered.\n\n'
|
|
||||||
'Add Wolfenstein 3D data files to one of these locations:\n'
|
|
||||||
'- packages/wolf_3d_assets/assets/retail\n'
|
|
||||||
'- packages/wolf_3d_assets/assets/shareware\n'
|
|
||||||
'- or a discoverable local game-data folder.\n\n'
|
|
||||||
'Restart the app after adding the files.',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 15,
|
|
||||||
height: 1.4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,464 @@
|
|||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
|
||||||
|
|
||||||
|
import 'game_data_picker_manager.dart';
|
||||||
|
|
||||||
|
/// GUI-host fallback screen shown when no Wolf3D game data is discovered.
|
||||||
|
class NoGameDataScreen extends StatelessWidget {
|
||||||
|
static const WolfMenuPresentation _menu = WolfMenuPresentation.classic();
|
||||||
|
|
||||||
|
/// Creates the no-data screen with app-owned setup actions.
|
||||||
|
const NoGameDataScreen({
|
||||||
|
super.key,
|
||||||
|
this.configuredDataDirectory,
|
||||||
|
this.onPickGameDataDirectory,
|
||||||
|
this.onPickGameDataFiles,
|
||||||
|
this.onUseSelectedData,
|
||||||
|
this.onImportSelectedData,
|
||||||
|
this.onSelectReadyVersion,
|
||||||
|
this.isLoadingGameData = false,
|
||||||
|
this.pickerError,
|
||||||
|
this.scanResult,
|
||||||
|
this.selectedReadyVersion,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Previously configured external game-data directory, if any.
|
||||||
|
final String? configuredDataDirectory;
|
||||||
|
|
||||||
|
/// Invoked when the user requests selecting a game-data directory.
|
||||||
|
final Future<void> Function()? onPickGameDataDirectory;
|
||||||
|
|
||||||
|
/// Invoked when the user requests selecting one or more data files.
|
||||||
|
final Future<void> Function()? onPickGameDataFiles;
|
||||||
|
|
||||||
|
/// Invoked when the user wants to load the selected ready version directly.
|
||||||
|
final Future<void> Function()? onUseSelectedData;
|
||||||
|
|
||||||
|
/// Invoked when the user wants to import the selected ready version.
|
||||||
|
final Future<void> Function()? onImportSelectedData;
|
||||||
|
|
||||||
|
/// Invoked when the ready-version dropdown changes.
|
||||||
|
final ValueChanged<GameVersion?>? onSelectReadyVersion;
|
||||||
|
|
||||||
|
/// Whether the host is currently reloading after picker selection.
|
||||||
|
final bool isLoadingGameData;
|
||||||
|
|
||||||
|
/// Optional picker/reload error shown to the user.
|
||||||
|
final String? pickerError;
|
||||||
|
|
||||||
|
/// Most recent scan result for user-selected files or directories.
|
||||||
|
final GameDataScanResult? scanResult;
|
||||||
|
|
||||||
|
/// Currently selected ready version.
|
||||||
|
final GameVersion? selectedReadyVersion;
|
||||||
|
|
||||||
|
static String _stateLabel(GameDataVersionState state) {
|
||||||
|
switch (state) {
|
||||||
|
case GameDataVersionState.incomplete:
|
||||||
|
return 'Incomplete';
|
||||||
|
case GameDataVersionState.checksumWarning:
|
||||||
|
return 'Unknown checksum';
|
||||||
|
case GameDataVersionState.ready:
|
||||||
|
return 'Ready';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Color _stateColor(GameDataVersionState state) {
|
||||||
|
switch (state) {
|
||||||
|
case GameDataVersionState.ready:
|
||||||
|
return Color(_menu.emphasisColor);
|
||||||
|
case GameDataVersionState.checksumWarning:
|
||||||
|
return Color(_menu.warningColor);
|
||||||
|
case GameDataVersionState.incomplete:
|
||||||
|
return Color(_menu.mutedColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Color _fileStateColor(GameDataFileState state) {
|
||||||
|
switch (state) {
|
||||||
|
case GameDataFileState.ready:
|
||||||
|
return Color(_menu.emphasisColor);
|
||||||
|
case GameDataFileState.warning:
|
||||||
|
return Color(_menu.warningColor);
|
||||||
|
case GameDataFileState.missing:
|
||||||
|
return Color(_menu.mutedColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final List<GameDataVersionAnalysis> readyVersions =
|
||||||
|
scanResult?.readyVersions ?? <GameDataVersionAnalysis>[];
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Color(_menu.backgroundColor),
|
||||||
|
body: LayoutBuilder(
|
||||||
|
builder: (BuildContext context, BoxConstraints viewportConstraints) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
minHeight: viewportConstraints.maxHeight - 48,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 640),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Color(_menu.panelColor),
|
||||||
|
border: Border.all(
|
||||||
|
color: Color(_menu.borderColor),
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'WOLF3D DATA NOT FOUND',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(_menu.titleColor),
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No game files were discovered.\n\n'
|
||||||
|
'Select a game-data directory, or select one or more game-data files.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(_menu.bodyColor),
|
||||||
|
fontSize: 15,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'A complete version can be loaded directly or imported into the app config folder.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(_menu.emphasisColor),
|
||||||
|
fontSize: 14,
|
||||||
|
height: 1.35,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 12,
|
||||||
|
children: [
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: isLoadingGameData
|
||||||
|
? null
|
||||||
|
: onPickGameDataDirectory,
|
||||||
|
child: Text(
|
||||||
|
isLoadingGameData
|
||||||
|
? 'Loading data...'
|
||||||
|
: 'Select data directory',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: isLoadingGameData
|
||||||
|
? null
|
||||||
|
: onPickGameDataFiles,
|
||||||
|
child: Text(
|
||||||
|
isLoadingGameData
|
||||||
|
? 'Loading data...'
|
||||||
|
: 'Select data files',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (isLoadingGameData)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 10),
|
||||||
|
child: Text(
|
||||||
|
'Scanning selected locations...',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(_menu.bodyColor),
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (scanResult != null) ...[
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
_ScanSummary(
|
||||||
|
bodyColor: Color(_menu.bodyColor),
|
||||||
|
mutedColor: Color(_menu.mutedColor),
|
||||||
|
scanResult: scanResult!,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
...scanResult!.versions.map(
|
||||||
|
(GameDataVersionAnalysis analysis) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: _VersionCard(
|
||||||
|
panelColor: Color(_menu.backgroundColor),
|
||||||
|
borderColor: Color(_menu.borderColor),
|
||||||
|
titleColor: Color(_menu.titleColor),
|
||||||
|
bodyColor: Color(_menu.bodyColor),
|
||||||
|
mutedColor: Color(_menu.mutedColor),
|
||||||
|
analysis: analysis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (readyVersions.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
DropdownButtonFormField<GameVersion>(
|
||||||
|
initialValue:
|
||||||
|
selectedReadyVersion ??
|
||||||
|
readyVersions.first.version,
|
||||||
|
dropdownColor: Color(_menu.panelColor),
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(_menu.bodyColor),
|
||||||
|
),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Complete version',
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: Color(_menu.bodyColor),
|
||||||
|
),
|
||||||
|
enabledBorder: UnderlineInputBorder(
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: Color(_menu.borderColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
items: readyVersions
|
||||||
|
.map(
|
||||||
|
(
|
||||||
|
GameDataVersionAnalysis analysis,
|
||||||
|
) => DropdownMenuItem<GameVersion>(
|
||||||
|
value: analysis.version,
|
||||||
|
child: Text(
|
||||||
|
'${analysis.version.label} (${analysis.dataVersion.name})',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(growable: false),
|
||||||
|
onChanged: isLoadingGameData
|
||||||
|
? null
|
||||||
|
: onSelectReadyVersion,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 12,
|
||||||
|
children: [
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: isLoadingGameData
|
||||||
|
? null
|
||||||
|
: onUseSelectedData,
|
||||||
|
child: const Text('Use selected data'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: isLoadingGameData
|
||||||
|
? null
|
||||||
|
: onImportSelectedData,
|
||||||
|
child: const Text('Import selected data'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (pickerError != null &&
|
||||||
|
pickerError!.trim().isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12),
|
||||||
|
child: Text(
|
||||||
|
pickerError!.trim(),
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(_menu.emphasisColor),
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.3,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (configuredDataDirectory != null &&
|
||||||
|
configuredDataDirectory!.trim().isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12),
|
||||||
|
child: Text(
|
||||||
|
'Configured data directory: ${configuredDataDirectory!.trim()}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(_menu.bodyColor),
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScanSummary extends StatelessWidget {
|
||||||
|
const _ScanSummary({
|
||||||
|
required this.bodyColor,
|
||||||
|
required this.mutedColor,
|
||||||
|
required this.scanResult,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Color bodyColor;
|
||||||
|
final Color mutedColor;
|
||||||
|
final GameDataScanResult scanResult;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Scanned locations:',
|
||||||
|
style: TextStyle(
|
||||||
|
color: bodyColor,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
...scanResult.scannedDirectories.map(
|
||||||
|
(String directory) => Text(
|
||||||
|
directory,
|
||||||
|
style: TextStyle(
|
||||||
|
color: mutedColor,
|
||||||
|
fontSize: 12,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VersionCard extends StatelessWidget {
|
||||||
|
const _VersionCard({
|
||||||
|
required this.panelColor,
|
||||||
|
required this.borderColor,
|
||||||
|
required this.titleColor,
|
||||||
|
required this.bodyColor,
|
||||||
|
required this.mutedColor,
|
||||||
|
required this.analysis,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Color panelColor;
|
||||||
|
final Color borderColor;
|
||||||
|
final Color titleColor;
|
||||||
|
final Color bodyColor;
|
||||||
|
final Color mutedColor;
|
||||||
|
final GameDataVersionAnalysis analysis;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: panelColor,
|
||||||
|
border: Border.all(
|
||||||
|
color: NoGameDataScreen._stateColor(analysis.state),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
analysis.version.label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: titleColor,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
NoGameDataScreen._stateLabel(analysis.state),
|
||||||
|
style: TextStyle(
|
||||||
|
color: NoGameDataScreen._stateColor(analysis.state),
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (analysis.vswapChecksum != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(
|
||||||
|
'VSWAP checksum: ${analysis.vswapChecksum}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: mutedColor,
|
||||||
|
fontSize: 12,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
...analysis.files.map(
|
||||||
|
(GameDataFileAnalysis file) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 126,
|
||||||
|
child: Text(
|
||||||
|
file.expectedName,
|
||||||
|
style: TextStyle(
|
||||||
|
color: bodyColor,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
file.sourceName ?? 'Missing',
|
||||||
|
style: TextStyle(
|
||||||
|
color: NoGameDataScreen._fileStateColor(
|
||||||
|
file.state,
|
||||||
|
),
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (file.note != null)
|
||||||
|
Text(
|
||||||
|
file.note!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: mutedColor,
|
||||||
|
fontSize: 11,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||||
|
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
|
||||||
|
|
||||||
|
typedef PackagedGameDataLoader =
|
||||||
|
Future<WolfensteinData> Function({
|
||||||
|
required GameVersion version,
|
||||||
|
required String assetDirectory,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<List<WolfensteinData>> loadPackagedGames({
|
||||||
|
PackagedGameDataLoader? loader,
|
||||||
|
}) async {
|
||||||
|
final PackagedGameDataLoader effectiveLoader =
|
||||||
|
loader ??
|
||||||
|
({required version, required assetDirectory}) =>
|
||||||
|
Wolf3dFlutterEngine.loadGameDataFromAssets(
|
||||||
|
version: version,
|
||||||
|
packageName: 'wolf_3d_assets',
|
||||||
|
assetDirectory: assetDirectory,
|
||||||
|
);
|
||||||
|
|
||||||
|
final List<WolfensteinData> games = [];
|
||||||
|
|
||||||
|
Future<void> tryLoad({
|
||||||
|
required GameVersion version,
|
||||||
|
required String assetDirectory,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
games.add(
|
||||||
|
await effectiveLoader(
|
||||||
|
version: version,
|
||||||
|
assetDirectory: assetDirectory,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(
|
||||||
|
'Packaged game load skipped for ${version.label} ($assetDirectory): $e',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await tryLoad(version: GameVersion.retail, assetDirectory: 'assets/retail');
|
||||||
|
await tryLoad(
|
||||||
|
version: GameVersion.shareware,
|
||||||
|
assetDirectory: 'assets/shareware',
|
||||||
|
);
|
||||||
|
await tryLoad(
|
||||||
|
version: GameVersion.spearOfDestinyDemo,
|
||||||
|
assetDirectory: 'assets/sod/shareware',
|
||||||
|
);
|
||||||
|
|
||||||
|
return games;
|
||||||
|
}
|
||||||
@@ -1,436 +0,0 @@
|
|||||||
/// Active gameplay screen for the Flutter host.
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:window_manager/window_manager.dart';
|
|
||||||
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
|
||||||
import 'package:wolf_3d_dart/wolf_3d_input.dart';
|
|
||||||
import 'package:wolf_3d_dart/wolf_3d_renderer.dart';
|
|
||||||
import 'package:wolf_3d_flutter/renderer_settings_persistence_flutter.dart';
|
|
||||||
import 'package:wolf_3d_flutter/save_game_persistence_flutter.dart';
|
|
||||||
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
|
|
||||||
import 'package:wolf_3d_flutter/wolf_3d_input_flutter.dart';
|
|
||||||
import 'package:wolf_3d_gui/screens/debug_tools_screen.dart';
|
|
||||||
import 'package:wolf_3d_renderer/wolf_3d_ascii_renderer.dart';
|
|
||||||
import 'package:wolf_3d_renderer/wolf_3d_flutter_renderer.dart';
|
|
||||||
import 'package:wolf_3d_renderer/wolf_3d_glsl_renderer.dart';
|
|
||||||
|
|
||||||
enum RendererMode {
|
|
||||||
/// Software pixel renderer presented via decoded framebuffer images.
|
|
||||||
software,
|
|
||||||
|
|
||||||
/// Text-mode renderer for debugging and retro terminal aesthetics.
|
|
||||||
ascii,
|
|
||||||
|
|
||||||
/// GLSL renderer with optional CRT-style post processing.
|
|
||||||
hardware,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Semantic actions that host-level shortcuts can trigger.
|
|
||||||
///
|
|
||||||
/// These intents are intentionally UI-host focused (windowing, app shell), not
|
|
||||||
/// engine gameplay actions. The engine continues to receive input through
|
|
||||||
/// [Wolf3dFlutterInput].
|
|
||||||
enum HostShortcutIntent {
|
|
||||||
/// Toggle desktop fullscreen on/off.
|
|
||||||
toggleFullscreen,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Declarative mapping from a key pattern to a host shortcut intent.
|
|
||||||
///
|
|
||||||
/// [matches] identifies whether a key event should trigger this binding.
|
|
||||||
/// [suppressedActions] are one-frame engine actions that should be blocked
|
|
||||||
/// when the binding is consumed (for example, blocking `interact` on Alt+Enter
|
|
||||||
/// so Enter does not activate menu selections).
|
|
||||||
class HostShortcutBinding {
|
|
||||||
/// Predicate that returns true when this shortcut should fire.
|
|
||||||
final bool Function(KeyEvent event) matches;
|
|
||||||
|
|
||||||
/// Host operation to perform when [matches] succeeds.
|
|
||||||
final HostShortcutIntent intent;
|
|
||||||
|
|
||||||
/// Engine actions to suppress for a single input update tick.
|
|
||||||
final Set<WolfInputAction> suppressedActions;
|
|
||||||
|
|
||||||
/// Creates a host shortcut binding with optional suppressed engine actions.
|
|
||||||
const HostShortcutBinding({
|
|
||||||
required this.matches,
|
|
||||||
required this.intent,
|
|
||||||
this.suppressedActions = const <WolfInputAction>{},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ordered set of host shortcut bindings.
|
|
||||||
///
|
|
||||||
/// The first binding whose [HostShortcutBinding.matches] returns true wins.
|
|
||||||
/// This keeps behavior deterministic when multiple shortcuts could overlap.
|
|
||||||
class HostShortcutRegistry {
|
|
||||||
/// Ordered bindings consulted for each incoming key-down event.
|
|
||||||
final List<HostShortcutBinding> bindings;
|
|
||||||
|
|
||||||
/// Creates a registry with explicit [bindings].
|
|
||||||
const HostShortcutRegistry({
|
|
||||||
required this.bindings,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Returns the first binding that matches [event], or null when none do.
|
|
||||||
HostShortcutBinding? match(KeyEvent event) {
|
|
||||||
for (final HostShortcutBinding binding in bindings) {
|
|
||||||
if (binding.matches(event)) {
|
|
||||||
return binding;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Default host shortcuts used by [GameScreen].
|
|
||||||
///
|
|
||||||
/// Alt+Enter toggles fullscreen and suppresses the engine `interact` action
|
|
||||||
/// for one frame so Enter does not also activate menu/game interactions.
|
|
||||||
static const HostShortcutRegistry defaults = HostShortcutRegistry(
|
|
||||||
bindings: <HostShortcutBinding>[
|
|
||||||
HostShortcutBinding(
|
|
||||||
matches: _isAltEnterShortcut,
|
|
||||||
intent: HostShortcutIntent.toggleFullscreen,
|
|
||||||
suppressedActions: <WolfInputAction>{WolfInputAction.interact},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Optional imperative host shortcut override.
|
|
||||||
///
|
|
||||||
/// Return true when the event was fully handled. The handler receives the
|
|
||||||
/// shared [Wolf3dFlutterInput] so it can suppress engine actions as needed.
|
|
||||||
typedef HostShortcutHandler =
|
|
||||||
bool Function(
|
|
||||||
KeyEvent event,
|
|
||||||
Wolf3dFlutterInput input,
|
|
||||||
);
|
|
||||||
|
|
||||||
/// Launches a [WolfEngine] via [Wolf3d] and exposes renderer/input integrations.
|
|
||||||
class GameScreen extends StatefulWidget {
|
|
||||||
/// Shared application facade owning the engine, audio, and input.
|
|
||||||
final Wolf3d wolf3d;
|
|
||||||
|
|
||||||
/// Optional host-level shortcut override.
|
|
||||||
///
|
|
||||||
/// Return `true` when the event was consumed. Handlers may call
|
|
||||||
/// [Wolf3dFlutterInput.suppressActionOnce] to keep actions from reaching the
|
|
||||||
/// engine update loop.
|
|
||||||
final HostShortcutHandler? hostShortcutHandler;
|
|
||||||
|
|
||||||
/// Declarative host shortcut registry used when [hostShortcutHandler] is null.
|
|
||||||
final HostShortcutRegistry hostShortcutRegistry;
|
|
||||||
|
|
||||||
/// Creates a gameplay screen driven by [wolf3d].
|
|
||||||
const GameScreen({
|
|
||||||
required this.wolf3d,
|
|
||||||
this.hostShortcutHandler,
|
|
||||||
this.hostShortcutRegistry = HostShortcutRegistry.defaults,
|
|
||||||
super.key,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<GameScreen> createState() => _GameScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _GameScreenState extends State<GameScreen> {
|
|
||||||
late final WolfEngine _engine;
|
|
||||||
final FlutterRendererSettingsPersistence _persistence =
|
|
||||||
FlutterRendererSettingsPersistence();
|
|
||||||
final FlutterSaveGamePersistence _savePersistence =
|
|
||||||
FlutterSaveGamePersistence();
|
|
||||||
|
|
||||||
/// Mirrors [WolfRendererSettings.mode] into the Flutter renderer enum.
|
|
||||||
RendererMode _rendererMode = RendererMode.hardware;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
const Set<WolfRendererMode> supportedModes = <WolfRendererMode>{
|
|
||||||
WolfRendererMode.hardware,
|
|
||||||
WolfRendererMode.software,
|
|
||||||
WolfRendererMode.ascii,
|
|
||||||
};
|
|
||||||
_engine = widget.wolf3d.launchEngine(
|
|
||||||
rendererCapabilities: const WolfRendererCapabilities(
|
|
||||||
supportedModes: supportedModes,
|
|
||||||
supportsAsciiThemes: true,
|
|
||||||
supportsHardwareEffects: true,
|
|
||||||
supportsBloom: true,
|
|
||||||
supportsFpsCounter: true,
|
|
||||||
),
|
|
||||||
rendererSettings: const WolfRendererSettings(
|
|
||||||
mode: WolfRendererMode.hardware,
|
|
||||||
),
|
|
||||||
onRendererSettingsChanged: (settings) {
|
|
||||||
unawaited(_persistence.save(settings));
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_syncRendererModeFrom(settings);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onGameWon: () {
|
|
||||||
_engine.difficulty = null;
|
|
||||||
widget.wolf3d.clearActiveDifficulty();
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
|
||||||
onQuit: () {
|
|
||||||
SystemNavigator.pop();
|
|
||||||
},
|
|
||||||
saveGamePersistence: _savePersistence,
|
|
||||||
);
|
|
||||||
_syncRendererModeFrom(_engine.rendererSettings);
|
|
||||||
_loadPersistedSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadPersistedSettings() async {
|
|
||||||
final WolfRendererSettings? saved = await _persistence.load();
|
|
||||||
if (saved != null && mounted) {
|
|
||||||
_engine.updateRendererSettings(saved);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _syncRendererModeFrom(WolfRendererSettings settings) {
|
|
||||||
switch (settings.mode) {
|
|
||||||
case WolfRendererMode.hardware:
|
|
||||||
_rendererMode = RendererMode.hardware;
|
|
||||||
break;
|
|
||||||
case WolfRendererMode.software:
|
|
||||||
_rendererMode = RendererMode.software;
|
|
||||||
break;
|
|
||||||
case WolfRendererMode.ascii:
|
|
||||||
case WolfRendererMode.sixel:
|
|
||||||
_rendererMode = RendererMode.ascii;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return PopScope(
|
|
||||||
canPop: _engine.difficulty != null,
|
|
||||||
onPopInvokedWithResult: (didPop, _) {
|
|
||||||
if (!didPop && _engine.difficulty == null) {
|
|
||||||
widget.wolf3d.input.queueBackAction();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Scaffold(
|
|
||||||
floatingActionButton: kDebugMode && _engine.difficulty != null
|
|
||||||
? FloatingActionButton(
|
|
||||||
onPressed: _openDebugTools,
|
|
||||||
tooltip: 'Open Debug Tools',
|
|
||||||
child: const Icon(Icons.bug_report),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
body: LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
return Listener(
|
|
||||||
onPointerDown: (event) {
|
|
||||||
widget.wolf3d.input.onPointerDown(event);
|
|
||||||
},
|
|
||||||
onPointerUp: widget.wolf3d.input.onPointerUp,
|
|
||||||
onPointerMove: widget.wolf3d.input.onPointerMove,
|
|
||||||
onPointerHover: widget.wolf3d.input.onPointerMove,
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
_buildRenderer(),
|
|
||||||
|
|
||||||
if (!_engine.isInitialized)
|
|
||||||
Container(
|
|
||||||
color: Colors.black,
|
|
||||||
child: const Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
CircularProgressIndicator(color: Colors.teal),
|
|
||||||
SizedBox(height: 20),
|
|
||||||
Text(
|
|
||||||
"GET PSYCHED!",
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.teal,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// A second full-screen overlay keeps the presentation simple while
|
|
||||||
// the engine is still warming up or decoding the first frame.
|
|
||||||
if (!_engine.isInitialized)
|
|
||||||
Container(
|
|
||||||
color: Colors.black,
|
|
||||||
child: const Center(
|
|
||||||
child: CircularProgressIndicator(color: Colors.teal),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildRenderer() {
|
|
||||||
// Keep all renderers behind the same engine so mode switching does not
|
|
||||||
// reset level state or audio playback.
|
|
||||||
final WolfRendererSettings settings = _engine.rendererSettings;
|
|
||||||
switch (_rendererMode) {
|
|
||||||
case RendererMode.software:
|
|
||||||
return WolfFlutterRenderer(
|
|
||||||
engine: _engine,
|
|
||||||
onKeyEvent: _handleRendererKeyEvent,
|
|
||||||
);
|
|
||||||
case RendererMode.ascii:
|
|
||||||
final AsciiTheme theme =
|
|
||||||
settings.asciiThemeId == WolfRendererSettings.asciiThemeQuadrant
|
|
||||||
? AsciiThemes.quadrant
|
|
||||||
: AsciiThemes.blocks;
|
|
||||||
return WolfAsciiRenderer(
|
|
||||||
engine: _engine,
|
|
||||||
theme: theme,
|
|
||||||
onKeyEvent: _handleRendererKeyEvent,
|
|
||||||
);
|
|
||||||
case RendererMode.hardware:
|
|
||||||
return WolfGlslRenderer(
|
|
||||||
engine: _engine,
|
|
||||||
effectsEnabled: settings.hardwareEffectsEnabled,
|
|
||||||
bloomEnabled: settings.bloomEnabled,
|
|
||||||
onKeyEvent: _handleRendererKeyEvent,
|
|
||||||
onUnavailable: _onGlslUnavailable,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleRendererKeyEvent(KeyEvent event) {
|
|
||||||
if (event is! KeyDownEvent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Host shortcuts must be processed before game actions so they can
|
|
||||||
// suppress overlapping keys (for example Alt+Enter consuming Enter).
|
|
||||||
if (_handleHostShortcut(event)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.logicalKey == widget.wolf3d.input.rendererToggleKey) {
|
|
||||||
_engine.cycleRendererMode();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.logicalKey == widget.wolf3d.input.fpsToggleKey) {
|
|
||||||
setState(() => _engine.toggleFpsCounter());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.logicalKey == widget.wolf3d.input.asciiThemeCycleKey) {
|
|
||||||
if (_rendererMode == RendererMode.ascii) {
|
|
||||||
_engine.cycleAsciiTheme();
|
|
||||||
} else if (_rendererMode == RendererMode.hardware) {
|
|
||||||
_engine.toggleHardwareEffects();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onGlslUnavailable() {
|
|
||||||
if (!mounted || _rendererMode != RendererMode.hardware) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_engine.updateRendererSettings(
|
|
||||||
_engine.rendererSettings.copyWith(mode: WolfRendererMode.software),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _openDebugTools() {
|
|
||||||
Navigator.of(context).push(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => DebugToolsScreen(wolf3d: widget.wolf3d),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _handleHostShortcut(KeyEvent event) {
|
|
||||||
final HostShortcutHandler? customHandler = widget.hostShortcutHandler;
|
|
||||||
if (customHandler != null) {
|
|
||||||
// Custom handlers take full precedence to support future menu-driven
|
|
||||||
// rebinding/override systems without modifying this screen.
|
|
||||||
return customHandler(event, widget.wolf3d.input);
|
|
||||||
}
|
|
||||||
|
|
||||||
final HostShortcutBinding? binding = widget.hostShortcutRegistry.match(
|
|
||||||
event,
|
|
||||||
);
|
|
||||||
if (binding == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Suppress conflicting gameplay/menu actions for one update frame.
|
|
||||||
for (final WolfInputAction action in binding.suppressedActions) {
|
|
||||||
widget.wolf3d.input.suppressActionOnce(action);
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (binding.intent) {
|
|
||||||
case HostShortcutIntent.toggleFullscreen:
|
|
||||||
unawaited(_toggleFullscreen());
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Toggles desktop fullscreen state when supported by the host platform.
|
|
||||||
///
|
|
||||||
/// This no-ops on unsupported targets and safely ignores missing plugin
|
|
||||||
/// hosts to keep gameplay input resilient in embedded/test environments.
|
|
||||||
Future<void> _toggleFullscreen() async {
|
|
||||||
if (!_supportsDesktopWindowing) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final bool isFullScreen = await windowManager.isFullScreen();
|
|
||||||
await windowManager.setFullScreen(!isFullScreen);
|
|
||||||
} on MissingPluginException {
|
|
||||||
// No-op on hosts where the window manager plugin is unavailable.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether runtime desktop window management APIs are expected to work.
|
|
||||||
bool get _supportsDesktopWindowing {
|
|
||||||
if (kIsWeb) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return switch (defaultTargetPlatform) {
|
|
||||||
TargetPlatform.linux ||
|
|
||||||
TargetPlatform.windows ||
|
|
||||||
TargetPlatform.macOS => true,
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true when [event] is Enter/NumpadEnter while Alt is pressed.
|
|
||||||
bool _isAltEnterShortcut(KeyEvent event) {
|
|
||||||
final bool isEnter =
|
|
||||||
event.logicalKey == LogicalKeyboardKey.enter ||
|
|
||||||
event.logicalKey == LogicalKeyboardKey.numpadEnter;
|
|
||||||
if (!isEnter) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
final Set<LogicalKeyboardKey> pressedKeys =
|
|
||||||
HardwareKeyboard.instance.logicalKeysPressed;
|
|
||||||
return pressedKeys.contains(LogicalKeyboardKey.altLeft) ||
|
|
||||||
pressedKeys.contains(LogicalKeyboardKey.altRight) ||
|
|
||||||
pressedKeys.contains(LogicalKeyboardKey.alt);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
|
||||||
|
|
||||||
|
import 'game_data_picker_manager.dart';
|
||||||
|
import 'no_game_data_screen.dart';
|
||||||
|
|
||||||
|
/// GUI-host application shell that owns setup/import UX.
|
||||||
|
class Wolf3dGuiApp extends StatefulWidget {
|
||||||
|
/// Creates the GUI host shell for a prepared engine facade.
|
||||||
|
const Wolf3dGuiApp({
|
||||||
|
super.key,
|
||||||
|
required this.engine,
|
||||||
|
this.pickerManager,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Shared initialized facade that owns game data, input, and audio services.
|
||||||
|
final Wolf3dFlutterEngine engine;
|
||||||
|
|
||||||
|
/// Optional injected picker manager used by tests.
|
||||||
|
final GameDataPickerManager? pickerManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<Wolf3dGuiApp> createState() => _Wolf3dGuiAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Wolf3dGuiAppState extends State<Wolf3dGuiApp>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
|
late final GameDataPickerManager _pickerManager;
|
||||||
|
Future<void>? _shutdownFuture;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_pickerManager =
|
||||||
|
widget.pickerManager ?? GameDataPickerManager(engine: widget.engine);
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
unawaited(_ensureAudioShutdown());
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state == AppLifecycleState.detached) {
|
||||||
|
unawaited(_ensureAudioShutdown());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _ensureAudioShutdown() {
|
||||||
|
final Future<void>? existing = _shutdownFuture;
|
||||||
|
if (existing != null) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Future<void> shutdown = widget.engine.shutdownAudio();
|
||||||
|
_shutdownFuture = shutdown;
|
||||||
|
return shutdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickGameDataDirectory() {
|
||||||
|
return _runPickerAction(
|
||||||
|
() => _pickerManager.pickGameDataDirectory(
|
||||||
|
notifyChanged: () {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickGameDataFiles() {
|
||||||
|
return _runPickerAction(
|
||||||
|
() => _pickerManager.pickGameDataFiles(
|
||||||
|
notifyChanged: () {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _runPickerAction(Future<void> Function() action) async {
|
||||||
|
await action();
|
||||||
|
|
||||||
|
final GameDataVersionAnalysis? soleReadyVersion =
|
||||||
|
_pickerManager.scanResult?.soleReadyVersion;
|
||||||
|
if (soleReadyVersion == null ||
|
||||||
|
!mounted ||
|
||||||
|
widget.engine.availableGames.isNotEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_pickerManager.selectedReadyVersion != soleReadyVersion.version) {
|
||||||
|
setState(() {
|
||||||
|
_pickerManager.selectReadyVersion(soleReadyVersion.version);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await _useSelectedData();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _useSelectedData() {
|
||||||
|
return _pickerManager.useSelectedData(
|
||||||
|
notifyChanged: () {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _importSelectedData() {
|
||||||
|
return _pickerManager.importSelectedData(
|
||||||
|
notifyChanged: () {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return widget.engine.availableGames.isEmpty
|
||||||
|
? NoGameDataScreen(
|
||||||
|
configuredDataDirectory: widget.engine.configuredDataDirectory,
|
||||||
|
onPickGameDataDirectory: _pickGameDataDirectory,
|
||||||
|
onPickGameDataFiles: _pickGameDataFiles,
|
||||||
|
onUseSelectedData: _useSelectedData,
|
||||||
|
onImportSelectedData: _importSelectedData,
|
||||||
|
onSelectReadyVersion: (version) {
|
||||||
|
if (version == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_pickerManager.selectReadyVersion(version);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isLoadingGameData: _pickerManager.isLoadingGameData,
|
||||||
|
pickerError: _pickerManager.pickerError,
|
||||||
|
scanResult: _pickerManager.scanResult,
|
||||||
|
selectedReadyVersion: _pickerManager.selectedReadyVersion,
|
||||||
|
)
|
||||||
|
: GameScreen(wolf3d: widget.engine);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
#include "generated_plugin_registrant.h"
|
#include "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 <screen_retriever_linux/screen_retriever_linux_plugin.h>
|
||||||
#include <window_manager/window_manager_plugin.h>
|
#include <window_manager/window_manager_plugin.h>
|
||||||
|
|
||||||
@@ -14,6 +15,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
|||||||
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
|
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 =
|
g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin");
|
||||||
screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar);
|
screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
audioplayers_linux
|
audioplayers_linux
|
||||||
|
file_selector_linux
|
||||||
screen_retriever_linux
|
screen_retriever_linux
|
||||||
window_manager
|
window_manager
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,13 +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
|
|
||||||
window_manager: ^0.5.1
|
|
||||||
|
|
||||||
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)
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
# wolf_3d_dart
|
||||||
|
|
||||||
|
Core non-Flutter Wolfenstein 3D runtime package used by both CLI and Flutter hosts.
|
||||||
|
|
||||||
|
## What This Package Provides
|
||||||
|
|
||||||
|
`wolf_3d_dart` contains the platform-neutral simulation/runtime surface:
|
||||||
|
|
||||||
|
- engine and session lifecycle,
|
||||||
|
- game-data loading and data types,
|
||||||
|
- renderer backends and frame-buffer abstractions,
|
||||||
|
- menu state/navigation models,
|
||||||
|
- input/audio host abstractions,
|
||||||
|
- entity and gameplay logic.
|
||||||
|
|
||||||
|
## Public Library Surfaces
|
||||||
|
|
||||||
|
Primary entry libraries in `lib/`:
|
||||||
|
|
||||||
|
- `wolf_3d_engine.dart` — engine exports and runtime contracts.
|
||||||
|
- `wolf_3d_data.dart` / `wolf_3d_data_types.dart` — game-data discovery and DTOs.
|
||||||
|
- `wolf_3d_renderer.dart` — rendering/backends integration points.
|
||||||
|
- `wolf_3d_audio.dart` — audio interfaces and host backends.
|
||||||
|
- `wolf_3d_input.dart` — input abstractions.
|
||||||
|
- `wolf_3d_menu.dart` — menu models/managers and the registry-backed `WolfMenuPresentation` helpers for hosts.
|
||||||
|
- `wolf_3d_host.dart` — host-level glue contracts.
|
||||||
|
|
||||||
|
Implementation details live under `lib/src/`.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
From this directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dart pub get
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
From this directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dart analyze
|
||||||
|
dart test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Notes
|
||||||
|
|
||||||
|
- Hosts own platform concerns (windowing, lifecycle, platform input wiring).
|
||||||
|
- This package owns deterministic engine/frame progression and shared game logic.
|
||||||
|
- Frame-buffer sizing is controlled by hosts through engine APIs.
|
||||||
|
- Rendering code is maintained under `lib/src/rendering/`.
|
||||||
|
- Menu coordination is split under `lib/src/menu/manager/`; public consumers should prefer `lib/wolf_3d_menu.dart` or the internal barrel at `lib/src/menu/menu_manager.dart` instead of reaching into individual implementation files.
|
||||||
|
- Menu presentation is selected through `AssetRegistry.menuPresentation`, which keeps retail/shareware/Spear variants and user-defined menu overrides aligned with the rest of the registry system.
|
||||||
|
|
||||||
|
## Custom Menus
|
||||||
|
|
||||||
|
Custom menu support is split across two registry modules:
|
||||||
|
|
||||||
|
- `MenuPicModule` maps symbolic menu keys such as `MenuPicKey.title` or an episode selection entry to concrete VGA picture indices in `WolfensteinData.vgaImages`.
|
||||||
|
- `MenuPresentationModule` defines the palette indices and higher-level menu art lookups that renderers and hosts consume.
|
||||||
|
|
||||||
|
That split is intentional:
|
||||||
|
|
||||||
|
- `MenuPicModule` answers "which image index represents this menu asset for this game/mod?"
|
||||||
|
- `MenuPresentationModule` answers "which colors and optional art should the UI use?"
|
||||||
|
|
||||||
|
In practice, most custom variants will either:
|
||||||
|
|
||||||
|
- reuse an existing `MenuPicModule` and only change colors/presentation, or
|
||||||
|
- provide both a custom `MenuPicModule` and a matching `MenuPresentationModule` when the menu art layout itself changes.
|
||||||
|
|
||||||
|
### Using Menu Presentation From Loaded Data
|
||||||
|
|
||||||
|
Once game data has been loaded, bind menu presentation through the active registry:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final WolfMenuPresentation menu = WolfMenuPresentation(data);
|
||||||
|
|
||||||
|
final int panelColor = menu.panelColor;
|
||||||
|
final VgaImage? title = menu.title;
|
||||||
|
final VgaImage? episode1 = menu.episodeOption(0);
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the normal path for renderers and any UI that should track the active game variant automatically.
|
||||||
|
|
||||||
|
### Fallback Presentation Before Data Loads
|
||||||
|
|
||||||
|
Host-owned screens that appear before game data discovery can still use menu-consistent colors:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
const WolfMenuPresentation classicMenu = WolfMenuPresentation.classic();
|
||||||
|
const WolfMenuPresentation spearMenu = WolfMenuPresentation.spear();
|
||||||
|
```
|
||||||
|
|
||||||
|
Those fallback constructors expose colors without requiring a loaded `WolfensteinData` instance. Art getters return `null` until real data is attached.
|
||||||
|
|
||||||
|
### Implementing A Custom MenuPicModule
|
||||||
|
|
||||||
|
Use `MenuPicModule` when your mod changes which VGA pictures back the classic menu keys:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class ModMenuPics extends MenuPicModule {
|
||||||
|
const ModMenuPics();
|
||||||
|
|
||||||
|
@override
|
||||||
|
MenuPicRef? resolve(MenuPicKey key) {
|
||||||
|
switch (key) {
|
||||||
|
case MenuPicKey.title:
|
||||||
|
return const MenuPicRef(140);
|
||||||
|
case MenuPicKey.optionTitle:
|
||||||
|
return const MenuPicRef(141);
|
||||||
|
case MenuPicKey.customizeTitle:
|
||||||
|
return const MenuPicRef(142);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
MenuPicKey episodeKey(int episodeIndex) {
|
||||||
|
switch (episodeIndex) {
|
||||||
|
case 0:
|
||||||
|
return MenuPicKey.episode1;
|
||||||
|
case 1:
|
||||||
|
return MenuPicKey.episode2;
|
||||||
|
case 2:
|
||||||
|
return MenuPicKey.episode3;
|
||||||
|
default:
|
||||||
|
return MenuPicKey.episode1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
MenuPicKey difficultyKey(Difficulty difficulty) {
|
||||||
|
switch (difficulty) {
|
||||||
|
case Difficulty.easy:
|
||||||
|
return MenuPicKey.skill1;
|
||||||
|
case Difficulty.medium:
|
||||||
|
return MenuPicKey.skill2;
|
||||||
|
case Difficulty.hard:
|
||||||
|
return MenuPicKey.skill3;
|
||||||
|
case Difficulty.expert:
|
||||||
|
return MenuPicKey.skill4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returning `null` from `resolve` means that the key is not provided by that module.
|
||||||
|
|
||||||
|
### Implementing A Custom MenuPresentationModule
|
||||||
|
|
||||||
|
Use `MenuPresentationModule` when you want to change menu colors, point existing menu concepts at different art, or selectively omit optional art:
|
||||||
|
|
||||||
|
### Custom Menu Presentation Example
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class ModMenuPresentation extends MenuPresentationModule {
|
||||||
|
const ModMenuPresentation();
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get backgroundIndex => 111;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get panelIndex => 97;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get borderIndex => 87;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get emphasisIndex => 10;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get warningIndex => 14;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get mutedIndex => 8;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get selectedTextIndex => 19;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get unselectedTextIndex => 23;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get disabledTextIndex => 4;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get headerTextIndex => 15;
|
||||||
|
|
||||||
|
@override
|
||||||
|
VgaImage? controlBackground(WolfensteinData data) => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
VgaImage? title(WolfensteinData data) => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
VgaImage? heading(WolfensteinData data) => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
VgaImage? selectedMarker(WolfensteinData data) => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
VgaImage? unselectedMarker(WolfensteinData data) => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
VgaImage? optionsLabel(WolfensteinData data) => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
VgaImage? customizeLabel(WolfensteinData data) => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
VgaImage? credits(WolfensteinData data) => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
VgaImage? episodeOption(WolfensteinData data, int episodeIndex) => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
VgaImage? difficultyOption(WolfensteinData data, Difficulty difficulty) =>
|
||||||
|
null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
VgaImage? mappedPic(WolfensteinData data, int index) => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final registry = AssetRegistry(
|
||||||
|
sfx: mySfxModule,
|
||||||
|
music: myMusicModule,
|
||||||
|
entities: myEntityModule,
|
||||||
|
hud: myHudModule,
|
||||||
|
menu: myMenuPicModule,
|
||||||
|
menuPresentation: const ModMenuPresentation(),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
The presentation module should treat its image-returning methods as optional hooks:
|
||||||
|
|
||||||
|
- return a `VgaImage` when that surface has variant-specific art,
|
||||||
|
- return `null` when the presentation intentionally has no image for that concept,
|
||||||
|
|
||||||
|
### Palette Conversion Guardrail
|
||||||
|
|
||||||
|
When mapping target RGB menu tones to a VGA palette index (for example, preserving Wolf classic dark-red theme), resolve nearest colors from `ColorPalette.argbFromVgaIndex()` values.
|
||||||
|
|
||||||
|
Do not use `ColorPalette.findClosestPaletteIndex()` for this specific workflow, because its channel interpretation is legacy-oriented and can produce hue-swapped matches (for example, red targets resolving to blue-ish indices).
|
||||||
|
|
||||||
|
In short:
|
||||||
|
|
||||||
|
- For variant-defined menu colors: use explicit palette indices from `MenuPresentationModule`.
|
||||||
|
- For host-defined fallback RGB tones: find nearest VGA index by comparing RGB distance against `argbFromVgaIndex()` output.
|
||||||
|
- use `mappedPic(...)` only for legacy numeric menu art lookups that still matter for a renderer path.
|
||||||
|
|
||||||
|
### Wiring A Fully Custom Registry
|
||||||
|
|
||||||
|
To ship a complete custom menu variant, provide both modules through `AssetRegistry` when loading data:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final registry = AssetRegistry(
|
||||||
|
sfx: mySfxModule,
|
||||||
|
music: myMusicModule,
|
||||||
|
entities: myEntityModule,
|
||||||
|
hud: myHudModule,
|
||||||
|
menu: const ModMenuPics(),
|
||||||
|
menuPresentation: const ModMenuPresentation(),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
If your menu art still follows the built-in retail/shareware layout, you may not need a custom `MenuPicModule`. In that case, keep the built-in module and only swap `menuPresentation`.
|
||||||
|
|
||||||
|
### Choosing The Right Extension Point
|
||||||
|
|
||||||
|
- Change colors only: implement `MenuPresentationModule`.
|
||||||
|
- Change symbolic menu art mapping: implement `MenuPicModule`.
|
||||||
|
- Change both colors and art layout: implement both modules.
|
||||||
|
- Build a host setup screen before data loads: use `WolfMenuPresentation.classic()` or `WolfMenuPresentation.spear()`.
|
||||||
|
|
||||||
|
For most host code, prefer the public `wolf_3d_menu.dart` surface instead of importing internal files directly.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Flutter widgets/screens are not part of this package.
|
||||||
|
- Bundled app assets are handled by `wolf_3d_assets`.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **Parity regressions**: run targeted tests under `test/engine/` and `test/entities/`.
|
||||||
|
- **Host integration issues**: verify host packages/apps are using exported surfaces from `lib/` rather than private `src/` paths.
|
||||||
|
|
||||||
|
## Related Modules
|
||||||
|
|
||||||
|
- Flutter integration layer: [`../wolf_3d_flutter/README.md`](../wolf_3d_flutter/README.md)
|
||||||
|
- Shared packaged assets: [`../wolf_3d_assets/README.md`](../wolf_3d_assets/README.md)
|
||||||
|
- CLI host app: [`../../apps/wolf_3d_cli/README.md`](../../apps/wolf_3d_cli/README.md)
|
||||||
|
- GUI host app: [`../../apps/wolf_3d_gui/README.md`](../../apps/wolf_3d_gui/README.md)
|
||||||
|
- Workspace overview: [`../../README.md`](../../README.md)
|
||||||
@@ -2,7 +2,7 @@ import 'dart:developer';
|
|||||||
import 'dart:io';
|
import 'dart: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
|
||||||
@@ -45,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
|
||||||
@@ -112,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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||||
|
|
||||||
|
abstract class DebugMusicPlayer {
|
||||||
|
Future<void> playMusic(ImfMusic track, {bool looping = true});
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:developer';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:wolf_3d_dart/src/engine/audio/debug_music_player.dart';
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_synth.dart';
|
||||||
|
|
||||||
|
class NativeSubprocessAudio implements EngineAudio, DebugMusicPlayer {
|
||||||
|
NativeSubprocessAudio({this.maxConcurrentSfx = 8});
|
||||||
|
|
||||||
|
static bool get supportsCurrentPlatform =>
|
||||||
|
Platform.isLinux || Platform.isMacOS || Platform.isWindows;
|
||||||
|
|
||||||
|
final int maxConcurrentSfx;
|
||||||
|
|
||||||
|
@override
|
||||||
|
WolfensteinData? activeGame;
|
||||||
|
|
||||||
|
bool _initialized = false;
|
||||||
|
bool _isSupported = false;
|
||||||
|
bool _disposed = false;
|
||||||
|
_AudioBackend _backend = _AudioBackend.none;
|
||||||
|
String _windowsShellCommand = 'powershell';
|
||||||
|
|
||||||
|
Process? _musicProcess;
|
||||||
|
int _musicLoopToken = 0;
|
||||||
|
String? _musicTempFilePath;
|
||||||
|
|
||||||
|
final List<Process> _sfxProcesses = <Process>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> init() async {
|
||||||
|
if (_initialized || _disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_backend = await _detectBackend();
|
||||||
|
_isSupported = _backend != _AudioBackend.none;
|
||||||
|
_initialized = true;
|
||||||
|
|
||||||
|
if (_isSupported) {
|
||||||
|
log('[NATIVE AUDIO] Subprocess backend enabled: ${_backend.name}');
|
||||||
|
} else {
|
||||||
|
log('[NATIVE AUDIO] No supported audio backend found; running silent.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
if (_disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_disposed = true;
|
||||||
|
|
||||||
|
_musicLoopToken++;
|
||||||
|
_musicProcess?.kill();
|
||||||
|
_musicProcess = null;
|
||||||
|
|
||||||
|
for (final process in List<Process>.from(_sfxProcesses)) {
|
||||||
|
process.kill();
|
||||||
|
}
|
||||||
|
_sfxProcesses.clear();
|
||||||
|
|
||||||
|
final path = _musicTempFilePath;
|
||||||
|
_musicTempFilePath = null;
|
||||||
|
if (path != null) {
|
||||||
|
unawaited(_cleanupTempWav(path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> debugSoundTest() async {
|
||||||
|
for (int i = 0; i < 50; i++) {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||||
|
await playSoundEffectId(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> playMenuMusic() async {
|
||||||
|
final data = activeGame;
|
||||||
|
final trackIndex = data == null
|
||||||
|
? null
|
||||||
|
: Music.menuTheme.trackIndexFor(data.version);
|
||||||
|
if (data == null || trackIndex == null || trackIndex >= data.music.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await playMusic(data.music[trackIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> playLevelMusic(Music music) async {
|
||||||
|
final data = activeGame;
|
||||||
|
if (data == null || data.music.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final index = music.trackIndexFor(data.version) ?? 0;
|
||||||
|
if (index < 0 || index >= data.music.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await playMusic(data.music[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> playMusic(ImfMusic track, {bool looping = true}) async {
|
||||||
|
if (!_isSupported) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final pcmSamples = ImfRenderer.render(track);
|
||||||
|
final wavBytes = ImfRenderer.createWavFile(pcmSamples);
|
||||||
|
|
||||||
|
if (looping) {
|
||||||
|
await _startLoopingMusic(wavBytes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await stopMusic();
|
||||||
|
final process = await _startPlaybackProcess(
|
||||||
|
wavBytes: wavBytes,
|
||||||
|
role: _PlaybackRole.music,
|
||||||
|
);
|
||||||
|
if (process != null) {
|
||||||
|
_musicProcess = process;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startLoopingMusic(Uint8List wavBytes) async {
|
||||||
|
await stopMusic();
|
||||||
|
|
||||||
|
final int token = ++_musicLoopToken;
|
||||||
|
|
||||||
|
while (_musicLoopToken == token) {
|
||||||
|
final process = await _startPlaybackProcess(
|
||||||
|
wavBytes: wavBytes,
|
||||||
|
role: _PlaybackRole.music,
|
||||||
|
);
|
||||||
|
if (process == null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
_musicProcess = process;
|
||||||
|
await process.exitCode;
|
||||||
|
|
||||||
|
if (_musicLoopToken != token) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> stopMusic() async {
|
||||||
|
_musicLoopToken++;
|
||||||
|
_musicProcess?.kill();
|
||||||
|
_musicProcess = null;
|
||||||
|
|
||||||
|
final path = _musicTempFilePath;
|
||||||
|
_musicTempFilePath = null;
|
||||||
|
if (path != null) {
|
||||||
|
await _cleanupTempWav(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> stopAllAudio() async {
|
||||||
|
await stopMusic();
|
||||||
|
|
||||||
|
for (final process in List<Process>.from(_sfxProcesses)) {
|
||||||
|
process.kill();
|
||||||
|
}
|
||||||
|
_sfxProcesses.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> playSoundEffect(SoundEffect effect) async {
|
||||||
|
final data = activeGame;
|
||||||
|
if (data == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final resolved = data.registry.sfx.resolve(effect);
|
||||||
|
if (resolved != null) {
|
||||||
|
await playSoundEffectId(resolved.slotIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.version == GameVersion.spearOfDestinyDemo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await playSoundEffectId(effect.idFor(data.version));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> playSoundEffectId(int sfxId) async {
|
||||||
|
if (!_isSupported) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = activeGame;
|
||||||
|
if (data == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final soundsList = data.sounds;
|
||||||
|
if (sfxId < 0 || sfxId >= soundsList.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final raw8bitBytes = soundsList[sfxId].bytes;
|
||||||
|
if (raw8bitBytes.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Int16List converted16bit = Int16List(raw8bitBytes.length);
|
||||||
|
for (int i = 0; i < raw8bitBytes.length; i++) {
|
||||||
|
converted16bit[i] = (raw8bitBytes[i] - 128) * 256;
|
||||||
|
}
|
||||||
|
|
||||||
|
final wavBytes = ImfRenderer.createWavFile(
|
||||||
|
converted16bit,
|
||||||
|
sampleRate: 7000,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (_sfxProcesses.length >= maxConcurrentSfx) {
|
||||||
|
final oldest = _sfxProcesses.removeAt(0);
|
||||||
|
oldest.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
final process = await _startPlaybackProcess(
|
||||||
|
wavBytes: wavBytes,
|
||||||
|
role: _PlaybackRole.sfx,
|
||||||
|
);
|
||||||
|
if (process == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_sfxProcesses.add(process);
|
||||||
|
unawaited(
|
||||||
|
process.exitCode.then((_) {
|
||||||
|
_sfxProcesses.remove(process);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Process?> _startPlaybackProcess({
|
||||||
|
required Uint8List wavBytes,
|
||||||
|
required _PlaybackRole role,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
switch (_backend) {
|
||||||
|
case _AudioBackend.linuxPipeWire:
|
||||||
|
return _startFilePlaybackProcess('pw-play', const [], wavBytes, role);
|
||||||
|
case _AudioBackend.linuxPulseAudio:
|
||||||
|
return _startFilePlaybackProcess('paplay', const [], wavBytes, role);
|
||||||
|
case _AudioBackend.linuxAplay:
|
||||||
|
return _startFilePlaybackProcess(
|
||||||
|
'aplay',
|
||||||
|
const ['-q'],
|
||||||
|
wavBytes,
|
||||||
|
role,
|
||||||
|
);
|
||||||
|
case _AudioBackend.macosAfplay:
|
||||||
|
return _startFilePlaybackProcess('afplay', const [], wavBytes, role);
|
||||||
|
case _AudioBackend.windowsPowerShell:
|
||||||
|
return _startWindowsPlaybackProcess(wavBytes, role: role);
|
||||||
|
case _AudioBackend.none:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log('[NATIVE AUDIO] Failed to start playback process: $error');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Process> _startFilePlaybackProcess(
|
||||||
|
String executable,
|
||||||
|
List<String> baseArguments,
|
||||||
|
Uint8List wavBytes,
|
||||||
|
_PlaybackRole role,
|
||||||
|
) async {
|
||||||
|
final path = await _writeTempWav(wavBytes, prefix: 'wolf3d_native_audio_');
|
||||||
|
|
||||||
|
if (role == _PlaybackRole.music) {
|
||||||
|
final existing = _musicTempFilePath;
|
||||||
|
_musicTempFilePath = path;
|
||||||
|
if (existing != null && existing != path) {
|
||||||
|
await _cleanupTempWav(existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final process = await Process.start(executable, <String>[
|
||||||
|
...baseArguments,
|
||||||
|
path,
|
||||||
|
]);
|
||||||
|
|
||||||
|
unawaited(
|
||||||
|
process.exitCode.then((code) async {
|
||||||
|
if (code != 0) {
|
||||||
|
log('[NATIVE AUDIO] Player exited with code $code: $executable');
|
||||||
|
}
|
||||||
|
await _cleanupTempWav(path);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return process;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Process> _startWindowsPlaybackProcess(
|
||||||
|
Uint8List wavBytes, {
|
||||||
|
required _PlaybackRole role,
|
||||||
|
}) async {
|
||||||
|
final path = await _writeWindowsTempWav(wavBytes, role: role);
|
||||||
|
|
||||||
|
if (role == _PlaybackRole.music) {
|
||||||
|
final existing = _musicTempFilePath;
|
||||||
|
_musicTempFilePath = path;
|
||||||
|
if (existing != null && existing != path) {
|
||||||
|
await _cleanupTempWav(existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final escapedPath = path.replaceAll("'", "''");
|
||||||
|
final script = "(New-Object Media.SoundPlayer '$escapedPath').PlaySync()";
|
||||||
|
|
||||||
|
final process = await Process.start(_windowsShellCommand, <String>[
|
||||||
|
'-NoProfile',
|
||||||
|
'-NonInteractive',
|
||||||
|
'-Command',
|
||||||
|
script,
|
||||||
|
]);
|
||||||
|
|
||||||
|
unawaited(
|
||||||
|
process.exitCode.then((_) async {
|
||||||
|
await _cleanupTempWav(path);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return process;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _writeWindowsTempWav(
|
||||||
|
Uint8List wavBytes, {
|
||||||
|
required _PlaybackRole role,
|
||||||
|
}) async {
|
||||||
|
final tempDir = await Directory.systemTemp.createTemp(
|
||||||
|
'wolf3d_native_audio_',
|
||||||
|
);
|
||||||
|
final suffix = role == _PlaybackRole.music
|
||||||
|
? 'music_${DateTime.now().microsecondsSinceEpoch}.wav'
|
||||||
|
: 'sfx_${DateTime.now().microsecondsSinceEpoch}.wav';
|
||||||
|
final path = '${tempDir.path}${Platform.pathSeparator}$suffix';
|
||||||
|
await File(path).writeAsBytes(wavBytes, flush: true);
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _writeTempWav(
|
||||||
|
Uint8List wavBytes, {
|
||||||
|
required String prefix,
|
||||||
|
}) async {
|
||||||
|
final tempDir = await Directory.systemTemp.createTemp(prefix);
|
||||||
|
final path =
|
||||||
|
'${tempDir.path}${Platform.pathSeparator}audio_${DateTime.now().microsecondsSinceEpoch}.wav';
|
||||||
|
await File(path).writeAsBytes(wavBytes, flush: true);
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_AudioBackend> _detectBackend() async {
|
||||||
|
if (Platform.isLinux) {
|
||||||
|
final hasPwPlay = await _commandExists('pw-play');
|
||||||
|
if (hasPwPlay) {
|
||||||
|
return _AudioBackend.linuxPipeWire;
|
||||||
|
}
|
||||||
|
|
||||||
|
final hasPaplay = await _commandExists('paplay');
|
||||||
|
if (hasPaplay) {
|
||||||
|
return _AudioBackend.linuxPulseAudio;
|
||||||
|
}
|
||||||
|
|
||||||
|
final hasAplay = await _commandExists('aplay');
|
||||||
|
if (hasAplay) {
|
||||||
|
return _AudioBackend.linuxAplay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Platform.isMacOS) {
|
||||||
|
final hasAfplay = await _commandExists('afplay');
|
||||||
|
if (hasAfplay) {
|
||||||
|
return _AudioBackend.macosAfplay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Platform.isWindows) {
|
||||||
|
final hasPowerShell = await _commandExists('powershell');
|
||||||
|
if (hasPowerShell) {
|
||||||
|
_windowsShellCommand = 'powershell';
|
||||||
|
return _AudioBackend.windowsPowerShell;
|
||||||
|
}
|
||||||
|
|
||||||
|
final hasPwsh = await _commandExists('pwsh');
|
||||||
|
if (hasPwsh) {
|
||||||
|
_windowsShellCommand = 'pwsh';
|
||||||
|
return _AudioBackend.windowsPowerShell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _AudioBackend.none;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _commandExists(String command) async {
|
||||||
|
final probe = Platform.isWindows
|
||||||
|
? await Process.run('where', <String>[command])
|
||||||
|
: await Process.run('which', <String>[command]);
|
||||||
|
return probe.exitCode == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cleanupTempWav(String path) async {
|
||||||
|
try {
|
||||||
|
final file = File(path);
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
final directory = file.parent;
|
||||||
|
if (await directory.exists()) {
|
||||||
|
await directory.delete();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log('[NATIVE AUDIO] Temp WAV cleanup failed: $error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _AudioBackend {
|
||||||
|
none,
|
||||||
|
linuxPipeWire,
|
||||||
|
linuxPulseAudio,
|
||||||
|
linuxAplay,
|
||||||
|
macosAfplay,
|
||||||
|
windowsPowerShell,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _PlaybackRole { music, sfx }
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import 'package:wolf_3d_dart/src/engine/audio/debug_music_player.dart';
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
||||||
|
|
||||||
|
class NativeSubprocessAudio implements EngineAudio, DebugMusicPlayer {
|
||||||
|
NativeSubprocessAudio({this.maxConcurrentSfx = 8});
|
||||||
|
|
||||||
|
static bool get supportsCurrentPlatform => false;
|
||||||
|
|
||||||
|
final int maxConcurrentSfx;
|
||||||
|
|
||||||
|
@override
|
||||||
|
WolfensteinData? activeGame;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> debugSoundTest() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> init() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> playMenuMusic() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> playLevelMusic(Music music) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> stopMusic() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> stopAllAudio() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> playSoundEffect(SoundEffect effect) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> playSoundEffectId(int sfxId) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> playMusic(ImfMusic track, {bool looping = true}) async {}
|
||||||
|
}
|
||||||
+4
-12
@@ -1,13 +1,13 @@
|
|||||||
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';
|
||||||
|
|
||||||
class CliSilentAudio implements EngineAudio {
|
class SilentAudio implements EngineAudio {
|
||||||
@override
|
@override
|
||||||
WolfensteinData? activeGame;
|
WolfensteinData? activeGame;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
// No-op for CLI
|
// No-op fallback backend.
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -23,18 +23,10 @@ class CliSilentAudio implements EngineAudio {
|
|||||||
Future<void> stopAllAudio() async {}
|
Future<void> stopAllAudio() async {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void playSoundEffect(SoundEffect effect) {
|
void playSoundEffect(SoundEffect effect) {}
|
||||||
// Optional: You could use the terminal 'bell' character here
|
|
||||||
// to actually make a system beep when a sound plays!
|
|
||||||
// stdout.write('\x07');
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void playSoundEffectId(int sfxId) {
|
void playSoundEffectId(int sfxId) {}
|
||||||
// Optional: You could use the terminal 'bell' character here
|
|
||||||
// to actually make a system beep when a sound plays!
|
|
||||||
// stdout.write('\x07');
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {}
|
void dispose() {}
|
||||||
+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,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 {}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,12 @@ abstract class SaveGamePersistence {
|
|||||||
/// Loads raw bytes for [slot] and [version], or `null` when no save exists.
|
/// Loads raw bytes for [slot] and [version], or `null` when no save exists.
|
||||||
Future<Uint8List?> load({required int slot, required GameVersion version});
|
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].
|
/// Persists [bytes] for [slot] and [version].
|
||||||
Future<void> save({
|
Future<void> save({
|
||||||
required int slot,
|
required int slot,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -8,6 +8,7 @@ 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_entities.dart';
|
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_input.dart';
|
import 'package:wolf_3d_dart/wolf_3d_input.dart';
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
|
||||||
|
|
||||||
/// The core orchestration class for the Wolfenstein 3D engine.
|
/// The core orchestration class for the Wolfenstein 3D engine.
|
||||||
///
|
///
|
||||||
@@ -50,8 +51,8 @@ class WolfEngine {
|
|||||||
'Provide either data or a non-empty availableGames list.',
|
'Provide either data or a non-empty availableGames list.',
|
||||||
),
|
),
|
||||||
_availableGames = availableGames ?? <WolfensteinData>[data!],
|
_availableGames = availableGames ?? <WolfensteinData>[data!],
|
||||||
saveGameCodec = saveGameCodec ?? SaveGameCodec(),
|
saveGameCodec = saveGameCodec ?? CompatibleSaveGameCodec(),
|
||||||
audio = engineAudio ?? CliSilentAudio(),
|
audio = engineAudio ?? SilentAudio(),
|
||||||
doorManager = DoorManager(
|
doorManager = DoorManager(
|
||||||
onPlaySound: (effect) => engineAudio?.playSoundEffect(effect),
|
onPlaySound: (effect) => engineAudio?.playSoundEffect(effect),
|
||||||
),
|
),
|
||||||
@@ -61,7 +62,7 @@ class WolfEngine {
|
|||||||
if (_availableGames.isEmpty) {
|
if (_availableGames.isEmpty) {
|
||||||
throw StateError('WolfEngine requires at least one game data set.');
|
throw StateError('WolfEngine requires at least one game data set.');
|
||||||
}
|
}
|
||||||
menuManager.menuBackgroundRgb = menuBackgroundRgb;
|
_applyMenuPresentationTheme();
|
||||||
_normalizeRendererSettings();
|
_normalizeRendererSettings();
|
||||||
_syncRendererMenuModel();
|
_syncRendererMenuModel();
|
||||||
}
|
}
|
||||||
@@ -88,10 +89,10 @@ class WolfEngine {
|
|||||||
WolfensteinData get data => _availableGames[_currentGameIndex];
|
WolfensteinData get data => _availableGames[_currentGameIndex];
|
||||||
|
|
||||||
/// Desired menu background color in 24-bit RGB.
|
/// Desired menu background color in 24-bit RGB.
|
||||||
final int menuBackgroundRgb;
|
int menuBackgroundRgb;
|
||||||
|
|
||||||
/// Desired menu panel color in 24-bit RGB.
|
/// Desired menu panel color in 24-bit RGB.
|
||||||
final int menuPanelRgb;
|
int menuPanelRgb;
|
||||||
|
|
||||||
/// The active difficulty level, affecting enemy spawning and behavior.
|
/// The active difficulty level, affecting enemy spawning and behavior.
|
||||||
Difficulty? difficulty;
|
Difficulty? difficulty;
|
||||||
@@ -231,6 +232,7 @@ class WolfEngine {
|
|||||||
void init() {
|
void init() {
|
||||||
_currentGameIndex = 0;
|
_currentGameIndex = 0;
|
||||||
audio.activeGame = data;
|
audio.activeGame = data;
|
||||||
|
_applyMenuPresentationTheme();
|
||||||
onGameSelected?.call(data);
|
onGameSelected?.call(data);
|
||||||
|
|
||||||
_currentEpisodeIndex = startingEpisode ?? 0;
|
_currentEpisodeIndex = startingEpisode ?? 0;
|
||||||
@@ -244,6 +246,7 @@ class WolfEngine {
|
|||||||
hasResumableGame: false,
|
hasResumableGame: false,
|
||||||
hasLoadableSave: _hasLoadableSave,
|
hasLoadableSave: _hasLoadableSave,
|
||||||
initialGameIsRetail: data.version == GameVersion.retail,
|
initialGameIsRetail: data.version == GameVersion.retail,
|
||||||
|
initialGameIsSpear: _isSpearVariant(data.version),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (_availableGames.length == 1) {
|
if (_availableGames.length == 1) {
|
||||||
@@ -330,9 +333,7 @@ class WolfEngine {
|
|||||||
final SaveGameFile file = saveGameCodec.decode(bytes);
|
final SaveGameFile file = saveGameCodec.decode(bytes);
|
||||||
GameSessionSnapshot snapshot = file.snapshot;
|
GameSessionSnapshot snapshot = file.snapshot;
|
||||||
|
|
||||||
int gameIndex = snapshot.currentGameIndex;
|
int gameIndex = _availableGames.indexWhere(
|
||||||
if (gameIndex < 0 || gameIndex >= _availableGames.length) {
|
|
||||||
gameIndex = _availableGames.indexWhere(
|
|
||||||
(game) =>
|
(game) =>
|
||||||
game.version == file.gameVersion &&
|
game.version == file.gameVersion &&
|
||||||
game.dataVersion.name == file.dataVersionName,
|
game.dataVersion.name == file.dataVersionName,
|
||||||
@@ -342,6 +343,12 @@ class WolfEngine {
|
|||||||
(game) => game.version == file.gameVersion,
|
(game) => game.version == file.gameVersion,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (gameIndex < 0) {
|
||||||
|
final int snapshotGameIndex = snapshot.currentGameIndex;
|
||||||
|
if (snapshotGameIndex >= 0 &&
|
||||||
|
snapshotGameIndex < _availableGames.length) {
|
||||||
|
gameIndex = snapshotGameIndex;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gameIndex < 0 || gameIndex >= _availableGames.length) {
|
if (gameIndex < 0 || gameIndex >= _availableGames.length) {
|
||||||
@@ -424,6 +431,8 @@ class WolfEngine {
|
|||||||
|
|
||||||
_currentGameIndex = snapshot.currentGameIndex;
|
_currentGameIndex = snapshot.currentGameIndex;
|
||||||
audio.activeGame = data;
|
audio.activeGame = data;
|
||||||
|
_applyMenuPresentationTheme();
|
||||||
|
menuManager.setCurrentGameVersion(data.version);
|
||||||
onGameSelected?.call(data);
|
onGameSelected?.call(data);
|
||||||
|
|
||||||
_currentEpisodeIndex = snapshot.currentEpisodeIndex;
|
_currentEpisodeIndex = snapshot.currentEpisodeIndex;
|
||||||
@@ -498,18 +507,10 @@ class WolfEngine {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool hasSave = false;
|
final bool hasSave = await saveGamePersistence!.exists(
|
||||||
for (final game in _availableGames) {
|
|
||||||
final Uint8List? bytes = await saveGamePersistence!.load(
|
|
||||||
slot: defaultSaveSlot,
|
slot: defaultSaveSlot,
|
||||||
version: game.version,
|
version: data.version,
|
||||||
);
|
);
|
||||||
if (bytes != null && bytes.isNotEmpty) {
|
|
||||||
hasSave = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_setLoadGameAvailability(hasSave);
|
_setLoadGameAvailability(hasSave);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -748,6 +749,72 @@ class WolfEngine {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _applyMenuPresentationTheme() {
|
||||||
|
if (!_isSpearVariant(data.version)) {
|
||||||
|
menuBackgroundRgb = _paletteMappedRgb24(0x890000);
|
||||||
|
menuPanelRgb = _paletteMappedRgb24(0x590002);
|
||||||
|
menuManager.menuBackgroundRgb = menuBackgroundRgb;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final presentation = WolfMenuPresentation(data);
|
||||||
|
final int resolvedBackgroundIndex = _resolvedMenuColorIndex(
|
||||||
|
presentation.backgroundIndex,
|
||||||
|
);
|
||||||
|
menuBackgroundRgb = _rgb24FromVgaIndex(resolvedBackgroundIndex);
|
||||||
|
menuPanelRgb = 0x000359;
|
||||||
|
menuManager.menuBackgroundRgb = menuBackgroundRgb;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _paletteMappedRgb24(int rgb) {
|
||||||
|
final int index = _closestVgaIndexForRgb24(rgb);
|
||||||
|
return _rgb24FromVgaIndex(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _closestVgaIndexForRgb24(int rgb24) {
|
||||||
|
final int targetR = (rgb24 >> 16) & 0xFF;
|
||||||
|
final int targetG = (rgb24 >> 8) & 0xFF;
|
||||||
|
final int targetB = rgb24 & 0xFF;
|
||||||
|
|
||||||
|
int bestIndex = 0;
|
||||||
|
int bestDistance = 0x7FFFFFFF;
|
||||||
|
|
||||||
|
for (int index = 0; index < 256; index++) {
|
||||||
|
final int argb = ColorPalette.argbFromVgaIndex(index);
|
||||||
|
final int r = (argb >> 16) & 0xFF;
|
||||||
|
final int g = (argb >> 8) & 0xFF;
|
||||||
|
final int b = argb & 0xFF;
|
||||||
|
final int dr = targetR - r;
|
||||||
|
final int dg = targetG - g;
|
||||||
|
final int db = targetB - b;
|
||||||
|
final int distance = (dr * dr) + (dg * dg) + (db * db);
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
bestDistance = distance;
|
||||||
|
bestIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _resolvedMenuColorIndex(int paletteIndex) {
|
||||||
|
if (_isSpearVariant(data.version)) {
|
||||||
|
return paletteIndex;
|
||||||
|
}
|
||||||
|
if (paletteIndex >= 0x20 && paletteIndex <= 0x2F) {
|
||||||
|
return paletteIndex + 0x70;
|
||||||
|
}
|
||||||
|
return paletteIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _rgb24FromVgaIndex(int paletteIndex) {
|
||||||
|
final int argb = ColorPalette.argbFromVgaIndex(paletteIndex);
|
||||||
|
final int r = (argb >> 16) & 0xFF;
|
||||||
|
final int g = (argb >> 8) & 0xFF;
|
||||||
|
final int b = argb & 0xFF;
|
||||||
|
return (r << 16) | (g << 8) | b;
|
||||||
|
}
|
||||||
|
|
||||||
/// The primary heartbeat of the engine.
|
/// The primary heartbeat of the engine.
|
||||||
///
|
///
|
||||||
/// Updates all world subsystems based on the [elapsed] time.
|
/// Updates all world subsystems based on the [elapsed] time.
|
||||||
@@ -917,10 +984,15 @@ class WolfEngine {
|
|||||||
if (menuResult.selectedIndex != null) {
|
if (menuResult.selectedIndex != null) {
|
||||||
_currentGameIndex = menuResult.selectedIndex!;
|
_currentGameIndex = menuResult.selectedIndex!;
|
||||||
audio.activeGame = data;
|
audio.activeGame = data;
|
||||||
|
_applyMenuPresentationTheme();
|
||||||
|
menuManager.setCurrentGameVersion(data.version);
|
||||||
onGameSelected?.call(data);
|
onGameSelected?.call(data);
|
||||||
_currentEpisodeIndex = 0;
|
_currentEpisodeIndex = 0;
|
||||||
onEpisodeSelected?.call(null);
|
onEpisodeSelected?.call(null);
|
||||||
menuManager.clearEpisodeSelection();
|
menuManager.clearEpisodeSelection();
|
||||||
|
if (saveGamePersistence != null) {
|
||||||
|
unawaited(_refreshLoadGameAvailability());
|
||||||
|
}
|
||||||
menuManager.beginIntroSplash(
|
menuManager.beginIntroSplash(
|
||||||
includeRetailWarning: data.version == GameVersion.retail,
|
includeRetailWarning: data.version == GameVersion.retail,
|
||||||
);
|
);
|
||||||
@@ -950,7 +1022,11 @@ class WolfEngine {
|
|||||||
void _tickDifficultySelectionMenu(EngineInput input) {
|
void _tickDifficultySelectionMenu(EngineInput input) {
|
||||||
final menuResult = menuManager.updateDifficultySelection(input);
|
final menuResult = menuManager.updateDifficultySelection(input);
|
||||||
if (menuResult.goBack) {
|
if (menuResult.goBack) {
|
||||||
|
if (_isSingleEpisodeFlowForCurrentGame) {
|
||||||
|
menuManager.startTransition(WolfMenuScreen.mainMenu);
|
||||||
|
} else {
|
||||||
menuManager.startTransition(WolfMenuScreen.episodeSelect);
|
menuManager.startTransition(WolfMenuScreen.episodeSelect);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1022,9 +1098,21 @@ class WolfEngine {
|
|||||||
void _beginNewGameMenuFlow() {
|
void _beginNewGameMenuFlow() {
|
||||||
onEpisodeSelected?.call(null);
|
onEpisodeSelected?.call(null);
|
||||||
menuManager.clearEpisodeSelection();
|
menuManager.clearEpisodeSelection();
|
||||||
|
if (_isSingleEpisodeFlowForCurrentGame) {
|
||||||
|
menuManager.startTransition(WolfMenuScreen.difficultySelect);
|
||||||
|
return;
|
||||||
|
}
|
||||||
menuManager.startTransition(WolfMenuScreen.episodeSelect);
|
menuManager.startTransition(WolfMenuScreen.episodeSelect);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get _isSingleEpisodeFlowForCurrentGame =>
|
||||||
|
_isSpearVariant(data.version) || data.episodes.length <= 1;
|
||||||
|
|
||||||
|
bool _isSpearVariant(GameVersion version) {
|
||||||
|
return version == GameVersion.spearOfDestiny ||
|
||||||
|
version == GameVersion.spearOfDestinyDemo;
|
||||||
|
}
|
||||||
|
|
||||||
void _openPauseMenu() {
|
void _openPauseMenu() {
|
||||||
if (!_hasActiveSession) {
|
if (!_hasActiveSession) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
library;
|
||||||
|
|
||||||
|
export 'cli_game_loop_stub.dart' if (dart.library.io) 'cli_game_loop_io.dart';
|
||||||
+7
-1
@@ -26,7 +26,6 @@ class CliGameLoop {
|
|||||||
'engine.input',
|
'engine.input',
|
||||||
'CliGameLoop requires a CliInput instance.',
|
'CliGameLoop requires a CliInput instance.',
|
||||||
),
|
),
|
||||||
|
|
||||||
primaryRenderer = SixelRenderer(),
|
primaryRenderer = SixelRenderer(),
|
||||||
secondaryRenderer = AsciiRenderer(
|
secondaryRenderer = AsciiRenderer(
|
||||||
mode: AsciiRendererMode.terminalAnsi,
|
mode: AsciiRendererMode.terminalAnsi,
|
||||||
@@ -162,6 +161,13 @@ class CliGameLoop {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.matchesFpsToggleShortcut(bytes)) {
|
||||||
|
engine.toggleFpsCounter();
|
||||||
|
_syncRendererFromEngine();
|
||||||
|
unawaited(persistence?.save(engine.rendererSettings));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
input.handleKey(bytes);
|
input.handleKey(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/// Web-safe stub for CLI game loop APIs.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_input.dart';
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_renderer.dart';
|
||||||
|
|
||||||
|
class CliGameLoop {
|
||||||
|
CliGameLoop({
|
||||||
|
required this.engine,
|
||||||
|
required this.onExit,
|
||||||
|
this.persistence,
|
||||||
|
this.initialSettings,
|
||||||
|
}) : input = engine.input is CliInput
|
||||||
|
? engine.input as CliInput
|
||||||
|
: throw ArgumentError.value(
|
||||||
|
engine.input,
|
||||||
|
'engine.input',
|
||||||
|
'CliGameLoop requires a CliInput instance.',
|
||||||
|
),
|
||||||
|
primaryRenderer = SixelRenderer(),
|
||||||
|
secondaryRenderer = AsciiRenderer(mode: AsciiRendererMode.terminalAnsi);
|
||||||
|
|
||||||
|
final WolfEngine engine;
|
||||||
|
final CliRendererBackend primaryRenderer;
|
||||||
|
final CliRendererBackend secondaryRenderer;
|
||||||
|
final CliInput input;
|
||||||
|
final void Function(int code) onExit;
|
||||||
|
final RendererSettingsPersistence? persistence;
|
||||||
|
final WolfRendererSettings? initialSettings;
|
||||||
|
|
||||||
|
Future<void> start() {
|
||||||
|
throw UnsupportedError('CliGameLoop is only available on dart:io hosts.');
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop() {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
||||||
|
|
||||||
|
/// Coordinates gameplay persistence concerns for host applications.
|
||||||
|
class GamePersistenceManager {
|
||||||
|
/// Creates persistence manager dependencies with overridable adapters.
|
||||||
|
GamePersistenceManager({
|
||||||
|
RendererSettingsPersistence? rendererSettingsPersistence,
|
||||||
|
SaveGamePersistence? saveGamePersistence,
|
||||||
|
}) : rendererSettingsPersistence =
|
||||||
|
rendererSettingsPersistence ??
|
||||||
|
DefaultRendererSettingsPersistence(
|
||||||
|
hostKey: rendererSettingsHostFlutter,
|
||||||
|
),
|
||||||
|
saveGamePersistence =
|
||||||
|
saveGamePersistence ?? DefaultSaveGamePersistence();
|
||||||
|
|
||||||
|
/// Persists and restores runtime renderer settings.
|
||||||
|
final RendererSettingsPersistence rendererSettingsPersistence;
|
||||||
|
|
||||||
|
/// Persists slot-based save game snapshots.
|
||||||
|
final SaveGamePersistence saveGamePersistence;
|
||||||
|
|
||||||
|
/// Loads previously persisted renderer settings.
|
||||||
|
Future<WolfRendererSettings?> loadRendererSettings() {
|
||||||
|
return rendererSettingsPersistence.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads persisted renderer settings and applies them to [engine].
|
||||||
|
Future<WolfRendererSettings?> restoreRendererSettings(
|
||||||
|
WolfEngine engine,
|
||||||
|
) async {
|
||||||
|
final WolfRendererSettings? saved = await loadRendererSettings();
|
||||||
|
if (saved != null) {
|
||||||
|
engine.updateRendererSettings(saved);
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves current renderer settings.
|
||||||
|
Future<void> saveRendererSettings(WolfRendererSettings settings) {
|
||||||
|
return rendererSettingsPersistence.save(settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
||||||
|
|
||||||
|
/// Renderer presentation mode used by host widgets.
|
||||||
|
enum GameRendererMode {
|
||||||
|
/// Software pixel renderer presented via decoded framebuffer images.
|
||||||
|
software,
|
||||||
|
|
||||||
|
/// Text-mode renderer for debugging and retro terminal aesthetics.
|
||||||
|
ascii,
|
||||||
|
|
||||||
|
/// GLSL renderer with optional CRT-style post processing.
|
||||||
|
hardware,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps engine renderer settings to host renderer presentation mode.
|
||||||
|
GameRendererMode gameRendererModeFromSettings(WolfRendererSettings settings) {
|
||||||
|
return switch (settings.mode) {
|
||||||
|
WolfRendererMode.hardware => GameRendererMode.hardware,
|
||||||
|
WolfRendererMode.software => GameRendererMode.software,
|
||||||
|
WolfRendererMode.ascii || WolfRendererMode.sixel => GameRendererMode.ascii,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Falls back to software mode when GLSL rendering is unavailable at runtime.
|
||||||
|
void handleGlslUnavailable({
|
||||||
|
required bool isMounted,
|
||||||
|
required GameRendererMode rendererMode,
|
||||||
|
required WolfEngine? engine,
|
||||||
|
}) {
|
||||||
|
if (!isMounted || rendererMode != GameRendererMode.hardware) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final WolfEngine? activeEngine = engine;
|
||||||
|
if (activeEngine == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeEngine.updateRendererSettings(
|
||||||
|
activeEngine.rendererSettings.copyWith(mode: WolfRendererMode.software),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ class CliInput extends Wolf3dInput {
|
|||||||
/// Keyboard shortcut used by the CLI host to cycle ASCII themes.
|
/// Keyboard shortcut used by the CLI host to cycle ASCII themes.
|
||||||
String asciiThemeCycleKey = 't';
|
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.
|
/// Human-friendly label for [rendererToggleKey] shown in CLI hints.
|
||||||
String get rendererToggleKeyLabel => _formatShortcutLabel(rendererToggleKey);
|
String get rendererToggleKeyLabel => _formatShortcutLabel(rendererToggleKey);
|
||||||
|
|
||||||
@@ -19,6 +22,9 @@ class CliInput extends Wolf3dInput {
|
|||||||
String get asciiThemeCycleKeyLabel =>
|
String get asciiThemeCycleKeyLabel =>
|
||||||
_formatShortcutLabel(asciiThemeCycleKey);
|
_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.
|
/// Returns true when [bytes] triggers the renderer-toggle shortcut.
|
||||||
bool matchesRendererToggleShortcut(List<int> bytes) =>
|
bool matchesRendererToggleShortcut(List<int> bytes) =>
|
||||||
_matchesShortcut(bytes, rendererToggleKey);
|
_matchesShortcut(bytes, rendererToggleKey);
|
||||||
@@ -27,6 +33,10 @@ class CliInput extends Wolf3dInput {
|
|||||||
bool matchesAsciiThemeCycleShortcut(List<int> bytes) =>
|
bool matchesAsciiThemeCycleShortcut(List<int> bytes) =>
|
||||||
_matchesShortcut(bytes, asciiThemeCycleKey);
|
_matchesShortcut(bytes, asciiThemeCycleKey);
|
||||||
|
|
||||||
|
/// Returns true when [bytes] triggers the FPS-toggle shortcut.
|
||||||
|
bool matchesFpsToggleShortcut(List<int> bytes) =>
|
||||||
|
_matchesShortcut(bytes, fpsToggleKey);
|
||||||
|
|
||||||
String _formatShortcutLabel(String key) {
|
String _formatShortcutLabel(String key) {
|
||||||
final String trimmed = key.trim();
|
final String trimmed = key.trim();
|
||||||
if (trimmed.isEmpty) {
|
if (trimmed.isEmpty) {
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
||||||
|
|
||||||
|
import 'menu_manager_entries.dart';
|
||||||
|
import 'menu_manager_enums.dart';
|
||||||
|
|
||||||
|
part 'menu_manager_intro_mixin.dart';
|
||||||
|
part 'menu_manager_navigation_mixin.dart';
|
||||||
|
part 'menu_manager_selection_mixin.dart';
|
||||||
|
|
||||||
|
enum _WolfIntroPhase { fadeIn, hold, fadeOut }
|
||||||
|
|
||||||
|
abstract class _MenuManagerBase {
|
||||||
|
static const int transitionDurationMs = 280;
|
||||||
|
static const int introFadeDurationMs = 280;
|
||||||
|
static const int introRetailBackgroundRgb = 0xA00000;
|
||||||
|
static const int introPg13BackgroundRgb = 0x33A2E8;
|
||||||
|
static const int introTitleBackgroundRgb = 0x000000;
|
||||||
|
|
||||||
|
WolfMenuScreen _activeMenu = WolfMenuScreen.difficultySelect;
|
||||||
|
WolfMenuScreen? _transitionTarget;
|
||||||
|
WolfTransitionEffect _transitionEffect = WolfTransitionEffect.normalFade;
|
||||||
|
int _transitionElapsedMs = 0;
|
||||||
|
bool _transitionSwappedMenu = false;
|
||||||
|
WolfMenuScreen _introLandingMenu = WolfMenuScreen.mainMenu;
|
||||||
|
WolfTransitionEffect _introEffect = WolfTransitionEffect.normalFade;
|
||||||
|
int _introSlideIndex = 0;
|
||||||
|
int _introElapsedMs = 0;
|
||||||
|
_WolfIntroPhase _introPhase = _WolfIntroPhase.fadeIn;
|
||||||
|
bool _introAdvanceRequested = false;
|
||||||
|
List<WolfIntroSlide> _introSlides = <WolfIntroSlide>[
|
||||||
|
WolfIntroSlide.pg13,
|
||||||
|
WolfIntroSlide.title,
|
||||||
|
];
|
||||||
|
|
||||||
|
int _selectedMainIndex = 0;
|
||||||
|
int _selectedGameIndex = 0;
|
||||||
|
int _selectedEpisodeIndex = 0;
|
||||||
|
int _selectedDifficultyIndex = 0;
|
||||||
|
int _selectedChangeViewIndex = 0;
|
||||||
|
int _selectedRendererOptionIndex = 0;
|
||||||
|
String _rendererOptionsTitle = 'CUSTOMIZE';
|
||||||
|
List<WolfMenuRendererEntry> _changeViewEntries =
|
||||||
|
const <WolfMenuRendererEntry>[];
|
||||||
|
List<WolfMenuRendererOptionEntry> _rendererOptionEntries =
|
||||||
|
const <WolfMenuRendererOptionEntry>[];
|
||||||
|
bool _showResumeOption = false;
|
||||||
|
bool _hasLoadableSave = false;
|
||||||
|
int _gameCount = 1;
|
||||||
|
bool _isSpearVariant = false;
|
||||||
|
|
||||||
|
bool _prevUp = false;
|
||||||
|
bool _prevDown = false;
|
||||||
|
bool _prevConfirm = false;
|
||||||
|
bool _prevBack = false;
|
||||||
|
|
||||||
|
int _menuBackgroundRgb = 0x890000;
|
||||||
|
|
||||||
|
bool get isTransitioning => _transitionTarget != null;
|
||||||
|
|
||||||
|
bool get isIntroSplashActive => _activeMenu == WolfMenuScreen.introSplash;
|
||||||
|
|
||||||
|
int get changeViewItemCount =>
|
||||||
|
_changeViewEntries.length + _rendererOptionEntries.length;
|
||||||
|
|
||||||
|
void consumeEdgeState(EngineInput input) {
|
||||||
|
_prevUp = input.isMovingForward;
|
||||||
|
_prevDown = input.isMovingBackward;
|
||||||
|
_prevConfirm = input.isInteracting || input.isFiring;
|
||||||
|
_prevBack = input.isBack;
|
||||||
|
}
|
||||||
|
|
||||||
|
void resetEdgeState() {
|
||||||
|
_prevUp = false;
|
||||||
|
_prevDown = false;
|
||||||
|
_prevConfirm = false;
|
||||||
|
_prevBack = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int findSelectableIndex(
|
||||||
|
int startIndex,
|
||||||
|
int itemCount,
|
||||||
|
bool Function(int index) selectable,
|
||||||
|
) {
|
||||||
|
if (itemCount <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
for (int offset = 0; offset < itemCount; offset++) {
|
||||||
|
final int index = (startIndex + offset) % itemCount;
|
||||||
|
if (selectable(index)) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clampIndex(startIndex, itemCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
int moveSelectableIndex(
|
||||||
|
int currentIndex,
|
||||||
|
int itemCount,
|
||||||
|
int delta,
|
||||||
|
bool Function(int index) selectable,
|
||||||
|
) {
|
||||||
|
if (itemCount <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int index = currentIndex;
|
||||||
|
for (int step = 0; step < itemCount; step++) {
|
||||||
|
index = (index + delta + itemCount) % itemCount;
|
||||||
|
if (selectable(index)) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return currentIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
int clampIndex(int index, int itemCount) {
|
||||||
|
if (itemCount <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return index.clamp(0, itemCount - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void beginSelectionFlow({
|
||||||
|
required int gameCount,
|
||||||
|
int initialGameIndex = 0,
|
||||||
|
int initialEpisodeIndex = 0,
|
||||||
|
Difficulty? initialDifficulty,
|
||||||
|
bool hasResumableGame = false,
|
||||||
|
bool hasLoadableSave = false,
|
||||||
|
bool initialGameIsRetail = false,
|
||||||
|
bool initialGameIsSpear = false,
|
||||||
|
WolfTransitionEffect introEffect = WolfTransitionEffect.normalFade,
|
||||||
|
});
|
||||||
|
|
||||||
|
int _defaultMainMenuIndex();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coordinates menu state, splash sequencing, and selection updates.
|
||||||
|
///
|
||||||
|
/// Hosts and renderers interact with this type through the stable
|
||||||
|
/// `src/menu/menu_manager.dart` barrel, while the implementation remains split
|
||||||
|
/// across focused files under `src/menu/manager/`.
|
||||||
|
class MenuManager extends _MenuManagerBase
|
||||||
|
with
|
||||||
|
_MenuManagerIntroMixin,
|
||||||
|
_MenuManagerSelectionMixin,
|
||||||
|
_MenuManagerNavigationMixin {
|
||||||
|
static const int transitionDurationMs = _MenuManagerBase.transitionDurationMs;
|
||||||
|
static const int introFadeDurationMs = _MenuManagerBase.introFadeDurationMs;
|
||||||
|
|
||||||
|
/// Whether to show the alternate cursor frame at [elapsedMs].
|
||||||
|
bool isCursorAltFrame(int elapsedMs) => ((elapsedMs ~/ 220) % 2) == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MenuAction {
|
||||||
|
const _MenuAction({
|
||||||
|
required this.index,
|
||||||
|
required this.confirmed,
|
||||||
|
required this.goBack,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int index;
|
||||||
|
final bool confirmed;
|
||||||
|
final bool goBack;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
||||||
|
|
||||||
|
/// Logical actions exposed by the main menu.
|
||||||
|
enum WolfMenuMainAction {
|
||||||
|
newGame,
|
||||||
|
sound,
|
||||||
|
control,
|
||||||
|
loadGame,
|
||||||
|
saveGame,
|
||||||
|
changeView,
|
||||||
|
readThis,
|
||||||
|
viewScores,
|
||||||
|
endGame,
|
||||||
|
backToGame,
|
||||||
|
backToDemo,
|
||||||
|
quit,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immutable description of a main-menu row.
|
||||||
|
class WolfMenuMainEntry {
|
||||||
|
const WolfMenuMainEntry({
|
||||||
|
required this.action,
|
||||||
|
required this.label,
|
||||||
|
this.isEnabled = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final WolfMenuMainAction action;
|
||||||
|
final String label;
|
||||||
|
final bool isEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immutable description of a renderer row in the change-view menu.
|
||||||
|
class WolfMenuRendererEntry {
|
||||||
|
const WolfMenuRendererEntry({
|
||||||
|
required this.mode,
|
||||||
|
required this.label,
|
||||||
|
required this.hasOptions,
|
||||||
|
this.isEnabled = true,
|
||||||
|
this.isChecked = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final WolfRendererMode mode;
|
||||||
|
final String label;
|
||||||
|
final bool hasOptions;
|
||||||
|
final bool isEnabled;
|
||||||
|
final bool isChecked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immutable description of a renderer-specific option row.
|
||||||
|
class WolfMenuRendererOptionEntry {
|
||||||
|
const WolfMenuRendererOptionEntry({
|
||||||
|
required this.id,
|
||||||
|
required this.label,
|
||||||
|
this.isEnabled = true,
|
||||||
|
this.isChecked = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final WolfRendererOptionId id;
|
||||||
|
final String label;
|
||||||
|
final bool isEnabled;
|
||||||
|
final bool isChecked;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/// Menu screens handled by [MenuManager].
|
||||||
|
enum WolfMenuScreen {
|
||||||
|
introSplash,
|
||||||
|
mainMenu,
|
||||||
|
gameSelect,
|
||||||
|
episodeSelect,
|
||||||
|
difficultySelect,
|
||||||
|
changeView,
|
||||||
|
rendererOptions,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splash slides shown before the control-panel menu.
|
||||||
|
enum WolfIntroSlide { retailWarning, pg13, title }
|
||||||
|
|
||||||
|
/// Visual effect used when entering or leaving a menu surface.
|
||||||
|
enum WolfTransitionEffect { none, normalFade, fizzleFade }
|
||||||
|
|
||||||
|
/// Phase of a two-stage menu transition effect.
|
||||||
|
enum WolfTransitionPhase { idle, covering, revealing }
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
part of 'menu_manager.dart';
|
||||||
|
|
||||||
|
mixin _MenuManagerIntroMixin on _MenuManagerBase {
|
||||||
|
/// The currently visible intro slide.
|
||||||
|
WolfIntroSlide get currentIntroSlide {
|
||||||
|
if (_introSlides.isEmpty) {
|
||||||
|
return WolfIntroSlide.title;
|
||||||
|
}
|
||||||
|
final int index = _introSlideIndex.clamp(0, _introSlides.length - 1);
|
||||||
|
return _introSlides[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the retail warning card is currently visible.
|
||||||
|
bool get isIntroRetailWarningSlide =>
|
||||||
|
currentIntroSlide == WolfIntroSlide.retailWarning;
|
||||||
|
|
||||||
|
/// Whether the PG-13 splash is currently visible.
|
||||||
|
bool get isIntroPg13Slide => currentIntroSlide == WolfIntroSlide.pg13;
|
||||||
|
|
||||||
|
/// Whether the title splash is currently visible.
|
||||||
|
bool get isIntroTitleSlide => currentIntroSlide == WolfIntroSlide.title;
|
||||||
|
|
||||||
|
/// Background RGB used for the active intro slide.
|
||||||
|
int get introBackgroundRgb {
|
||||||
|
switch (currentIntroSlide) {
|
||||||
|
case WolfIntroSlide.retailWarning:
|
||||||
|
return _MenuManagerBase.introRetailBackgroundRgb;
|
||||||
|
case WolfIntroSlide.pg13:
|
||||||
|
return _MenuManagerBase.introPg13BackgroundRgb;
|
||||||
|
case WolfIntroSlide.title:
|
||||||
|
return _MenuManagerBase.introTitleBackgroundRgb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overlay alpha for the current intro transition.
|
||||||
|
double get introOverlayAlpha {
|
||||||
|
if (!isIntroSplashActive) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
switch (_introPhase) {
|
||||||
|
case _WolfIntroPhase.fadeIn:
|
||||||
|
return (1.0 - (_introElapsedMs / _MenuManagerBase.introFadeDurationMs))
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
|
case _WolfIntroPhase.hold:
|
||||||
|
return 0.0;
|
||||||
|
case _WolfIntroPhase.fadeOut:
|
||||||
|
return (_introElapsedMs / _MenuManagerBase.introFadeDurationMs).clamp(
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Effect currently applied to the intro overlay.
|
||||||
|
WolfTransitionEffect get introOverlayEffect {
|
||||||
|
if (!isIntroSplashActive || _introPhase == _WolfIntroPhase.hold) {
|
||||||
|
return WolfTransitionEffect.none;
|
||||||
|
}
|
||||||
|
return _introEffect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase currently applied to the intro overlay.
|
||||||
|
WolfTransitionPhase get introOverlayPhase {
|
||||||
|
if (!isIntroSplashActive) {
|
||||||
|
return WolfTransitionPhase.idle;
|
||||||
|
}
|
||||||
|
switch (_introPhase) {
|
||||||
|
case _WolfIntroPhase.fadeIn:
|
||||||
|
return WolfTransitionPhase.revealing;
|
||||||
|
case _WolfIntroPhase.hold:
|
||||||
|
return WolfTransitionPhase.idle;
|
||||||
|
case _WolfIntroPhase.fadeOut:
|
||||||
|
return WolfTransitionPhase.covering;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalized progress for the current intro overlay phase.
|
||||||
|
double get introOverlayPhaseProgress {
|
||||||
|
if (!isIntroSplashActive || _introPhase == _WolfIntroPhase.hold) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
return (_introElapsedMs / _MenuManagerBase.introFadeDurationMs).clamp(
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fade alpha for active menu transitions, in the range `0.0..1.0`.
|
||||||
|
double get transitionAlpha {
|
||||||
|
if (!isTransitioning) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
final int half = _MenuManagerBase.transitionDurationMs ~/ 2;
|
||||||
|
if (_transitionElapsedMs <= half) {
|
||||||
|
return (_transitionElapsedMs / half).clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
final int fadeInElapsed = _transitionElapsedMs - half;
|
||||||
|
return (1.0 - (fadeInElapsed / half)).clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Effect applied to the active transition.
|
||||||
|
WolfTransitionEffect get transitionEffect {
|
||||||
|
if (!isTransitioning) {
|
||||||
|
return WolfTransitionEffect.none;
|
||||||
|
}
|
||||||
|
return _transitionEffect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase of the current menu transition.
|
||||||
|
WolfTransitionPhase get transitionPhase {
|
||||||
|
if (!isTransitioning) {
|
||||||
|
return WolfTransitionPhase.idle;
|
||||||
|
}
|
||||||
|
final int half = _MenuManagerBase.transitionDurationMs ~/ 2;
|
||||||
|
if (_transitionElapsedMs < half) {
|
||||||
|
return WolfTransitionPhase.covering;
|
||||||
|
}
|
||||||
|
return WolfTransitionPhase.revealing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalized progress for the current menu transition phase.
|
||||||
|
double get transitionPhaseProgress {
|
||||||
|
if (!isTransitioning) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
final int half = _MenuManagerBase.transitionDurationMs ~/ 2;
|
||||||
|
if (_transitionElapsedMs < half) {
|
||||||
|
return (_transitionElapsedMs / half).clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
return ((_transitionElapsedMs - half) / half).clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resets menu state for startup and optionally begins the intro sequence.
|
||||||
|
@override
|
||||||
|
void beginSelectionFlow({
|
||||||
|
required int gameCount,
|
||||||
|
int initialGameIndex = 0,
|
||||||
|
int initialEpisodeIndex = 0,
|
||||||
|
Difficulty? initialDifficulty,
|
||||||
|
bool hasResumableGame = false,
|
||||||
|
bool hasLoadableSave = false,
|
||||||
|
bool initialGameIsRetail = false,
|
||||||
|
bool initialGameIsSpear = false,
|
||||||
|
WolfTransitionEffect introEffect = WolfTransitionEffect.normalFade,
|
||||||
|
}) {
|
||||||
|
_gameCount = gameCount;
|
||||||
|
_showResumeOption = hasResumableGame;
|
||||||
|
_hasLoadableSave = hasLoadableSave;
|
||||||
|
_selectedMainIndex = _defaultMainMenuIndex();
|
||||||
|
_selectedGameIndex = clampIndex(initialGameIndex, gameCount);
|
||||||
|
_selectedEpisodeIndex = initialEpisodeIndex < 0 ? 0 : initialEpisodeIndex;
|
||||||
|
_selectedDifficultyIndex = initialDifficulty == null
|
||||||
|
? 0
|
||||||
|
: Difficulty.values
|
||||||
|
.indexOf(initialDifficulty)
|
||||||
|
.clamp(0, Difficulty.values.length - 1);
|
||||||
|
_isSpearVariant = initialGameIsSpear;
|
||||||
|
_introLandingMenu = WolfMenuScreen.mainMenu;
|
||||||
|
if (gameCount > 1) {
|
||||||
|
_activeMenu = WolfMenuScreen.gameSelect;
|
||||||
|
_introEffect = introEffect;
|
||||||
|
_introElapsedMs = 0;
|
||||||
|
_introPhase = _WolfIntroPhase.fadeIn;
|
||||||
|
_introSlideIndex = 0;
|
||||||
|
_introSlides = <WolfIntroSlide>[
|
||||||
|
WolfIntroSlide.pg13,
|
||||||
|
WolfIntroSlide.title,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
_startIntroSequence(
|
||||||
|
includeRetailWarning: initialGameIsRetail,
|
||||||
|
effect: introEffect,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_transitionTarget = null;
|
||||||
|
_transitionEffect = WolfTransitionEffect.normalFade;
|
||||||
|
_transitionElapsedMs = 0;
|
||||||
|
_transitionSwappedMenu = false;
|
||||||
|
resetEdgeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the intro splash flow and lands on [landingMenu] when complete.
|
||||||
|
void beginIntroSplash({
|
||||||
|
WolfMenuScreen landingMenu = WolfMenuScreen.mainMenu,
|
||||||
|
bool includeRetailWarning = false,
|
||||||
|
WolfTransitionEffect effect = WolfTransitionEffect.normalFade,
|
||||||
|
}) {
|
||||||
|
_introLandingMenu = landingMenu;
|
||||||
|
_transitionTarget = null;
|
||||||
|
_transitionEffect = WolfTransitionEffect.normalFade;
|
||||||
|
_transitionElapsedMs = 0;
|
||||||
|
_transitionSwappedMenu = false;
|
||||||
|
_startIntroSequence(
|
||||||
|
includeRetailWarning: includeRetailWarning,
|
||||||
|
effect: effect,
|
||||||
|
);
|
||||||
|
resetEdgeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts a transition from the current menu to [target].
|
||||||
|
void startTransition(
|
||||||
|
WolfMenuScreen target, {
|
||||||
|
WolfTransitionEffect effect = WolfTransitionEffect.normalFade,
|
||||||
|
}) {
|
||||||
|
if (_activeMenu == target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_transitionTarget = target;
|
||||||
|
_transitionEffect = effect;
|
||||||
|
_transitionElapsedMs = 0;
|
||||||
|
_transitionSwappedMenu = false;
|
||||||
|
resetEdgeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances active splash or menu transitions by [deltaMs].
|
||||||
|
void tickTransition(int deltaMs) {
|
||||||
|
if (isIntroSplashActive) {
|
||||||
|
_tickIntro(deltaMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isTransitioning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_transitionElapsedMs += deltaMs;
|
||||||
|
final int half = _MenuManagerBase.transitionDurationMs ~/ 2;
|
||||||
|
if (!_transitionSwappedMenu && _transitionElapsedMs >= half) {
|
||||||
|
_activeMenu = _transitionTarget!;
|
||||||
|
_transitionSwappedMenu = true;
|
||||||
|
}
|
||||||
|
if (_transitionElapsedMs >= _MenuManagerBase.transitionDurationMs) {
|
||||||
|
_transitionTarget = null;
|
||||||
|
_transitionEffect = WolfTransitionEffect.normalFade;
|
||||||
|
_transitionElapsedMs = 0;
|
||||||
|
_transitionSwappedMenu = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumes input for the intro splash screen.
|
||||||
|
void updateIntroSplash(EngineInput input) {
|
||||||
|
if (!isIntroSplashActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final bool confirmNow = input.isInteracting;
|
||||||
|
if (confirmNow && !_prevConfirm) {
|
||||||
|
if (_introPhase == _WolfIntroPhase.fadeOut) {
|
||||||
|
} else if (_introPhase == _WolfIntroPhase.hold) {
|
||||||
|
_introPhase = _WolfIntroPhase.fadeOut;
|
||||||
|
_introElapsedMs = 0;
|
||||||
|
} else {
|
||||||
|
_introAdvanceRequested = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
consumeEdgeState(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startIntroSequence({
|
||||||
|
required bool includeRetailWarning,
|
||||||
|
required WolfTransitionEffect effect,
|
||||||
|
}) {
|
||||||
|
_activeMenu = WolfMenuScreen.introSplash;
|
||||||
|
_introEffect = effect;
|
||||||
|
_introSlides = includeRetailWarning
|
||||||
|
? <WolfIntroSlide>[
|
||||||
|
WolfIntroSlide.retailWarning,
|
||||||
|
WolfIntroSlide.pg13,
|
||||||
|
WolfIntroSlide.title,
|
||||||
|
]
|
||||||
|
: <WolfIntroSlide>[WolfIntroSlide.pg13, WolfIntroSlide.title];
|
||||||
|
_introSlideIndex = 0;
|
||||||
|
_introElapsedMs = 0;
|
||||||
|
_introPhase = _WolfIntroPhase.fadeIn;
|
||||||
|
_introAdvanceRequested = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _tickIntro(int deltaMs) {
|
||||||
|
if (!isIntroSplashActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_introElapsedMs += deltaMs;
|
||||||
|
|
||||||
|
switch (_introPhase) {
|
||||||
|
case _WolfIntroPhase.fadeIn:
|
||||||
|
if (_introElapsedMs >= _MenuManagerBase.introFadeDurationMs) {
|
||||||
|
_introElapsedMs = 0;
|
||||||
|
if (_introAdvanceRequested) {
|
||||||
|
_introPhase = _WolfIntroPhase.fadeOut;
|
||||||
|
_introAdvanceRequested = false;
|
||||||
|
} else {
|
||||||
|
_introPhase = _WolfIntroPhase.hold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case _WolfIntroPhase.hold:
|
||||||
|
_introElapsedMs = 0;
|
||||||
|
if (_introAdvanceRequested) {
|
||||||
|
_introPhase = _WolfIntroPhase.fadeOut;
|
||||||
|
_introAdvanceRequested = false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case _WolfIntroPhase.fadeOut:
|
||||||
|
if (_introElapsedMs >= _MenuManagerBase.introFadeDurationMs) {
|
||||||
|
_advanceIntroSlide();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _advanceIntroSlide() {
|
||||||
|
if (_introSlideIndex < _introSlides.length - 1) {
|
||||||
|
_introSlideIndex += 1;
|
||||||
|
_introElapsedMs = 0;
|
||||||
|
_introPhase = _WolfIntroPhase.fadeIn;
|
||||||
|
_introAdvanceRequested = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_activeMenu = _introLandingMenu;
|
||||||
|
_introElapsedMs = 0;
|
||||||
|
_introPhase = _WolfIntroPhase.fadeIn;
|
||||||
|
_introAdvanceRequested = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
part of 'menu_manager.dart';
|
||||||
|
|
||||||
|
mixin _MenuManagerNavigationMixin on _MenuManagerSelectionMixin {
|
||||||
|
/// Updates main-menu navigation and returns the selected action, if any.
|
||||||
|
({WolfMenuMainAction? selected, bool goBack}) updateMainMenu(
|
||||||
|
EngineInput input,
|
||||||
|
) {
|
||||||
|
if (isTransitioning) {
|
||||||
|
consumeEdgeState(input);
|
||||||
|
return (selected: null, goBack: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
final _MenuAction action = _updateLinearSelection(
|
||||||
|
input,
|
||||||
|
currentIndex: selectedMainIndex,
|
||||||
|
itemCount: mainMenuEntries.length,
|
||||||
|
isSelectableIndex: _isSelectableMainIndex,
|
||||||
|
);
|
||||||
|
_selectedMainIndex = action.index;
|
||||||
|
return (
|
||||||
|
selected: action.confirmed
|
||||||
|
? mainMenuEntries[_selectedMainIndex].action
|
||||||
|
: null,
|
||||||
|
goBack: action.goBack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates change-view navigation and returns either a mode or option choice.
|
||||||
|
({
|
||||||
|
WolfRendererMode? selectedMode,
|
||||||
|
WolfRendererOptionId? selectedOption,
|
||||||
|
bool goBack,
|
||||||
|
})
|
||||||
|
updateChangeViewMenu(EngineInput input) {
|
||||||
|
if (isTransitioning) {
|
||||||
|
consumeEdgeState(input);
|
||||||
|
return (
|
||||||
|
selectedMode: null,
|
||||||
|
selectedOption: null,
|
||||||
|
goBack: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final _MenuAction action = _updateLinearSelection(
|
||||||
|
input,
|
||||||
|
currentIndex: selectedChangeViewIndex,
|
||||||
|
itemCount: changeViewItemCount,
|
||||||
|
isSelectableIndex: _isSelectableChangeViewIndex,
|
||||||
|
);
|
||||||
|
_selectedChangeViewIndex = action.index;
|
||||||
|
|
||||||
|
if (!action.confirmed) {
|
||||||
|
return (
|
||||||
|
selectedMode: null,
|
||||||
|
selectedOption: null,
|
||||||
|
goBack: action.goBack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_selectedChangeViewIndex < _changeViewEntries.length) {
|
||||||
|
final WolfMenuRendererEntry entry =
|
||||||
|
_changeViewEntries[_selectedChangeViewIndex];
|
||||||
|
return (
|
||||||
|
selectedMode: entry.mode,
|
||||||
|
selectedOption: null,
|
||||||
|
goBack: action.goBack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int optionIndex =
|
||||||
|
_selectedChangeViewIndex - _changeViewEntries.length;
|
||||||
|
if (optionIndex < 0 || optionIndex >= _rendererOptionEntries.length) {
|
||||||
|
return (
|
||||||
|
selectedMode: null,
|
||||||
|
selectedOption: null,
|
||||||
|
goBack: action.goBack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_selectedRendererOptionIndex = optionIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
selectedMode: null,
|
||||||
|
selectedOption: _rendererOptionEntries[optionIndex].id,
|
||||||
|
goBack: action.goBack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates renderer-option navigation and returns the selected option, if any.
|
||||||
|
({WolfRendererOptionId? selectedOption, bool goBack})
|
||||||
|
updateRendererOptionsMenu(EngineInput input) {
|
||||||
|
if (isTransitioning) {
|
||||||
|
consumeEdgeState(input);
|
||||||
|
return (selectedOption: null, goBack: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
final _MenuAction action = _updateLinearSelection(
|
||||||
|
input,
|
||||||
|
currentIndex: selectedRendererOptionIndex,
|
||||||
|
itemCount: _rendererOptionEntries.length,
|
||||||
|
isSelectableIndex: _isSelectableRendererOptionIndex,
|
||||||
|
);
|
||||||
|
_selectedRendererOptionIndex = action.index;
|
||||||
|
|
||||||
|
return (
|
||||||
|
selectedOption: action.confirmed && _rendererOptionEntries.isNotEmpty
|
||||||
|
? _rendererOptionEntries[_selectedRendererOptionIndex].id
|
||||||
|
: null,
|
||||||
|
goBack: action.goBack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates difficulty selection and returns the selected difficulty, if any.
|
||||||
|
({Difficulty? selected, bool goBack}) updateDifficultySelection(
|
||||||
|
EngineInput input,
|
||||||
|
) {
|
||||||
|
if (isTransitioning) {
|
||||||
|
consumeEdgeState(input);
|
||||||
|
return (selected: null, goBack: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
final bool upNow = input.isMovingForward;
|
||||||
|
final bool downNow = input.isMovingBackward;
|
||||||
|
final bool confirmNow = input.isInteracting || input.isFiring;
|
||||||
|
final bool backNow = input.isBack;
|
||||||
|
|
||||||
|
if (upNow && !_prevUp) {
|
||||||
|
_selectedDifficultyIndex =
|
||||||
|
(_selectedDifficultyIndex - 1 + Difficulty.values.length) %
|
||||||
|
Difficulty.values.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (downNow && !_prevDown) {
|
||||||
|
_selectedDifficultyIndex =
|
||||||
|
(_selectedDifficultyIndex + 1) % Difficulty.values.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
Difficulty? selected;
|
||||||
|
if (confirmNow && !_prevConfirm) {
|
||||||
|
selected = Difficulty.values[_selectedDifficultyIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
final bool goBack = backNow && !_prevBack;
|
||||||
|
|
||||||
|
_prevUp = upNow;
|
||||||
|
_prevDown = downNow;
|
||||||
|
_prevConfirm = confirmNow;
|
||||||
|
_prevBack = backNow;
|
||||||
|
|
||||||
|
return (selected: selected, goBack: goBack);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates game selection and returns the selected row index, if any.
|
||||||
|
({int? selectedIndex, bool goBack}) updateGameSelection(
|
||||||
|
EngineInput input, {
|
||||||
|
required int gameCount,
|
||||||
|
}) {
|
||||||
|
if (isTransitioning) {
|
||||||
|
consumeEdgeState(input);
|
||||||
|
return (selectedIndex: null, goBack: false);
|
||||||
|
}
|
||||||
|
final _MenuAction action = _updateLinearSelection(
|
||||||
|
input,
|
||||||
|
currentIndex: selectedGameIndex,
|
||||||
|
itemCount: gameCount,
|
||||||
|
);
|
||||||
|
_selectedGameIndex = action.index;
|
||||||
|
return (
|
||||||
|
selectedIndex: action.confirmed ? _selectedGameIndex : null,
|
||||||
|
goBack: action.goBack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates episode selection and returns the selected row index, if any.
|
||||||
|
({int? selectedIndex, bool goBack}) updateEpisodeSelection(
|
||||||
|
EngineInput input, {
|
||||||
|
required int episodeCount,
|
||||||
|
}) {
|
||||||
|
if (isTransitioning) {
|
||||||
|
consumeEdgeState(input);
|
||||||
|
return (selectedIndex: null, goBack: false);
|
||||||
|
}
|
||||||
|
final _MenuAction action = _updateLinearSelection(
|
||||||
|
input,
|
||||||
|
currentIndex: selectedEpisodeIndex,
|
||||||
|
itemCount: episodeCount,
|
||||||
|
);
|
||||||
|
_selectedEpisodeIndex = action.index;
|
||||||
|
return (
|
||||||
|
selectedIndex: action.confirmed ? _selectedEpisodeIndex : null,
|
||||||
|
goBack: action.goBack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_MenuAction _updateLinearSelection(
|
||||||
|
EngineInput input, {
|
||||||
|
required int currentIndex,
|
||||||
|
required int itemCount,
|
||||||
|
bool Function(int index)? isSelectableIndex,
|
||||||
|
}) {
|
||||||
|
final bool upNow = input.isMovingForward;
|
||||||
|
final bool downNow = input.isMovingBackward;
|
||||||
|
final bool confirmNow = input.isInteracting || input.isFiring;
|
||||||
|
final bool backNow = input.isBack;
|
||||||
|
|
||||||
|
int nextIndex = clampIndex(currentIndex, itemCount);
|
||||||
|
final bool Function(int index) selectable =
|
||||||
|
isSelectableIndex ?? ((_) => true);
|
||||||
|
|
||||||
|
if (itemCount > 0 && !selectable(nextIndex)) {
|
||||||
|
nextIndex = findSelectableIndex(nextIndex, itemCount, selectable);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemCount > 0) {
|
||||||
|
if (upNow && !_prevUp) {
|
||||||
|
nextIndex = moveSelectableIndex(nextIndex, itemCount, -1, selectable);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (downNow && !_prevDown) {
|
||||||
|
nextIndex = moveSelectableIndex(nextIndex, itemCount, 1, selectable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final bool confirmed = confirmNow && !_prevConfirm && selectable(nextIndex);
|
||||||
|
final bool goBack = backNow && !_prevBack;
|
||||||
|
|
||||||
|
_prevUp = upNow;
|
||||||
|
_prevDown = downNow;
|
||||||
|
_prevConfirm = confirmNow;
|
||||||
|
_prevBack = backNow;
|
||||||
|
|
||||||
|
return _MenuAction(index: nextIndex, confirmed: confirmed, goBack: goBack);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
part of 'menu_manager.dart';
|
||||||
|
|
||||||
|
mixin _MenuManagerSelectionMixin on _MenuManagerBase {
|
||||||
|
/// Index of the selected main-menu row.
|
||||||
|
int get selectedMainIndex => _selectedMainIndex;
|
||||||
|
|
||||||
|
/// Index of the selected game row.
|
||||||
|
int get selectedGameIndex => _selectedGameIndex;
|
||||||
|
|
||||||
|
/// Index of the selected episode row.
|
||||||
|
int get selectedEpisodeIndex => _selectedEpisodeIndex;
|
||||||
|
|
||||||
|
/// Index of the selected row within the change-view menu.
|
||||||
|
int get selectedChangeViewIndex => _selectedChangeViewIndex;
|
||||||
|
|
||||||
|
/// Index of the selected renderer-options row.
|
||||||
|
int get selectedRendererOptionIndex => _selectedRendererOptionIndex;
|
||||||
|
|
||||||
|
/// Index of the selected difficulty row.
|
||||||
|
int get selectedDifficultyIndex => _selectedDifficultyIndex;
|
||||||
|
|
||||||
|
/// Title shown above renderer-specific options.
|
||||||
|
String get rendererOptionsTitle => _rendererOptionsTitle;
|
||||||
|
|
||||||
|
/// Renderer entries shown in the change-view menu.
|
||||||
|
List<WolfMenuRendererEntry> get changeViewEntries =>
|
||||||
|
List<WolfMenuRendererEntry>.unmodifiable(_changeViewEntries);
|
||||||
|
|
||||||
|
/// Renderer option entries shown in the customize menu.
|
||||||
|
List<WolfMenuRendererOptionEntry> get rendererOptionEntries =>
|
||||||
|
List<WolfMenuRendererOptionEntry>.unmodifiable(_rendererOptionEntries);
|
||||||
|
|
||||||
|
/// The currently active menu screen.
|
||||||
|
WolfMenuScreen get activeMenu => _activeMenu;
|
||||||
|
|
||||||
|
/// Background RGB used by menu renderers.
|
||||||
|
int get menuBackgroundRgb => _menuBackgroundRgb;
|
||||||
|
|
||||||
|
set menuBackgroundRgb(int value) {
|
||||||
|
_menuBackgroundRgb = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immutable snapshot of the current main-menu rows.
|
||||||
|
List<WolfMenuMainEntry> get mainMenuEntries {
|
||||||
|
final List<WolfMenuMainEntry> entries = <WolfMenuMainEntry>[
|
||||||
|
_mainMenuEntry(
|
||||||
|
action: WolfMenuMainAction.newGame,
|
||||||
|
label: 'NEW GAME',
|
||||||
|
),
|
||||||
|
_mainMenuEntry(
|
||||||
|
action: WolfMenuMainAction.sound,
|
||||||
|
label: 'SOUND',
|
||||||
|
),
|
||||||
|
_mainMenuEntry(
|
||||||
|
action: WolfMenuMainAction.control,
|
||||||
|
label: 'CONTROL',
|
||||||
|
),
|
||||||
|
_mainMenuEntry(
|
||||||
|
action: WolfMenuMainAction.loadGame,
|
||||||
|
label: 'LOAD GAME',
|
||||||
|
),
|
||||||
|
_mainMenuEntry(
|
||||||
|
action: WolfMenuMainAction.saveGame,
|
||||||
|
label: 'SAVE GAME',
|
||||||
|
),
|
||||||
|
_mainMenuEntry(
|
||||||
|
action: WolfMenuMainAction.changeView,
|
||||||
|
label: 'CHANGE VIEW',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!_isSpearVariant) {
|
||||||
|
entries.add(
|
||||||
|
_mainMenuEntry(
|
||||||
|
action: WolfMenuMainAction.readThis,
|
||||||
|
label: 'READ THIS!',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.add(
|
||||||
|
_mainMenuEntry(
|
||||||
|
action: _showResumeOption
|
||||||
|
? WolfMenuMainAction.endGame
|
||||||
|
: WolfMenuMainAction.viewScores,
|
||||||
|
label: _showResumeOption ? 'END GAME' : 'VIEW SCORES',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
entries.add(
|
||||||
|
_mainMenuEntry(
|
||||||
|
action: _showResumeOption
|
||||||
|
? WolfMenuMainAction.backToGame
|
||||||
|
: WolfMenuMainAction.backToDemo,
|
||||||
|
label: _showResumeOption ? 'BACK TO GAME' : 'BACK TO DEMO',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
entries.add(_mainMenuEntry(action: WolfMenuMainAction.quit, label: 'QUIT'));
|
||||||
|
|
||||||
|
return List<WolfMenuMainEntry>.unmodifiable(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates menu variant flags from the selected game version.
|
||||||
|
void setCurrentGameVersion(GameVersion version) {
|
||||||
|
_isSpearVariant =
|
||||||
|
version == GameVersion.spearOfDestiny ||
|
||||||
|
version == GameVersion.spearOfDestinyDemo;
|
||||||
|
_selectedMainIndex = clampIndex(_selectedMainIndex, mainMenuEntries.length);
|
||||||
|
if (!_isSelectableMainIndex(_selectedMainIndex)) {
|
||||||
|
_selectedMainIndex = findSelectableIndex(
|
||||||
|
_selectedMainIndex,
|
||||||
|
mainMenuEntries.length,
|
||||||
|
_isSelectableMainIndex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the main menu can return to the game-selection step.
|
||||||
|
bool get canGoBackToGameSelection => !_showResumeOption && _gameCount > 1;
|
||||||
|
|
||||||
|
/// Resets state for a fresh difficulty-only selection flow.
|
||||||
|
void beginDifficultySelection({Difficulty? initialDifficulty}) {
|
||||||
|
beginSelectionFlow(
|
||||||
|
gameCount: 1,
|
||||||
|
initialGameIndex: 0,
|
||||||
|
initialEpisodeIndex: 0,
|
||||||
|
initialDifficulty: initialDifficulty,
|
||||||
|
);
|
||||||
|
_activeMenu = WolfMenuScreen.difficultySelect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuilds the main menu for the current runtime state.
|
||||||
|
void showMainMenu({
|
||||||
|
required bool hasResumableGame,
|
||||||
|
bool? hasLoadableSave,
|
||||||
|
}) {
|
||||||
|
_showResumeOption = hasResumableGame;
|
||||||
|
if (hasLoadableSave != null) {
|
||||||
|
_hasLoadableSave = hasLoadableSave;
|
||||||
|
}
|
||||||
|
final int itemCount = mainMenuEntries.length;
|
||||||
|
if (itemCount == 0) {
|
||||||
|
_selectedMainIndex = 0;
|
||||||
|
} else {
|
||||||
|
_selectedMainIndex = _defaultMainMenuIndex();
|
||||||
|
}
|
||||||
|
_activeMenu = WolfMenuScreen.mainMenu;
|
||||||
|
_transitionTarget = null;
|
||||||
|
_transitionEffect = WolfTransitionEffect.normalFade;
|
||||||
|
_transitionElapsedMs = 0;
|
||||||
|
_transitionSwappedMenu = false;
|
||||||
|
_introEffect = WolfTransitionEffect.normalFade;
|
||||||
|
_introElapsedMs = 0;
|
||||||
|
resetEdgeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates whether the LOAD GAME row is selectable.
|
||||||
|
void setLoadGameAvailable(bool isAvailable) {
|
||||||
|
if (_hasLoadableSave == isAvailable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_hasLoadableSave = isAvailable;
|
||||||
|
final int itemCount = mainMenuEntries.length;
|
||||||
|
if (itemCount <= 0 || !_isSelectableMainIndex(_selectedMainIndex)) {
|
||||||
|
_selectedMainIndex = findSelectableIndex(
|
||||||
|
clampIndex(_selectedMainIndex, itemCount),
|
||||||
|
itemCount,
|
||||||
|
_isSelectableMainIndex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the renderer rows displayed in the change-view menu.
|
||||||
|
void setChangeViewEntries(List<WolfMenuRendererEntry> entries) {
|
||||||
|
final WolfRendererMode? previouslySelectedMode =
|
||||||
|
(_selectedChangeViewIndex >= 0 &&
|
||||||
|
_selectedChangeViewIndex < _changeViewEntries.length)
|
||||||
|
? _changeViewEntries[_selectedChangeViewIndex].mode
|
||||||
|
: null;
|
||||||
|
|
||||||
|
_changeViewEntries = List<WolfMenuRendererEntry>.unmodifiable(entries);
|
||||||
|
|
||||||
|
final int itemCount = changeViewItemCount;
|
||||||
|
if (itemCount == 0) {
|
||||||
|
_selectedChangeViewIndex = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previouslySelectedMode != null) {
|
||||||
|
final int modeIndex = _changeViewEntries.indexWhere(
|
||||||
|
(entry) => entry.mode == previouslySelectedMode,
|
||||||
|
);
|
||||||
|
if (modeIndex >= 0 && _isSelectableChangeViewIndex(modeIndex)) {
|
||||||
|
_selectedChangeViewIndex = modeIndex;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectedChangeViewIndex = findSelectableIndex(
|
||||||
|
clampIndex(_selectedChangeViewIndex, itemCount),
|
||||||
|
itemCount,
|
||||||
|
_isSelectableChangeViewIndex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the renderer-specific option rows displayed in the customize menu.
|
||||||
|
void setRendererOptionEntries({
|
||||||
|
required String title,
|
||||||
|
required List<WolfMenuRendererOptionEntry> entries,
|
||||||
|
}) {
|
||||||
|
final bool wasSelectingOption =
|
||||||
|
_selectedChangeViewIndex >= _changeViewEntries.length;
|
||||||
|
final WolfRendererOptionId? previousOption =
|
||||||
|
(_selectedRendererOptionIndex >= 0 &&
|
||||||
|
_selectedRendererOptionIndex < _rendererOptionEntries.length)
|
||||||
|
? _rendererOptionEntries[_selectedRendererOptionIndex].id
|
||||||
|
: null;
|
||||||
|
|
||||||
|
_rendererOptionsTitle = title;
|
||||||
|
_rendererOptionEntries = List<WolfMenuRendererOptionEntry>.unmodifiable(
|
||||||
|
entries,
|
||||||
|
);
|
||||||
|
|
||||||
|
final int totalCount = changeViewItemCount;
|
||||||
|
if (_rendererOptionEntries.isEmpty || totalCount == 0) {
|
||||||
|
_selectedRendererOptionIndex = 0;
|
||||||
|
if (_changeViewEntries.isNotEmpty) {
|
||||||
|
_selectedChangeViewIndex = findSelectableIndex(
|
||||||
|
0,
|
||||||
|
_changeViewEntries.length,
|
||||||
|
_isSelectableChangeViewIndex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousOption != null) {
|
||||||
|
final int previousIndex = _rendererOptionEntries.indexWhere(
|
||||||
|
(entry) => entry.id == previousOption,
|
||||||
|
);
|
||||||
|
if (previousIndex >= 0 &&
|
||||||
|
_isSelectableRendererOptionIndex(previousIndex)) {
|
||||||
|
_selectedRendererOptionIndex = previousIndex;
|
||||||
|
if (wasSelectingOption) {
|
||||||
|
_selectedChangeViewIndex = _changeViewEntries.length + previousIndex;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectedRendererOptionIndex = findSelectableIndex(
|
||||||
|
clampIndex(_selectedRendererOptionIndex, _rendererOptionEntries.length),
|
||||||
|
_rendererOptionEntries.length,
|
||||||
|
_isSelectableRendererOptionIndex,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (wasSelectingOption) {
|
||||||
|
_selectedChangeViewIndex =
|
||||||
|
_changeViewEntries.length + _selectedRendererOptionIndex;
|
||||||
|
} else {
|
||||||
|
_selectedChangeViewIndex = findSelectableIndex(
|
||||||
|
clampIndex(_selectedChangeViewIndex, totalCount),
|
||||||
|
totalCount,
|
||||||
|
_isSelectableChangeViewIndex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switches the active menu to the renderer-selection screen.
|
||||||
|
void showChangeViewMenu() {
|
||||||
|
_activeMenu = WolfMenuScreen.changeView;
|
||||||
|
_selectedChangeViewIndex = changeViewItemCount == 0
|
||||||
|
? 0
|
||||||
|
: findSelectableIndex(
|
||||||
|
0,
|
||||||
|
changeViewItemCount,
|
||||||
|
_isSelectableChangeViewIndex,
|
||||||
|
);
|
||||||
|
_transitionTarget = null;
|
||||||
|
_transitionEffect = WolfTransitionEffect.normalFade;
|
||||||
|
_transitionElapsedMs = 0;
|
||||||
|
_transitionSwappedMenu = false;
|
||||||
|
resetEdgeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switches the active menu to the renderer-options screen.
|
||||||
|
void showRendererOptionsMenu() {
|
||||||
|
_activeMenu = WolfMenuScreen.rendererOptions;
|
||||||
|
_selectedRendererOptionIndex = _rendererOptionEntries.isEmpty
|
||||||
|
? 0
|
||||||
|
: findSelectableIndex(
|
||||||
|
0,
|
||||||
|
_rendererOptionEntries.length,
|
||||||
|
_isSelectableRendererOptionIndex,
|
||||||
|
);
|
||||||
|
_transitionTarget = null;
|
||||||
|
_transitionEffect = WolfTransitionEffect.normalFade;
|
||||||
|
_transitionElapsedMs = 0;
|
||||||
|
_transitionSwappedMenu = false;
|
||||||
|
resetEdgeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears the selected episode back to the first row.
|
||||||
|
void clearEpisodeSelection() {
|
||||||
|
_selectedEpisodeIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumes the current input snapshot as the new edge baseline.
|
||||||
|
void absorbInputState(EngineInput input) {
|
||||||
|
consumeEdgeState(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores the current episode selection, clamped to the available row count.
|
||||||
|
void setSelectedEpisodeIndex(int index, int episodeCount) {
|
||||||
|
_selectedEpisodeIndex = clampIndex(index, episodeCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores the current game selection, clamped to the available row count.
|
||||||
|
void setSelectedGameIndex(int index, int gameCount) {
|
||||||
|
_selectedGameIndex = clampIndex(index, gameCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int _defaultMainMenuIndex() {
|
||||||
|
final WolfMenuMainAction target = _showResumeOption
|
||||||
|
? WolfMenuMainAction.backToGame
|
||||||
|
: WolfMenuMainAction.newGame;
|
||||||
|
final int found = mainMenuEntries.indexWhere(
|
||||||
|
(entry) => entry.action == target,
|
||||||
|
);
|
||||||
|
return found >= 0
|
||||||
|
? found
|
||||||
|
: findSelectableIndex(
|
||||||
|
0,
|
||||||
|
mainMenuEntries.length,
|
||||||
|
_isSelectableMainIndex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isSelectableMainIndex(int index) {
|
||||||
|
if (index < 0 || index >= mainMenuEntries.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return mainMenuEntries[index].isEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isSelectableChangeViewIndex(int index) {
|
||||||
|
if (index < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (index < _changeViewEntries.length) {
|
||||||
|
return _changeViewEntries[index].isEnabled;
|
||||||
|
}
|
||||||
|
final int optionIndex = index - _changeViewEntries.length;
|
||||||
|
if (optionIndex >= 0 && optionIndex < _rendererOptionEntries.length) {
|
||||||
|
return _rendererOptionEntries[optionIndex].isEnabled;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isSelectableRendererOptionIndex(int index) {
|
||||||
|
if (index < 0 || index >= _rendererOptionEntries.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return _rendererOptionEntries[index].isEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isWiredMainMenuAction(WolfMenuMainAction action) {
|
||||||
|
switch (action) {
|
||||||
|
case WolfMenuMainAction.newGame:
|
||||||
|
case WolfMenuMainAction.loadGame:
|
||||||
|
case WolfMenuMainAction.saveGame:
|
||||||
|
case WolfMenuMainAction.endGame:
|
||||||
|
case WolfMenuMainAction.backToGame:
|
||||||
|
case WolfMenuMainAction.backToDemo:
|
||||||
|
case WolfMenuMainAction.quit:
|
||||||
|
case WolfMenuMainAction.changeView:
|
||||||
|
return true;
|
||||||
|
case WolfMenuMainAction.sound:
|
||||||
|
case WolfMenuMainAction.control:
|
||||||
|
case WolfMenuMainAction.readThis:
|
||||||
|
case WolfMenuMainAction.viewScores:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WolfMenuMainEntry _mainMenuEntry({
|
||||||
|
required WolfMenuMainAction action,
|
||||||
|
required String label,
|
||||||
|
}) {
|
||||||
|
bool isEnabled = _isWiredMainMenuAction(action);
|
||||||
|
if (action == WolfMenuMainAction.loadGame) {
|
||||||
|
isEnabled = isEnabled && _hasLoadableSave;
|
||||||
|
}
|
||||||
|
if (action == WolfMenuMainAction.saveGame) {
|
||||||
|
isEnabled = isEnabled && _showResumeOption;
|
||||||
|
}
|
||||||
|
|
||||||
|
return WolfMenuMainEntry(
|
||||||
|
action: action,
|
||||||
|
label: label,
|
||||||
|
isEnabled: isEnabled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
|||||||
|
/// Known VGA picture indexes used by the original Wolf3D control-panel menus.
|
||||||
|
///
|
||||||
|
/// Values below are picture-table indexes (not raw chunk ids).
|
||||||
|
/// For example, `C_CONTROLPIC` is chunk 26 in `GFXV_WL6.H`, so its picture
|
||||||
|
/// index is `26 - STARTPICS(3) = 23`.
|
||||||
|
abstract class WolfMenuPic {
|
||||||
|
static const int hBj = 0; // H_BJPIC
|
||||||
|
static const int hTopWindow = 3; // H_TOPWINDOWPIC
|
||||||
|
static const int cOptions = 7; // C_OPTIONSPIC
|
||||||
|
static const int cCursor1 = 8; // C_CURSOR1PIC
|
||||||
|
static const int cCursor2 = 9; // C_CURSOR2PIC
|
||||||
|
static const int cNotSelected = 10; // C_NOTSELECTEDPIC
|
||||||
|
static const int cSelected = 11; // C_SELECTEDPIC
|
||||||
|
static const int cBabyMode = 16; // C_BABYMODEPIC
|
||||||
|
static const int cEasy = 17; // C_EASYPIC
|
||||||
|
static const int cNormal = 18; // C_NORMALPIC
|
||||||
|
static const int cHard = 19; // C_HARDPIC
|
||||||
|
static const int cControl = 23; // C_CONTROLPIC
|
||||||
|
static const int cCustomize = 24; // C_CUSTOMIZEPIC
|
||||||
|
static const int cEpisode1 = 27; // C_EPISODE1PIC
|
||||||
|
static const int cEpisode2 = 28; // C_EPISODE2PIC
|
||||||
|
static const int cEpisode3 = 29; // C_EPISODE3PIC
|
||||||
|
static const int cEpisode4 = 30; // C_EPISODE4PIC
|
||||||
|
static const int cEpisode5 = 31; // C_EPISODE5PIC
|
||||||
|
static const int cEpisode6 = 32; // C_EPISODE6PIC
|
||||||
|
static const int statusBar = 83; // STATUSBARPIC
|
||||||
|
static const int title = 84; // TITLEPIC
|
||||||
|
static const int pg13 = 85; // PG13PIC
|
||||||
|
static const int credits = 86; // CREDITSPIC
|
||||||
|
static const int highScores = 87; // HIGHSCORESPIC
|
||||||
|
|
||||||
|
static const List<int> episodePics = [
|
||||||
|
cEpisode1,
|
||||||
|
cEpisode2,
|
||||||
|
cEpisode3,
|
||||||
|
cEpisode4,
|
||||||
|
cEpisode5,
|
||||||
|
cEpisode6,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_menu_presentation_module.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/classic_menu_presentation_module.dart';
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||||
|
|
||||||
|
/// Bound access to the active menu presentation for a loaded data set.
|
||||||
|
///
|
||||||
|
/// Renderers should construct this from [WolfensteinData] and consume its
|
||||||
|
/// color and art accessors instead of hard-coding variant-specific menu rules.
|
||||||
|
class WolfMenuPresentation {
|
||||||
|
/// Loaded game data used to resolve art assets.
|
||||||
|
///
|
||||||
|
/// This is `null` for host-owned fallback presentations such as setup
|
||||||
|
/// screens that need menu colors before any game data has been loaded.
|
||||||
|
final WolfensteinData? data;
|
||||||
|
|
||||||
|
/// Presentation module that supplies colors and symbolic art lookups.
|
||||||
|
final MenuPresentationModule _module;
|
||||||
|
|
||||||
|
/// Binds the active menu presentation from [data.registry].
|
||||||
|
factory WolfMenuPresentation(WolfensteinData data) {
|
||||||
|
return WolfMenuPresentation.module(
|
||||||
|
data.registry.menuPresentation,
|
||||||
|
data: data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a presentation from an explicit module without loaded game data.
|
||||||
|
///
|
||||||
|
/// This is useful for host UI that wants menu-consistent colors before any
|
||||||
|
/// game assets have been discovered.
|
||||||
|
const WolfMenuPresentation.module(this._module, {this.data});
|
||||||
|
|
||||||
|
/// Classic fallback presentation for host-owned UI outside a loaded game.
|
||||||
|
const WolfMenuPresentation.classic()
|
||||||
|
: this.module(const ClassicMenuPresentationModule());
|
||||||
|
|
||||||
|
/// Spear fallback presentation for host-owned UI outside a loaded game.
|
||||||
|
const WolfMenuPresentation.spear()
|
||||||
|
: this.module(const SpearMenuPresentationModule());
|
||||||
|
|
||||||
|
/// VGA palette index used for menu background fills and header band accents.
|
||||||
|
int get backgroundIndex => _module.backgroundIndex;
|
||||||
|
|
||||||
|
/// VGA palette index used for menu panel fills.
|
||||||
|
int get panelIndex => _module.panelIndex;
|
||||||
|
|
||||||
|
/// VGA palette index used for menu panel borders and separators.
|
||||||
|
int get borderIndex => _module.borderIndex;
|
||||||
|
|
||||||
|
/// VGA palette index used for emphasized or affirmative UI text.
|
||||||
|
int get emphasisIndex => _module.emphasisIndex;
|
||||||
|
|
||||||
|
/// VGA palette index used for warnings and cautionary text.
|
||||||
|
int get warningIndex => _module.warningIndex;
|
||||||
|
|
||||||
|
/// VGA palette index used for subdued or de-emphasized text.
|
||||||
|
int get mutedIndex => _module.mutedIndex;
|
||||||
|
|
||||||
|
/// VGA palette index used for the selected menu row text.
|
||||||
|
int get selectedTextIndex => _module.selectedTextIndex;
|
||||||
|
|
||||||
|
/// VGA palette index used for normal menu row text.
|
||||||
|
int get unselectedTextIndex => _module.unselectedTextIndex;
|
||||||
|
|
||||||
|
/// VGA palette index used for disabled menu row text.
|
||||||
|
int get disabledTextIndex => _module.disabledTextIndex;
|
||||||
|
|
||||||
|
/// VGA palette index used for headings.
|
||||||
|
int get headerTextIndex => _module.headerTextIndex;
|
||||||
|
|
||||||
|
/// Background color resolved to `0xAARRGGBB`.
|
||||||
|
int get backgroundColor => ColorPalette.argbFromVgaIndex(backgroundIndex);
|
||||||
|
|
||||||
|
/// Panel fill color resolved to `0xAARRGGBB`.
|
||||||
|
int get panelColor => ColorPalette.argbFromVgaIndex(panelIndex);
|
||||||
|
|
||||||
|
/// Border color resolved to `0xAARRGGBB`.
|
||||||
|
int get borderColor => ColorPalette.argbFromVgaIndex(borderIndex);
|
||||||
|
|
||||||
|
/// Heading color resolved to `0xAARRGGBB`.
|
||||||
|
int get titleColor => ColorPalette.argbFromVgaIndex(headerTextIndex);
|
||||||
|
|
||||||
|
/// Standard body text color resolved to `0xAARRGGBB`.
|
||||||
|
int get bodyColor => ColorPalette.argbFromVgaIndex(unselectedTextIndex);
|
||||||
|
|
||||||
|
/// Emphasis color resolved to `0xAARRGGBB`.
|
||||||
|
int get emphasisColor => ColorPalette.argbFromVgaIndex(emphasisIndex);
|
||||||
|
|
||||||
|
/// Warning color resolved to `0xAARRGGBB`.
|
||||||
|
int get warningColor => ColorPalette.argbFromVgaIndex(warningIndex);
|
||||||
|
|
||||||
|
/// Muted color resolved to `0xAARRGGBB`.
|
||||||
|
int get mutedColor => ColorPalette.argbFromVgaIndex(mutedIndex);
|
||||||
|
|
||||||
|
/// Selected text color resolved through the VGA 32-bit table.
|
||||||
|
int get selectedTextColor => ColorPalette.vga32Bit[selectedTextIndex];
|
||||||
|
|
||||||
|
/// Normal text color resolved through the VGA 32-bit table.
|
||||||
|
int get unselectedTextColor => ColorPalette.vga32Bit[unselectedTextIndex];
|
||||||
|
|
||||||
|
/// Disabled text color resolved through the VGA 32-bit table.
|
||||||
|
int get disabledTextColor => ColorPalette.vga32Bit[disabledTextIndex];
|
||||||
|
|
||||||
|
/// Heading text color resolved through the VGA 32-bit table.
|
||||||
|
int get headerTextColor => ColorPalette.vga32Bit[headerTextIndex];
|
||||||
|
|
||||||
|
/// Background image used by the controls/customize panel, if available.
|
||||||
|
VgaImage? get controlBackground =>
|
||||||
|
data == null ? null : _module.controlBackground(data!);
|
||||||
|
|
||||||
|
/// Title splash image, if this presentation exposes one.
|
||||||
|
VgaImage? get title => data == null ? null : _module.title(data!);
|
||||||
|
|
||||||
|
/// Main menu heading art, if available.
|
||||||
|
VgaImage? get heading => data == null ? null : _module.heading(data!);
|
||||||
|
|
||||||
|
/// Selected checkbox or marker image, if available.
|
||||||
|
VgaImage? get selectedMarker =>
|
||||||
|
data == null ? null : _module.selectedMarker(data!);
|
||||||
|
|
||||||
|
/// Unselected checkbox or marker image, if available.
|
||||||
|
VgaImage? get unselectedMarker =>
|
||||||
|
data == null ? null : _module.unselectedMarker(data!);
|
||||||
|
|
||||||
|
/// Main menu options banner image, if available.
|
||||||
|
VgaImage? get optionsLabel =>
|
||||||
|
data == null ? null : _module.optionsLabel(data!);
|
||||||
|
|
||||||
|
/// Customize/options heading image, if available.
|
||||||
|
VgaImage? get customizeLabel =>
|
||||||
|
data == null ? null : _module.customizeLabel(data!);
|
||||||
|
|
||||||
|
/// Credits image, if available.
|
||||||
|
VgaImage? get credits => data == null ? null : _module.credits(data!);
|
||||||
|
|
||||||
|
/// Episode selection art for the zero-based [episodeIndex], if available.
|
||||||
|
VgaImage? episodeOption(int episodeIndex) {
|
||||||
|
return data == null ? null : _module.episodeOption(data!, episodeIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Difficulty selection art for [difficulty], if available.
|
||||||
|
VgaImage? difficultyOption(Difficulty difficulty) {
|
||||||
|
return data == null ? null : _module.difficultyOption(data!, difficulty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy numeric art lookup for classic renderer code paths.
|
||||||
|
///
|
||||||
|
/// Returns `null` when no loaded data is attached or when the requested art
|
||||||
|
/// does not exist in the active presentation.
|
||||||
|
VgaImage? mappedPic(int index) {
|
||||||
|
return data == null ? null : _module.mappedPic(data!, index);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/// Returns the platform-appropriate Wolf3D config directory path.
|
||||||
|
///
|
||||||
|
/// This file is only ever imported by native (dart:io) code paths and must
|
||||||
|
/// never be loaded on web.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
/// Returns the Wolf3D config directory for the current platform.
|
||||||
|
///
|
||||||
|
/// - Linux: `$XDG_CONFIG_HOME/wolf3d` (defaults to `~/.config/wolf3d`)
|
||||||
|
/// - macOS: `~/Library/Application Support/wolf3d`
|
||||||
|
/// - Windows: `%APPDATA%/wolf3d`
|
||||||
|
/// - Other: `~/.config/wolf3d`
|
||||||
|
String platformConfigDir() {
|
||||||
|
if (Platform.isLinux) {
|
||||||
|
final String xdg = Platform.environment['XDG_CONFIG_HOME'] ?? '';
|
||||||
|
final String home = Platform.environment['HOME'] ?? '.';
|
||||||
|
return xdg.isNotEmpty ? '$xdg/wolf3d' : '$home/.config/wolf3d';
|
||||||
|
}
|
||||||
|
if (Platform.isMacOS) {
|
||||||
|
final String home = Platform.environment['HOME'] ?? '.';
|
||||||
|
return '$home/Library/Application Support/wolf3d';
|
||||||
|
}
|
||||||
|
if (Platform.isWindows) {
|
||||||
|
final String appData = Platform.environment['APPDATA'] ?? '.';
|
||||||
|
return '$appData/wolf3d';
|
||||||
|
}
|
||||||
|
final String home = Platform.environment['HOME'] ?? '.';
|
||||||
|
return '$home/.config/wolf3d';
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:wolf_3d_dart/src/registry/modules/entity_asset_module.dart';
|
import 'package:wolf_3d_dart/src/registry/modules/entity_asset_module.dart';
|
||||||
import 'package:wolf_3d_dart/src/registry/modules/hud_module.dart';
|
import 'package:wolf_3d_dart/src/registry/modules/hud_module.dart';
|
||||||
import 'package:wolf_3d_dart/src/registry/modules/menu_pic_module.dart';
|
import 'package:wolf_3d_dart/src/registry/modules/menu_pic_module.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/registry/modules/menu_presentation_module.dart';
|
||||||
import 'package:wolf_3d_dart/src/registry/modules/music_module.dart';
|
import 'package:wolf_3d_dart/src/registry/modules/music_module.dart';
|
||||||
import 'package:wolf_3d_dart/src/registry/modules/sfx_module.dart';
|
import 'package:wolf_3d_dart/src/registry/modules/sfx_module.dart';
|
||||||
|
|
||||||
@@ -14,9 +15,10 @@ import 'package:wolf_3d_dart/src/registry/modules/sfx_module.dart';
|
|||||||
/// data.registry.entities.resolve(EntityKey.guard)
|
/// data.registry.entities.resolve(EntityKey.guard)
|
||||||
/// data.registry.hud.faceForHealth(player.health)
|
/// data.registry.hud.faceForHealth(player.health)
|
||||||
/// data.registry.menu.resolve(MenuPicKey.title)
|
/// data.registry.menu.resolve(MenuPicKey.title)
|
||||||
|
/// data.registry.menuPresentation.headerTextIndex
|
||||||
/// ```
|
/// ```
|
||||||
///
|
///
|
||||||
/// To provide a fully custom asset layout, implement all five module
|
/// To provide a fully custom asset layout, implement all six module
|
||||||
/// interfaces and pass them to this constructor, then supply the resulting
|
/// interfaces and pass them to this constructor, then supply the resulting
|
||||||
/// [AssetRegistry] to [WolfensteinLoader.loadFromBytes].
|
/// [AssetRegistry] to [WolfensteinLoader.loadFromBytes].
|
||||||
class AssetRegistry {
|
class AssetRegistry {
|
||||||
@@ -26,6 +28,7 @@ class AssetRegistry {
|
|||||||
required this.entities,
|
required this.entities,
|
||||||
required this.hud,
|
required this.hud,
|
||||||
required this.menu,
|
required this.menu,
|
||||||
|
required this.menuPresentation,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Sound-effect slot resolution.
|
/// Sound-effect slot resolution.
|
||||||
@@ -42,4 +45,7 @@ class AssetRegistry {
|
|||||||
|
|
||||||
/// Menu VGA picture index resolution.
|
/// Menu VGA picture index resolution.
|
||||||
final MenuPicModule menu;
|
final MenuPicModule menu;
|
||||||
|
|
||||||
|
/// Menu presentation and color routing.
|
||||||
|
final MenuPresentationModule menuPresentation;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import 'package:wolf_3d_dart/src/data_types/game_version.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/registry/asset_registry.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/registry/built_in/built_in_music_module.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_demo_entity_module.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_demo_hud_module.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_demo_sfx_module.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_menu_module.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_menu_presentation_module.dart';
|
||||||
|
|
||||||
|
/// Built-in [AssetRegistry] for full Spear of Destiny (`.SOD`).
|
||||||
|
class SpearAssetRegistry extends AssetRegistry {
|
||||||
|
SpearAssetRegistry()
|
||||||
|
: super(
|
||||||
|
sfx: const SpearDemoSfxModule(),
|
||||||
|
music: const BuiltInMusicModule(GameVersion.spearOfDestiny),
|
||||||
|
entities: const SpearDemoEntityModule(),
|
||||||
|
hud: const SpearDemoHudModule(),
|
||||||
|
menu: const SpearMenuPicModule(),
|
||||||
|
menuPresentation: const SpearMenuPresentationModule(),
|
||||||
|
);
|
||||||
|
}
|
||||||
+6
-4
@@ -1,10 +1,11 @@
|
|||||||
import 'package:wolf_3d_dart/src/data_types/game_version.dart';
|
import 'package:wolf_3d_dart/src/data_types/game_version.dart';
|
||||||
import 'package:wolf_3d_dart/src/registry/asset_registry.dart';
|
import 'package:wolf_3d_dart/src/registry/asset_registry.dart';
|
||||||
import 'package:wolf_3d_dart/src/registry/built_in/built_in_music_module.dart';
|
import 'package:wolf_3d_dart/src/registry/built_in/built_in_music_module.dart';
|
||||||
import 'package:wolf_3d_dart/src/registry/built_in/spear_demo_entity_module.dart';
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_demo_entity_module.dart';
|
||||||
import 'package:wolf_3d_dart/src/registry/built_in/spear_demo_hud_module.dart';
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_demo_hud_module.dart';
|
||||||
import 'package:wolf_3d_dart/src/registry/built_in/spear_demo_menu_module.dart';
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_demo_menu_module.dart';
|
||||||
import 'package:wolf_3d_dart/src/registry/built_in/spear_demo_sfx_module.dart';
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_demo_sfx_module.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_menu_presentation_module.dart';
|
||||||
|
|
||||||
/// Built-in [AssetRegistry] for Spear of Destiny demo/shareware (`.SDM`).
|
/// Built-in [AssetRegistry] for Spear of Destiny demo/shareware (`.SDM`).
|
||||||
class SpearDemoAssetRegistry extends AssetRegistry {
|
class SpearDemoAssetRegistry extends AssetRegistry {
|
||||||
@@ -15,5 +16,6 @@ class SpearDemoAssetRegistry extends AssetRegistry {
|
|||||||
entities: const SpearDemoEntityModule(),
|
entities: const SpearDemoEntityModule(),
|
||||||
hud: const SpearDemoHudModule(),
|
hud: const SpearDemoHudModule(),
|
||||||
menu: const SpearDemoMenuPicModule(),
|
menu: const SpearDemoMenuPicModule(),
|
||||||
|
menuPresentation: const SpearMenuPresentationModule(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import 'package:wolf_3d_dart/src/data_types/difficulty.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/registry/keys/menu_pic_key.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/registry/modules/menu_pic_module.dart';
|
||||||
|
|
||||||
|
/// Built-in menu-picture module for full Spear of Destiny releases (`.SOD`).
|
||||||
|
///
|
||||||
|
/// Picture indices are derived from `GFXV_SOD.H` (`chunkId - STARTPICS`).
|
||||||
|
class SpearMenuPicModule extends MenuPicModule {
|
||||||
|
const SpearMenuPicModule();
|
||||||
|
|
||||||
|
static final Map<MenuPicKey, int> _indices = {
|
||||||
|
MenuPicKey.title: 76, // TITLE1PIC
|
||||||
|
MenuPicKey.credits: 89, // CREDITSPIC
|
||||||
|
MenuPicKey.pg13: 88, // PG13PIC
|
||||||
|
|
||||||
|
MenuPicKey.controlBackground: 12, // C_CONTROLPIC
|
||||||
|
MenuPicKey.footer: 1, // C_MOUSELBACKPIC
|
||||||
|
MenuPicKey.heading: 0, // C_BACKDROPPIC
|
||||||
|
MenuPicKey.optionsLabel: 13, // C_OPTIONSPIC
|
||||||
|
MenuPicKey.customizeLabel: 6, // C_CUSTOMIZEPIC
|
||||||
|
|
||||||
|
MenuPicKey.cursorActive: 2, // C_CURSOR1PIC
|
||||||
|
MenuPicKey.cursorInactive: 3, // C_CURSOR2PIC
|
||||||
|
MenuPicKey.markerSelected: 5, // C_SELECTEDPIC
|
||||||
|
MenuPicKey.markerUnselected: 4, // C_NOTSELECTEDPIC
|
||||||
|
|
||||||
|
MenuPicKey.difficultyBaby: 18, // C_BABYMODEPIC
|
||||||
|
MenuPicKey.difficultyEasy: 19, // C_EASYPIC
|
||||||
|
MenuPicKey.difficultyNormal: 20, // C_NORMALPIC
|
||||||
|
MenuPicKey.difficultyHard: 21, // C_HARDPIC
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
MenuPicRef? resolve(MenuPicKey key) {
|
||||||
|
final int? index = _indices[key];
|
||||||
|
return index != null ? MenuPicRef(index) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
MenuPicKey episodeKey(int episodeIndex) {
|
||||||
|
return MenuPicKey.episode1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
MenuPicKey difficultyKey(Difficulty difficulty) {
|
||||||
|
return switch (difficulty) {
|
||||||
|
Difficulty.baby => MenuPicKey.difficultyBaby,
|
||||||
|
Difficulty.easy => MenuPicKey.difficultyEasy,
|
||||||
|
Difficulty.medium => MenuPicKey.difficultyNormal,
|
||||||
|
Difficulty.hard => MenuPicKey.difficultyHard,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/classic_menu_presentation_module.dart';
|
||||||
|
|
||||||
|
/// Built-in menu presentation for Spear variants.
|
||||||
|
///
|
||||||
|
/// Spear currently reuses the classic control-panel palette and layout rules,
|
||||||
|
/// but keeping it as a distinct concrete type gives Spear-specific releases and
|
||||||
|
/// user mods a stable place to diverge without changing retail/shareware
|
||||||
|
/// defaults.
|
||||||
|
class SpearMenuPresentationModule extends ClassicMenuPresentationModule {
|
||||||
|
/// Creates the default Spear menu presentation.
|
||||||
|
const SpearMenuPresentationModule();
|
||||||
|
|
||||||
|
/// Spear VGA background color (`BKGDCOLOR` in `WL_MENU.H`).
|
||||||
|
@override
|
||||||
|
int get backgroundIndex => 0x9D;
|
||||||
|
|
||||||
|
/// Spear panel fill color (`BORD2COLOR` in `WL_MENU.H`).
|
||||||
|
@override
|
||||||
|
int get panelIndex => 0x93;
|
||||||
|
|
||||||
|
/// Spear panel border color (`BORDCOLOR` in `WL_MENU.H`).
|
||||||
|
@override
|
||||||
|
int get borderIndex => 0x99;
|
||||||
|
|
||||||
|
/// Spear disabled/deactivated text color (`DEACTIVE` in `WL_MENU.H`).
|
||||||
|
@override
|
||||||
|
int get disabledTextIndex => 0x9B;
|
||||||
|
}
|
||||||
+192
@@ -0,0 +1,192 @@
|
|||||||
|
import 'package:wolf_3d_dart/src/data_types/difficulty.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/data_types/image.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/data_types/wolfenstein_data.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/menu/wolf_menu_pic.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/registry/keys/menu_pic_key.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/registry/modules/menu_presentation_module.dart';
|
||||||
|
|
||||||
|
/// Built-in menu presentation that mirrors the classic Wolf3D UI.
|
||||||
|
class ClassicMenuPresentationModule extends MenuPresentationModule {
|
||||||
|
/// Creates the classic Wolf3D menu presentation.
|
||||||
|
const ClassicMenuPresentationModule();
|
||||||
|
|
||||||
|
/// Classic menu background (`BKGDCOLOR` in `WL_MENU.H`).
|
||||||
|
@override
|
||||||
|
int get backgroundIndex => 0x2D;
|
||||||
|
|
||||||
|
/// Classic panel fill (`BORD2COLOR` in `WL_MENU.H`).
|
||||||
|
@override
|
||||||
|
int get panelIndex => 0x23;
|
||||||
|
|
||||||
|
/// Classic panel border (`BORDCOLOR` in `WL_MENU.H`).
|
||||||
|
@override
|
||||||
|
int get borderIndex => 0x29;
|
||||||
|
|
||||||
|
/// Highlight text (`HIGHLIGHT` in `WL_MENU.H`).
|
||||||
|
@override
|
||||||
|
int get emphasisIndex => 0x13;
|
||||||
|
|
||||||
|
/// Read-screen highlight (`READHCOLOR` in `WL_MENU.H`).
|
||||||
|
@override
|
||||||
|
int get warningIndex => 0x47;
|
||||||
|
|
||||||
|
/// Read-screen body text (`READCOLOR` in `WL_MENU.H`).
|
||||||
|
@override
|
||||||
|
int get mutedIndex => 0x4A;
|
||||||
|
|
||||||
|
/// Selected menu text (`HIGHLIGHT` in `WL_MENU.H`).
|
||||||
|
@override
|
||||||
|
int get selectedTextIndex => 0x13;
|
||||||
|
|
||||||
|
/// Unselected menu text (`TEXTCOLOR` in `WL_MENU.H`).
|
||||||
|
@override
|
||||||
|
int get unselectedTextIndex => 0x17;
|
||||||
|
|
||||||
|
/// Disabled menu text (`DEACTIVE` in `WL_MENU.H`).
|
||||||
|
@override
|
||||||
|
int get disabledTextIndex => 0x2B;
|
||||||
|
|
||||||
|
/// Header/read highlight (`READHCOLOR` in `WL_MENU.H`).
|
||||||
|
@override
|
||||||
|
int get headerTextIndex => 0x47;
|
||||||
|
|
||||||
|
/// Controls/customize panel background art.
|
||||||
|
@override
|
||||||
|
VgaImage? controlBackground(WolfensteinData data) =>
|
||||||
|
_imageForKey(data, MenuPicKey.controlBackground);
|
||||||
|
|
||||||
|
/// Title splash art.
|
||||||
|
@override
|
||||||
|
VgaImage? title(WolfensteinData data) => _imageForKey(data, MenuPicKey.title);
|
||||||
|
|
||||||
|
/// Main menu heading art.
|
||||||
|
@override
|
||||||
|
VgaImage? heading(WolfensteinData data) =>
|
||||||
|
_imageForKey(data, MenuPicKey.heading);
|
||||||
|
|
||||||
|
/// Selected marker art.
|
||||||
|
@override
|
||||||
|
VgaImage? selectedMarker(WolfensteinData data) =>
|
||||||
|
_imageForKey(data, MenuPicKey.markerSelected);
|
||||||
|
|
||||||
|
/// Unselected marker art.
|
||||||
|
@override
|
||||||
|
VgaImage? unselectedMarker(WolfensteinData data) =>
|
||||||
|
_imageForKey(data, MenuPicKey.markerUnselected);
|
||||||
|
|
||||||
|
/// Main options banner art.
|
||||||
|
@override
|
||||||
|
VgaImage? optionsLabel(WolfensteinData data) =>
|
||||||
|
_imageForKey(data, MenuPicKey.optionsLabel);
|
||||||
|
|
||||||
|
/// Customize heading art.
|
||||||
|
@override
|
||||||
|
VgaImage? customizeLabel(WolfensteinData data) =>
|
||||||
|
_imageForKey(data, MenuPicKey.customizeLabel);
|
||||||
|
|
||||||
|
/// Credits art.
|
||||||
|
@override
|
||||||
|
VgaImage? credits(WolfensteinData data) =>
|
||||||
|
_imageForKey(data, MenuPicKey.credits);
|
||||||
|
|
||||||
|
/// Episode selection art resolved through the active registry mapping.
|
||||||
|
@override
|
||||||
|
VgaImage? episodeOption(WolfensteinData data, int episodeIndex) {
|
||||||
|
if (episodeIndex < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return _imageForKey(data, data.registry.menu.episodeKey(episodeIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Difficulty art resolved through the active registry mapping.
|
||||||
|
@override
|
||||||
|
VgaImage? difficultyOption(WolfensteinData data, Difficulty difficulty) {
|
||||||
|
return _imageForKey(data, data.registry.menu.difficultyKey(difficulty));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves legacy numeric IDs through symbolic keys first.
|
||||||
|
@override
|
||||||
|
VgaImage? mappedPic(WolfensteinData data, int index) {
|
||||||
|
final key = _legacyKeyForIndex(index);
|
||||||
|
if (key != null) {
|
||||||
|
return _imageForKey(data, key);
|
||||||
|
}
|
||||||
|
return _pic(data, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads a symbolic menu picture through the active registry.
|
||||||
|
VgaImage? _imageForKey(WolfensteinData data, MenuPicKey key) {
|
||||||
|
final ref = data.registry.menu.resolve(key);
|
||||||
|
if (ref == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return _pic(data, ref.pictureIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Safely returns the VGA image at [index] when it contains usable pixels.
|
||||||
|
VgaImage? _pic(WolfensteinData data, int index) {
|
||||||
|
if (index < 0 || index >= data.vgaImages.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final image = data.vgaImages[index];
|
||||||
|
if (image.width <= 0 || image.height <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps classic numeric menu picture IDs to symbolic menu keys.
|
||||||
|
///
|
||||||
|
/// This preserves the old renderer-facing numbering scheme while routing the
|
||||||
|
/// actual picture resolution through the registry layer.
|
||||||
|
MenuPicKey? _legacyKeyForIndex(int index) {
|
||||||
|
switch (index) {
|
||||||
|
case WolfMenuPic.hTopWindow:
|
||||||
|
return MenuPicKey.heading;
|
||||||
|
case WolfMenuPic.cOptions:
|
||||||
|
return MenuPicKey.optionsLabel;
|
||||||
|
case WolfMenuPic.cCursor1:
|
||||||
|
return MenuPicKey.cursorActive;
|
||||||
|
case WolfMenuPic.cCursor2:
|
||||||
|
return MenuPicKey.cursorInactive;
|
||||||
|
case WolfMenuPic.cNotSelected:
|
||||||
|
return MenuPicKey.markerUnselected;
|
||||||
|
case WolfMenuPic.cSelected:
|
||||||
|
return MenuPicKey.markerSelected;
|
||||||
|
case 15:
|
||||||
|
return MenuPicKey.footer;
|
||||||
|
case WolfMenuPic.cBabyMode:
|
||||||
|
return MenuPicKey.difficultyBaby;
|
||||||
|
case WolfMenuPic.cEasy:
|
||||||
|
return MenuPicKey.difficultyEasy;
|
||||||
|
case WolfMenuPic.cNormal:
|
||||||
|
return MenuPicKey.difficultyNormal;
|
||||||
|
case WolfMenuPic.cHard:
|
||||||
|
return MenuPicKey.difficultyHard;
|
||||||
|
case WolfMenuPic.cControl:
|
||||||
|
return MenuPicKey.controlBackground;
|
||||||
|
case WolfMenuPic.cCustomize:
|
||||||
|
return MenuPicKey.customizeLabel;
|
||||||
|
case WolfMenuPic.cEpisode1:
|
||||||
|
return MenuPicKey.episode1;
|
||||||
|
case WolfMenuPic.cEpisode2:
|
||||||
|
return MenuPicKey.episode2;
|
||||||
|
case WolfMenuPic.cEpisode3:
|
||||||
|
return MenuPicKey.episode3;
|
||||||
|
case WolfMenuPic.cEpisode4:
|
||||||
|
return MenuPicKey.episode4;
|
||||||
|
case WolfMenuPic.cEpisode5:
|
||||||
|
return MenuPicKey.episode5;
|
||||||
|
case WolfMenuPic.cEpisode6:
|
||||||
|
return MenuPicKey.episode6;
|
||||||
|
case WolfMenuPic.title:
|
||||||
|
return MenuPicKey.title;
|
||||||
|
case WolfMenuPic.pg13:
|
||||||
|
return MenuPicKey.pg13;
|
||||||
|
case WolfMenuPic.credits:
|
||||||
|
return MenuPicKey.credits;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-3
@@ -2,9 +2,10 @@ import 'package:wolf_3d_dart/src/data_types/game_version.dart';
|
|||||||
import 'package:wolf_3d_dart/src/registry/asset_registry.dart';
|
import 'package:wolf_3d_dart/src/registry/asset_registry.dart';
|
||||||
import 'package:wolf_3d_dart/src/registry/built_in/built_in_music_module.dart';
|
import 'package:wolf_3d_dart/src/registry/built_in/built_in_music_module.dart';
|
||||||
import 'package:wolf_3d_dart/src/registry/built_in/built_in_sfx_module.dart';
|
import 'package:wolf_3d_dart/src/registry/built_in/built_in_sfx_module.dart';
|
||||||
import 'package:wolf_3d_dart/src/registry/built_in/retail_entity_module.dart';
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/classic_menu_presentation_module.dart';
|
||||||
import 'package:wolf_3d_dart/src/registry/built_in/retail_hud_module.dart';
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/retail_entity_module.dart';
|
||||||
import 'package:wolf_3d_dart/src/registry/built_in/retail_menu_module.dart';
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/retail_hud_module.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/retail_menu_module.dart';
|
||||||
|
|
||||||
/// The canonical [AssetRegistry] for all retail Wolf3D releases.
|
/// The canonical [AssetRegistry] for all retail Wolf3D releases.
|
||||||
///
|
///
|
||||||
@@ -19,5 +20,6 @@ class RetailAssetRegistry extends AssetRegistry {
|
|||||||
entities: const RetailEntityModule(),
|
entities: const RetailEntityModule(),
|
||||||
hud: const RetailHudModule(),
|
hud: const RetailHudModule(),
|
||||||
menu: const RetailMenuPicModule(),
|
menu: const RetailMenuPicModule(),
|
||||||
|
menuPresentation: const ClassicMenuPresentationModule(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+5
-3
@@ -2,9 +2,10 @@ import 'package:wolf_3d_dart/src/data_types/game_version.dart';
|
|||||||
import 'package:wolf_3d_dart/src/registry/asset_registry.dart';
|
import 'package:wolf_3d_dart/src/registry/asset_registry.dart';
|
||||||
import 'package:wolf_3d_dart/src/registry/built_in/built_in_music_module.dart';
|
import 'package:wolf_3d_dart/src/registry/built_in/built_in_music_module.dart';
|
||||||
import 'package:wolf_3d_dart/src/registry/built_in/built_in_sfx_module.dart';
|
import 'package:wolf_3d_dart/src/registry/built_in/built_in_sfx_module.dart';
|
||||||
import 'package:wolf_3d_dart/src/registry/built_in/shareware_entity_module.dart';
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/classic_menu_presentation_module.dart';
|
||||||
import 'package:wolf_3d_dart/src/registry/built_in/shareware_hud_module.dart';
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/shareware_entity_module.dart';
|
||||||
import 'package:wolf_3d_dart/src/registry/built_in/shareware_menu_module.dart';
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/shareware_hud_module.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/shareware_menu_module.dart';
|
||||||
|
|
||||||
/// The [AssetRegistry] for the Wolfenstein 3D v1.4 Shareware release.
|
/// The [AssetRegistry] for the Wolfenstein 3D v1.4 Shareware release.
|
||||||
///
|
///
|
||||||
@@ -27,6 +28,7 @@ class SharewareAssetRegistry extends AssetRegistry {
|
|||||||
menu: SharewareMenuPicModule(
|
menu: SharewareMenuPicModule(
|
||||||
useOriginalWl1Map: strictOriginalShareware,
|
useOriginalWl1Map: strictOriginalShareware,
|
||||||
),
|
),
|
||||||
|
menuPresentation: const ClassicMenuPresentationModule(),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Convenience accessor to the menu module for post-load initialisation.
|
/// Convenience accessor to the menu module for post-load initialisation.
|
||||||
+1
-1
@@ -8,7 +8,7 @@ import 'package:wolf_3d_dart/src/registry/modules/menu_pic_module.dart';
|
|||||||
/// the episode/difficulty/control-panel art sits at a shifted position in
|
/// the episode/difficulty/control-panel art sits at a shifted position in
|
||||||
/// the VGA image list. The exact shift is computed at resolve time by
|
/// the VGA image list. The exact shift is computed at resolve time by
|
||||||
/// scanning the loaded image list for the landmark STATUSBARPIC, mirroring
|
/// scanning the loaded image list for the landmark STATUSBARPIC, mirroring
|
||||||
/// the runtime heuristic in the original [WolfClassicMenuArt._indexOffset].
|
/// the same runtime heuristic used by the built-in classic menu presentation.
|
||||||
///
|
///
|
||||||
/// Offset determination is deferred until the first [resolve] call and
|
/// Offset determination is deferred until the first [resolve] call and
|
||||||
/// cached for subsequent lookups. If the landmark cannot be found the
|
/// cached for subsequent lookups. If the landmark cannot be found the
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import 'package:wolf_3d_dart/src/data_types/difficulty.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/data_types/image.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/data_types/wolfenstein_data.dart';
|
||||||
|
|
||||||
|
/// Provides the visual presentation for Wolf3D menus.
|
||||||
|
///
|
||||||
|
/// A presentation module owns both menu text colors and the symbolic art
|
||||||
|
/// lookups needed by renderers. Pair it with a [MenuPicModule] inside an
|
||||||
|
/// [AssetRegistry] to support built-in variants or fully custom user-defined
|
||||||
|
/// menus.
|
||||||
|
abstract class MenuPresentationModule {
|
||||||
|
/// Creates a menu presentation module.
|
||||||
|
const MenuPresentationModule();
|
||||||
|
|
||||||
|
/// VGA palette index used for menu background fills and header band accents.
|
||||||
|
int get backgroundIndex;
|
||||||
|
|
||||||
|
/// VGA palette index used for menu panel fills.
|
||||||
|
int get panelIndex;
|
||||||
|
|
||||||
|
/// VGA palette index used for panel borders and separators.
|
||||||
|
int get borderIndex;
|
||||||
|
|
||||||
|
/// VGA palette index used for emphasized UI text.
|
||||||
|
int get emphasisIndex;
|
||||||
|
|
||||||
|
/// VGA palette index used for warnings and cautionary text.
|
||||||
|
int get warningIndex;
|
||||||
|
|
||||||
|
/// VGA palette index used for subdued UI text.
|
||||||
|
int get mutedIndex;
|
||||||
|
|
||||||
|
/// VGA palette index used for the selected menu row text.
|
||||||
|
int get selectedTextIndex;
|
||||||
|
|
||||||
|
/// VGA palette index used for normal menu row text.
|
||||||
|
int get unselectedTextIndex;
|
||||||
|
|
||||||
|
/// VGA palette index used for disabled menu row text.
|
||||||
|
int get disabledTextIndex;
|
||||||
|
|
||||||
|
/// VGA palette index used for headings and title text.
|
||||||
|
int get headerTextIndex;
|
||||||
|
|
||||||
|
/// Returns the controls/customize panel background image, if supported.
|
||||||
|
VgaImage? controlBackground(WolfensteinData data);
|
||||||
|
|
||||||
|
/// Returns the title splash image, if supported.
|
||||||
|
VgaImage? title(WolfensteinData data);
|
||||||
|
|
||||||
|
/// Returns the primary heading art for the main menu, if supported.
|
||||||
|
VgaImage? heading(WolfensteinData data);
|
||||||
|
|
||||||
|
/// Returns the selected marker image, if supported.
|
||||||
|
VgaImage? selectedMarker(WolfensteinData data);
|
||||||
|
|
||||||
|
/// Returns the unselected marker image, if supported.
|
||||||
|
VgaImage? unselectedMarker(WolfensteinData data);
|
||||||
|
|
||||||
|
/// Returns the main options banner image, if supported.
|
||||||
|
VgaImage? optionsLabel(WolfensteinData data);
|
||||||
|
|
||||||
|
/// Returns the customize/options heading image, if supported.
|
||||||
|
VgaImage? customizeLabel(WolfensteinData data);
|
||||||
|
|
||||||
|
/// Returns the credits image, if supported.
|
||||||
|
VgaImage? credits(WolfensteinData data);
|
||||||
|
|
||||||
|
/// Returns episode selection art for zero-based [episodeIndex], if supported.
|
||||||
|
VgaImage? episodeOption(WolfensteinData data, int episodeIndex);
|
||||||
|
|
||||||
|
/// Returns difficulty selection art for [difficulty], if supported.
|
||||||
|
VgaImage? difficultyOption(WolfensteinData data, Difficulty difficulty);
|
||||||
|
|
||||||
|
/// Legacy numeric lookup retained for renderer code that still reasons in
|
||||||
|
/// original VGA picture IDs.
|
||||||
|
VgaImage? mappedPic(WolfensteinData data, int index);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import 'package:wolf_3d_dart/src/data/data_version.dart';
|
import 'package:wolf_3d_dart/src/data/data_version.dart';
|
||||||
import 'package:wolf_3d_dart/src/data_types/game_version.dart';
|
import 'package:wolf_3d_dart/src/data_types/game_version.dart';
|
||||||
import 'package:wolf_3d_dart/src/registry/asset_registry.dart';
|
import 'package:wolf_3d_dart/src/registry/asset_registry.dart';
|
||||||
import 'package:wolf_3d_dart/src/registry/built_in/retail_asset_registry.dart';
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_asset_registry.dart';
|
||||||
import 'package:wolf_3d_dart/src/registry/built_in/shareware_asset_registry.dart';
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_demo_asset_registry.dart';
|
||||||
import 'package:wolf_3d_dart/src/registry/built_in/spear_demo_asset_registry.dart';
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/retail_asset_registry.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/shareware_asset_registry.dart';
|
||||||
|
|
||||||
/// The input used by [AssetRegistryResolver] to select or build a registry.
|
/// The input used by [AssetRegistryResolver] to select or build a registry.
|
||||||
class RegistrySelectionContext {
|
class RegistrySelectionContext {
|
||||||
@@ -62,6 +63,7 @@ class BuiltInAssetRegistryResolver implements AssetRegistryResolver {
|
|||||||
case GameVersion.shareware:
|
case GameVersion.shareware:
|
||||||
return SharewareAssetRegistry();
|
return SharewareAssetRegistry();
|
||||||
case GameVersion.spearOfDestiny:
|
case GameVersion.spearOfDestiny:
|
||||||
|
return SpearAssetRegistry();
|
||||||
case GameVersion.spearOfDestinyDemo:
|
case GameVersion.spearOfDestinyDemo:
|
||||||
return SpearDemoAssetRegistry();
|
return SpearDemoAssetRegistry();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'dart:math' as math;
|
|||||||
import 'package:arcane_helper_utils/arcane_helper_utils.dart';
|
import 'package:arcane_helper_utils/arcane_helper_utils.dart';
|
||||||
import 'package:wolf_3d_dart/src/menu/menu_manager.dart';
|
import 'package:wolf_3d_dart/src/menu/menu_manager.dart';
|
||||||
import 'package:wolf_3d_dart/src/rendering/fizzle_fade.dart';
|
import 'package:wolf_3d_dart/src/rendering/fizzle_fade.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/rendering/menu_header_band.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
import 'package:wolf_3d_dart/wolf_3d_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_entities.dart';
|
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
|
||||||
@@ -546,36 +547,45 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
|
|||||||
engine.menuManager.menuBackgroundRgb,
|
engine.menuManager.menuBackgroundRgb,
|
||||||
);
|
);
|
||||||
final int panelColor = _rgbToPaletteColor(engine.menuPanelRgb);
|
final int panelColor = _rgbToPaletteColor(engine.menuPanelRgb);
|
||||||
final int headingColor = WolfMenuPalette.headerTextColor;
|
final menu = WolfMenuPresentation(engine.data);
|
||||||
final int selectedTextColor = WolfMenuPalette.selectedTextColor;
|
final bool isSpearVariant = MenuHeaderBand.isSpearVariant(
|
||||||
final int unselectedTextColor = WolfMenuPalette.unselectedTextColor;
|
engine.data.version,
|
||||||
final int disabledTextColor = WolfMenuPalette.disabledTextColor;
|
);
|
||||||
|
final int headingColor = menu.headerTextColor;
|
||||||
|
final int selectedTextColor = menu.selectedTextColor;
|
||||||
|
final int unselectedTextColor = menu.unselectedTextColor;
|
||||||
|
final int disabledTextColor = menu.disabledTextColor;
|
||||||
final _AsciiMenuTypography menuTypography = _resolveMenuTypography();
|
final _AsciiMenuTypography menuTypography = _resolveMenuTypography();
|
||||||
|
|
||||||
if (_usesTerminalLayout) {
|
if (isSpearVariant && menu.heading != null) {
|
||||||
|
_drawTiledMenuBackdrop(menu.heading!, bgColor);
|
||||||
|
} else if (_usesTerminalLayout) {
|
||||||
_fillTerminalRect(0, 0, width, _terminalPixelHeight, bgColor);
|
_fillTerminalRect(0, 0, width, _terminalPixelHeight, bgColor);
|
||||||
} else {
|
} else {
|
||||||
_fillRect(0, 0, width, height, activeTheme.solid, bgColor);
|
_fillRect(0, 0, width, height, activeTheme.solid, bgColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
final art = WolfClassicMenuArt(engine.data);
|
final optionsLabel = menu.optionsLabel;
|
||||||
final optionsLabel = art.optionsLabel;
|
|
||||||
if (optionsLabel != null) {
|
if (optionsLabel != null) {
|
||||||
_mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
|
_mainMenuBandFirstColumn = MenuHeaderBand.firstColumn(optionsLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (engine.menuManager.activeMenu == WolfMenuScreen.introSplash) {
|
if (engine.menuManager.activeMenu == WolfMenuScreen.introSplash) {
|
||||||
_drawIntroSplash(engine, art, menuTypography);
|
_drawIntroSplash(engine, menu, menuTypography);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (engine.menuManager.activeMenu == WolfMenuScreen.mainMenu) {
|
if (engine.menuManager.activeMenu == WolfMenuScreen.mainMenu) {
|
||||||
_fillRect320(68, 52, 178, 136, panelColor);
|
_fillRect320(68, 52, 178, 136, panelColor);
|
||||||
|
|
||||||
final optionsLabel = art.optionsLabel;
|
final optionsLabel = menu.optionsLabel;
|
||||||
if (optionsLabel != null) {
|
if (optionsLabel != null) {
|
||||||
final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319);
|
final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319);
|
||||||
_drawMainMenuOptionsSideBars(optionsLabel, optionsX);
|
_drawMainMenuOptionsSideBars(
|
||||||
|
optionsLabel,
|
||||||
|
optionsX,
|
||||||
|
debugContext: 'ascii/mainMenu',
|
||||||
|
);
|
||||||
_blitVgaImageAscii(optionsLabel, optionsX, 0);
|
_blitVgaImageAscii(optionsLabel, optionsX, 0);
|
||||||
} else {
|
} else {
|
||||||
_drawHeaderBarStack(
|
_drawHeaderBarStack(
|
||||||
@@ -591,7 +601,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final cursor = art.mappedPic(
|
final cursor = menu.mappedPic(
|
||||||
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
||||||
);
|
);
|
||||||
const int rowYStart = 55;
|
const int rowYStart = 55;
|
||||||
@@ -630,37 +640,47 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (engine.menuManager.activeMenu == WolfMenuScreen.gameSelect) {
|
if (engine.menuManager.activeMenu == WolfMenuScreen.gameSelect) {
|
||||||
|
final optionsLabel = menu.optionsLabel;
|
||||||
|
if (optionsLabel != null) {
|
||||||
|
final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319);
|
||||||
|
_drawMainMenuOptionsSideBars(
|
||||||
|
optionsLabel,
|
||||||
|
optionsX,
|
||||||
|
debugContext: 'ascii/gameSelect',
|
||||||
|
);
|
||||||
|
_blitVgaImageAscii(optionsLabel, optionsX, 0);
|
||||||
|
} else {
|
||||||
_drawHeaderBarStack(
|
_drawHeaderBarStack(
|
||||||
headingY200: _headerHeadingY,
|
headingY200: _headerHeadingY,
|
||||||
backgroundColor: bgColor,
|
backgroundColor: bgColor,
|
||||||
barColor: ColorPalette.vga32Bit[0],
|
barColor: ColorPalette.vga32Bit[0],
|
||||||
);
|
);
|
||||||
_fillRect320(28, 58, 264, 104, panelColor);
|
|
||||||
|
|
||||||
final cursor = art.mappedPic(
|
|
||||||
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
|
||||||
);
|
|
||||||
const int rowYStart = 84;
|
|
||||||
const int rowStep = 18;
|
|
||||||
final List<String> rows = engine.availableGames
|
|
||||||
.map((game) => _gameTitle(game.version))
|
|
||||||
.toList(growable: false);
|
|
||||||
_drawMenuTextCentered(
|
_drawMenuTextCentered(
|
||||||
'SELECT GAME',
|
'SELECT GAME',
|
||||||
_headerHeadingY,
|
_headerHeadingY,
|
||||||
headingColor,
|
headingColor,
|
||||||
scale: menuTypography.headingScale,
|
scale: menuTypography.headingScale,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
_fillRect320(68, 52, 178, 136, panelColor);
|
||||||
|
|
||||||
|
final cursor = menu.mappedPic(
|
||||||
|
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
||||||
|
);
|
||||||
|
const int rowYStart = 55;
|
||||||
|
const int rowStep = 13;
|
||||||
|
final List<String> rows = engine.availableGames
|
||||||
|
.map((game) => MenuHeaderBand.gameTitle(game.version))
|
||||||
|
.toList(growable: false);
|
||||||
_drawSelectableMenuRows(
|
_drawSelectableMenuRows(
|
||||||
typography: menuTypography,
|
typography: menuTypography,
|
||||||
rows: rows,
|
rows: rows,
|
||||||
selectedIndex: engine.menuManager.selectedGameIndex,
|
selectedIndex: engine.menuManager.selectedGameIndex,
|
||||||
rowYStart200: rowYStart,
|
rowYStart200: rowYStart,
|
||||||
rowStep200: rowStep,
|
rowStep200: rowStep,
|
||||||
textX320: 70,
|
textX320: 100,
|
||||||
panelX320: 28,
|
panelX320: 68,
|
||||||
panelW320: 264,
|
panelW320: 178,
|
||||||
colorForRow: (int _, bool isSelected) {
|
colorForRow: (int _, bool isSelected) {
|
||||||
return isSelected ? selectedTextColor : unselectedTextColor;
|
return isSelected ? selectedTextColor : unselectedTextColor;
|
||||||
},
|
},
|
||||||
@@ -669,7 +689,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
|
|||||||
if (cursor != null) {
|
if (cursor != null) {
|
||||||
_blitVgaImageAscii(
|
_blitVgaImageAscii(
|
||||||
cursor,
|
cursor,
|
||||||
38,
|
72,
|
||||||
(rowYStart + (engine.menuManager.selectedGameIndex * rowStep)) - 2,
|
(rowYStart + (engine.menuManager.selectedGameIndex * rowStep)) - 2,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -687,7 +707,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
|
|||||||
);
|
);
|
||||||
_fillRect320(12, 18, 296, 168, panelColor);
|
_fillRect320(12, 18, 296, 168, panelColor);
|
||||||
|
|
||||||
final cursor = art.mappedPic(
|
final cursor = menu.mappedPic(
|
||||||
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
||||||
);
|
);
|
||||||
const int rowYStart = 24;
|
const int rowYStart = 24;
|
||||||
@@ -720,7 +740,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
|
|||||||
// Keep episode icons visible in compact ASCII layouts so this screen
|
// Keep episode icons visible in compact ASCII layouts so this screen
|
||||||
// still communicates the same visual affordances as full-size menus.
|
// still communicates the same visual affordances as full-size menus.
|
||||||
for (int i = 0; i < engine.data.episodes.length; i++) {
|
for (int i = 0; i < engine.data.episodes.length; i++) {
|
||||||
final image = art.episodeOption(i);
|
final image = menu.episodeOption(i);
|
||||||
if (image != null) {
|
if (image != null) {
|
||||||
_blitVgaImageAscii(image, 40, rowYStart + (i * rowStep));
|
_blitVgaImageAscii(image, 40, rowYStart + (i * rowStep));
|
||||||
}
|
}
|
||||||
@@ -751,7 +771,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
|
|||||||
if (isSelected && cursor != null) {
|
if (isSelected && cursor != null) {
|
||||||
_blitVgaImageAscii(cursor, 16, y + 2);
|
_blitVgaImageAscii(cursor, 16, y + 2);
|
||||||
}
|
}
|
||||||
final image = art.episodeOption(i);
|
final image = menu.episodeOption(i);
|
||||||
if (image != null) {
|
if (image != null) {
|
||||||
_blitVgaImageAscii(image, 40, y);
|
_blitVgaImageAscii(image, 40, y);
|
||||||
}
|
}
|
||||||
@@ -780,12 +800,16 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (engine.menuManager.activeMenu == WolfMenuScreen.changeView) {
|
if (engine.menuManager.activeMenu == WolfMenuScreen.changeView) {
|
||||||
_drawCustomizeMenuHeader(art, headingColor, bgColor);
|
_drawCustomizeMenuHeader(
|
||||||
final cursor = art.mappedPic(
|
menu,
|
||||||
|
headingColor,
|
||||||
|
bgColor,
|
||||||
|
);
|
||||||
|
final cursor = menu.mappedPic(
|
||||||
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
||||||
);
|
);
|
||||||
final selectedMarker = art.selectedMarker;
|
final selectedMarker = menu.selectedMarker;
|
||||||
final unselectedMarker = art.unselectedMarker;
|
final unselectedMarker = menu.unselectedMarker;
|
||||||
const int rowYStart = 64;
|
const int rowYStart = 64;
|
||||||
const int rowStep = 16;
|
const int rowStep = 16;
|
||||||
const int cursorX = 62;
|
const int cursorX = 62;
|
||||||
@@ -884,7 +908,11 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (engine.menuManager.activeMenu == WolfMenuScreen.rendererOptions) {
|
if (engine.menuManager.activeMenu == WolfMenuScreen.rendererOptions) {
|
||||||
_drawCustomizeMenuHeader(art, headingColor, bgColor);
|
_drawCustomizeMenuHeader(
|
||||||
|
menu,
|
||||||
|
headingColor,
|
||||||
|
bgColor,
|
||||||
|
);
|
||||||
_fillRect320(56, 52, 208, 120, panelColor);
|
_fillRect320(56, 52, 208, 120, panelColor);
|
||||||
_drawMenuTextCentered(
|
_drawMenuTextCentered(
|
||||||
engine.menuManager.rendererOptionsTitle,
|
engine.menuManager.rendererOptionsTitle,
|
||||||
@@ -893,11 +921,11 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
|
|||||||
scale: 1,
|
scale: 1,
|
||||||
);
|
);
|
||||||
|
|
||||||
final cursor = art.mappedPic(
|
final cursor = menu.mappedPic(
|
||||||
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
||||||
);
|
);
|
||||||
final selectedMarker = art.selectedMarker;
|
final selectedMarker = menu.selectedMarker;
|
||||||
final unselectedMarker = art.unselectedMarker;
|
final unselectedMarker = menu.unselectedMarker;
|
||||||
const int rowYStart = 68;
|
const int rowYStart = 68;
|
||||||
const int rowStep = 20;
|
const int rowStep = 20;
|
||||||
const int cursorX = 62;
|
const int cursorX = 62;
|
||||||
@@ -947,14 +975,14 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
|
|||||||
|
|
||||||
_fillRect320(28, 70, 264, 82, panelColor);
|
_fillRect320(28, 70, 264, 82, panelColor);
|
||||||
|
|
||||||
final face = art.difficultyOption(
|
final face = menu.difficultyOption(
|
||||||
Difficulty.values[selectedDifficultyIndex],
|
Difficulty.values[selectedDifficultyIndex],
|
||||||
);
|
);
|
||||||
if (face != null) {
|
if (face != null) {
|
||||||
_blitVgaImageAscii(face, 28 + 264 - face.width - 18, 92);
|
_blitVgaImageAscii(face, 28 + 264 - face.width - 18, 92);
|
||||||
}
|
}
|
||||||
|
|
||||||
final cursor = art.mappedPic(
|
final cursor = menu.mappedPic(
|
||||||
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
||||||
);
|
);
|
||||||
const rowYStart = 86;
|
const rowYStart = 86;
|
||||||
@@ -1018,28 +1046,15 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
|
|||||||
_applyMenuTransition(engine.menuManager, bgColor);
|
_applyMenuTransition(engine.menuManager, bgColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _gameTitle(GameVersion version) {
|
|
||||||
switch (version) {
|
|
||||||
case GameVersion.shareware:
|
|
||||||
return 'SHAREWARE';
|
|
||||||
case GameVersion.retail:
|
|
||||||
return 'RETAIL';
|
|
||||||
case GameVersion.spearOfDestiny:
|
|
||||||
return 'SPEAR OF DESTINY';
|
|
||||||
case GameVersion.spearOfDestinyDemo:
|
|
||||||
return 'SOD DEMO';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _drawIntroSplash(
|
void _drawIntroSplash(
|
||||||
WolfEngine engine,
|
WolfEngine engine,
|
||||||
WolfClassicMenuArt art,
|
WolfMenuPresentation menu,
|
||||||
_AsciiMenuTypography menuTypography,
|
_AsciiMenuTypography menuTypography,
|
||||||
) {
|
) {
|
||||||
final image = switch (engine.menuManager.currentIntroSlide) {
|
final image = switch (engine.menuManager.currentIntroSlide) {
|
||||||
WolfIntroSlide.retailWarning => null,
|
WolfIntroSlide.retailWarning => null,
|
||||||
WolfIntroSlide.pg13 => art.mappedPic(WolfMenuPic.pg13),
|
WolfIntroSlide.pg13 => menu.mappedPic(WolfMenuPic.pg13),
|
||||||
WolfIntroSlide.title => art.mappedPic(WolfMenuPic.title),
|
WolfIntroSlide.title => menu.mappedPic(WolfMenuPic.title),
|
||||||
};
|
};
|
||||||
|
|
||||||
int splashBg = _rgbToPaletteColor(engine.menuManager.menuBackgroundRgb);
|
int splashBg = _rgbToPaletteColor(engine.menuManager.menuBackgroundRgb);
|
||||||
@@ -1172,17 +1187,38 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drawMainMenuOptionsSideBars(VgaImage optionsLabel, int optionsX320) {
|
void _drawMainMenuOptionsSideBars(
|
||||||
_mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
|
VgaImage optionsLabel,
|
||||||
_drawScaledColumnBand(_mainMenuBandFirstColumn!);
|
int optionsX320, {
|
||||||
|
required String debugContext,
|
||||||
|
}) {
|
||||||
|
_mainMenuBandFirstColumn = MenuHeaderBand.firstColumn(optionsLabel);
|
||||||
|
MenuHeaderBand.applyFromHeadingImage(
|
||||||
|
image: optionsLabel,
|
||||||
|
imageX320: optionsX320,
|
||||||
|
debugContext: debugContext,
|
||||||
|
fillSideEdgesRow:
|
||||||
|
(
|
||||||
|
int y200,
|
||||||
|
int leftWidth320,
|
||||||
|
int rightStartX320,
|
||||||
|
int paletteIndex,
|
||||||
|
) {
|
||||||
|
final int color = ColorPalette.vga32Bit[paletteIndex];
|
||||||
|
if (leftWidth320 > 0) {
|
||||||
|
_fillRect320(0, y200, leftWidth320, 1, color);
|
||||||
}
|
}
|
||||||
|
if (rightStartX320 < 320) {
|
||||||
List<int> _cacheFirstColumn(VgaImage image) {
|
_fillRect320(
|
||||||
final List<int> column = List<int>.filled(image.height, 0);
|
rightStartX320,
|
||||||
for (int y = 0; y < image.height; y++) {
|
y200,
|
||||||
column[y] = image.decodePixel(0, y);
|
320 - rightStartX320,
|
||||||
|
1,
|
||||||
|
color,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return column;
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drawScaledColumnBand(List<int> column) {
|
void _drawScaledColumnBand(List<int> column) {
|
||||||
@@ -1190,6 +1226,18 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int firstBlack = -1;
|
||||||
|
int lastBlack = -1;
|
||||||
|
for (int y = 0; y < column.length; y++) {
|
||||||
|
if (column[y] == 0) {
|
||||||
|
firstBlack = firstBlack == -1 ? y : firstBlack;
|
||||||
|
lastBlack = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (firstBlack == -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final int maxDrawHeight = _usesTerminalLayout
|
final int maxDrawHeight = _usesTerminalLayout
|
||||||
? _terminalPixelHeight
|
? _terminalPixelHeight
|
||||||
: height;
|
: height;
|
||||||
@@ -1198,6 +1246,9 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
|
|||||||
|
|
||||||
for (int dy = 0; dy < destHeight; dy++) {
|
for (int dy = 0; dy < destHeight; dy++) {
|
||||||
final int srcY = (dy / scaleY).toInt().clamp(0, column.length - 1);
|
final int srcY = (dy / scaleY).toInt().clamp(0, column.length - 1);
|
||||||
|
if (srcY < firstBlack || srcY > lastBlack) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
final int fillColor = ColorPalette.vga32Bit[column[srcY]];
|
final int fillColor = ColorPalette.vga32Bit[column[srcY]];
|
||||||
|
|
||||||
if (_usesTerminalLayout) {
|
if (_usesTerminalLayout) {
|
||||||
@@ -1593,7 +1644,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _drawCustomizeMenuHeader(
|
void _drawCustomizeMenuHeader(
|
||||||
WolfClassicMenuArt art,
|
WolfMenuPresentation menu,
|
||||||
int headingColor,
|
int headingColor,
|
||||||
int backgroundColor,
|
int backgroundColor,
|
||||||
) {
|
) {
|
||||||
@@ -1603,9 +1654,14 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
|
|||||||
barColor: ColorPalette.vga32Bit[0],
|
barColor: ColorPalette.vga32Bit[0],
|
||||||
);
|
);
|
||||||
|
|
||||||
final VgaImage? heading = art.customizeLabel ?? art.optionsLabel;
|
final VgaImage? heading = menu.customizeLabel ?? menu.optionsLabel;
|
||||||
if (heading != null) {
|
if (heading != null) {
|
||||||
final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319);
|
final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319);
|
||||||
|
_drawMainMenuOptionsSideBars(
|
||||||
|
heading,
|
||||||
|
headingX,
|
||||||
|
debugContext: 'ascii/customizeHeader',
|
||||||
|
);
|
||||||
_blitVgaImageAscii(heading, headingX, 0);
|
_blitVgaImageAscii(heading, headingX, 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2516,4 +2572,20 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
|
|||||||
int _rgbToPaletteColor(int rgb) {
|
int _rgbToPaletteColor(int rgb) {
|
||||||
return ColorPalette.vga32Bit[ColorPalette.findClosestPaletteIndex(rgb)];
|
return ColorPalette.vga32Bit[ColorPalette.findClosestPaletteIndex(rgb)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _drawTiledMenuBackdrop(VgaImage image, int fallbackColor) {
|
||||||
|
if (image.width <= 0 || image.height <= 0) {
|
||||||
|
if (_usesTerminalLayout) {
|
||||||
|
_fillTerminalRect(0, 0, width, _terminalPixelHeight, fallbackColor);
|
||||||
|
} else {
|
||||||
|
_fillRect(0, 0, width, height, activeTheme.solid, fallbackColor);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (int y = 0; y < 200; y += image.height) {
|
||||||
|
for (int x = 0; x < 320; x += image.width) {
|
||||||
|
_blitVgaImageAscii(image, x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||||
|
|
||||||
|
abstract class MenuHeaderBand {
|
||||||
|
static void Function(String message)? debugLogger;
|
||||||
|
|
||||||
|
static bool isSpearVariant(GameVersion version) {
|
||||||
|
switch (version) {
|
||||||
|
case GameVersion.spearOfDestiny:
|
||||||
|
case GameVersion.spearOfDestinyDemo:
|
||||||
|
return true;
|
||||||
|
case GameVersion.shareware:
|
||||||
|
case GameVersion.retail:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<int> firstColumn(VgaImage image) {
|
||||||
|
final List<int> column = List<int>.filled(image.height, 0);
|
||||||
|
final int sampleX = image.width > 1 ? 1 : 0;
|
||||||
|
for (int y = 0; y < image.height; y++) {
|
||||||
|
column[y] = image.decodePixel(sampleX, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int effectiveRows = _effectiveBandRowCount(column);
|
||||||
|
if (effectiveRows == column.length) {
|
||||||
|
return column;
|
||||||
|
}
|
||||||
|
return column.sublist(0, effectiveRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String gameTitle(GameVersion version) {
|
||||||
|
switch (version) {
|
||||||
|
case GameVersion.shareware:
|
||||||
|
return 'SHAREWARE';
|
||||||
|
case GameVersion.retail:
|
||||||
|
return 'RETAIL';
|
||||||
|
case GameVersion.spearOfDestiny:
|
||||||
|
return 'SPEAR OF DESTINY';
|
||||||
|
case GameVersion.spearOfDestinyDemo:
|
||||||
|
return 'SOD DEMO';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void applyFromHeadingImage({
|
||||||
|
required VgaImage image,
|
||||||
|
required int imageX320,
|
||||||
|
required void Function(
|
||||||
|
int y200,
|
||||||
|
int leftWidth320,
|
||||||
|
int rightStartX320,
|
||||||
|
int paletteIndex,
|
||||||
|
)
|
||||||
|
fillSideEdgesRow,
|
||||||
|
int maxRows = 200,
|
||||||
|
String? debugContext,
|
||||||
|
}) {
|
||||||
|
if (image.width <= 0 || image.height <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final int sampledRows = image.height < maxRows ? image.height : maxRows;
|
||||||
|
if (sampledRows <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final int sampleX = image.width > 1 ? 1 : 0;
|
||||||
|
final List<int> sourceByRow = List<int>.filled(sampledRows, 0);
|
||||||
|
final List<bool> isBlackByRow = List<bool>.filled(sampledRows, false);
|
||||||
|
for (int y = 0; y < sampledRows; y++) {
|
||||||
|
final int sourceIndex = image.decodePixel(sampleX, y);
|
||||||
|
sourceByRow[y] = sourceIndex;
|
||||||
|
isBlackByRow[y] = sourceIndex == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
final int rowCount = _effectiveBandRowCount(sourceByRow);
|
||||||
|
if (rowCount <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final int leftWidth320 = imageX320.clamp(0, 320);
|
||||||
|
final int rightStartX320 = (imageX320 + image.width).clamp(0, 320);
|
||||||
|
|
||||||
|
// Extend rows that fall inside the span bounded by the first and last
|
||||||
|
// black row. Leading non-black rows (coloured image border at the top)
|
||||||
|
// and trailing non-black rows let the background show through. Interior
|
||||||
|
// non-black rows (e.g. a decorative stripe sandwiched between two black
|
||||||
|
// sections) are extended with their actual palette colour.
|
||||||
|
int firstBlack = -1;
|
||||||
|
int lastBlack = -1;
|
||||||
|
for (int y = 0; y < rowCount; y++) {
|
||||||
|
if (sourceByRow[y] == 0) {
|
||||||
|
firstBlack = firstBlack == -1 ? y : firstBlack;
|
||||||
|
lastBlack = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (firstBlack == -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (int y = firstBlack; y <= lastBlack; y++) {
|
||||||
|
fillSideEdgesRow(y, leftWidth320, rightStartX320, sourceByRow[y]);
|
||||||
|
}
|
||||||
|
|
||||||
|
final debug = debugLogger;
|
||||||
|
if (debug != null) {
|
||||||
|
final String label = debugContext ?? 'header-band';
|
||||||
|
final String runsText = _summarizeRunsByBlackState(isBlackByRow);
|
||||||
|
debug(
|
||||||
|
'$label rows=$rowCount left=$leftWidth320 right=$rightStartX320 runs=$runsText',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _summarizeRunsByBlackState(List<bool> isBlackByRow) {
|
||||||
|
if (isBlackByRow.isEmpty) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
int runStart = 0;
|
||||||
|
bool runIsBlack = isBlackByRow[0];
|
||||||
|
final List<String> chunks = <String>[];
|
||||||
|
|
||||||
|
for (int y = 1; y < isBlackByRow.length; y++) {
|
||||||
|
if (isBlackByRow[y] == runIsBlack) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
chunks.add(
|
||||||
|
'[$runStart-${y - 1}:${runIsBlack ? 'black' : 'non-black'}]',
|
||||||
|
);
|
||||||
|
runStart = y;
|
||||||
|
runIsBlack = isBlackByRow[y];
|
||||||
|
}
|
||||||
|
chunks.add(
|
||||||
|
'[$runStart-${isBlackByRow.length - 1}:${runIsBlack ? 'black' : 'non-black'}]',
|
||||||
|
);
|
||||||
|
return chunks.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _effectiveBandRowCount(List<int> sampledByRow) {
|
||||||
|
if (sampledByRow.isEmpty) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int lastBlack = -1;
|
||||||
|
for (int y = sampledByRow.length - 1; y >= 0; y--) {
|
||||||
|
if (sampledByRow[y] == 0) {
|
||||||
|
lastBlack = y;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastBlack == -1) {
|
||||||
|
return sampledByRow.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
final int trailingRows = sampledByRow.length - (lastBlack + 1);
|
||||||
|
const int maxTrailingRows = 3;
|
||||||
|
final int keptTrailingRows = trailingRows > maxTrailingRows
|
||||||
|
? maxTrailingRows
|
||||||
|
: trailingRows;
|
||||||
|
return lastBlack + 1 + keptTrailingRows;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import 'dart:typed_data';
|
|||||||
import 'package:wolf_3d_dart/src/input/cli_input.dart';
|
import 'package:wolf_3d_dart/src/input/cli_input.dart';
|
||||||
import 'package:wolf_3d_dart/src/menu/menu_manager.dart';
|
import 'package:wolf_3d_dart/src/menu/menu_manager.dart';
|
||||||
import 'package:wolf_3d_dart/src/rendering/fizzle_fade.dart';
|
import 'package:wolf_3d_dart/src/rendering/fizzle_fade.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/rendering/menu_header_band.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
import 'package:wolf_3d_dart/wolf_3d_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_entities.dart';
|
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
|
||||||
@@ -476,35 +477,46 @@ class SixelRenderer extends CliRendererBackend<String> {
|
|||||||
engine.menuManager.menuBackgroundRgb,
|
engine.menuManager.menuBackgroundRgb,
|
||||||
);
|
);
|
||||||
final int panelColor = _rgbToPaletteIndex(engine.menuPanelRgb);
|
final int panelColor = _rgbToPaletteIndex(engine.menuPanelRgb);
|
||||||
final int headingIndex = WolfMenuPalette.headerTextIndex;
|
final menu = WolfMenuPresentation(engine.data);
|
||||||
final int selectedTextIndex = WolfMenuPalette.selectedTextIndex;
|
final bool isSpearVariant = MenuHeaderBand.isSpearVariant(
|
||||||
final int unselectedTextIndex = WolfMenuPalette.unselectedTextIndex;
|
engine.data.version,
|
||||||
final int disabledTextIndex = WolfMenuPalette.disabledTextIndex;
|
);
|
||||||
|
final int headingIndex = menu.headerTextIndex;
|
||||||
|
final int selectedTextIndex = menu.selectedTextIndex;
|
||||||
|
final int unselectedTextIndex = menu.unselectedTextIndex;
|
||||||
|
final int disabledTextIndex = menu.disabledTextIndex;
|
||||||
|
|
||||||
|
if (isSpearVariant && menu.heading != null) {
|
||||||
|
_drawTiledMenuBackdrop(menu.heading!, bgColor);
|
||||||
|
} else {
|
||||||
for (int i = 0; i < _screen.length; i++) {
|
for (int i = 0; i < _screen.length; i++) {
|
||||||
_screen[i] = bgColor;
|
_screen[i] = bgColor;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final art = WolfClassicMenuArt(engine.data);
|
final optionsLabel = menu.optionsLabel;
|
||||||
final optionsLabel = art.optionsLabel;
|
|
||||||
if (optionsLabel != null) {
|
if (optionsLabel != null) {
|
||||||
_mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
|
_mainMenuBandFirstColumn = MenuHeaderBand.firstColumn(optionsLabel);
|
||||||
}
|
}
|
||||||
// Draw footer first so menu panels can clip overlap in the center.
|
// Draw footer first so menu panels can clip overlap in the center.
|
||||||
_drawMenuFooterArt(art);
|
_drawMenuFooterArt(menu);
|
||||||
|
|
||||||
if (engine.menuManager.activeMenu == WolfMenuScreen.introSplash) {
|
if (engine.menuManager.activeMenu == WolfMenuScreen.introSplash) {
|
||||||
_drawIntroSplash(engine, art);
|
_drawIntroSplash(engine, menu);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (engine.menuManager.activeMenu == WolfMenuScreen.mainMenu) {
|
if (engine.menuManager.activeMenu == WolfMenuScreen.mainMenu) {
|
||||||
_fillRect320(68, 52, 178, 136, panelColor);
|
_fillRect320(68, 52, 178, 136, panelColor);
|
||||||
|
|
||||||
final optionsLabel = art.optionsLabel;
|
final optionsLabel = menu.optionsLabel;
|
||||||
if (optionsLabel != null) {
|
if (optionsLabel != null) {
|
||||||
final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319);
|
final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319);
|
||||||
_drawMainMenuOptionsSideBars(optionsLabel, optionsX);
|
_drawMainMenuOptionsSideBars(
|
||||||
|
optionsLabel,
|
||||||
|
optionsX,
|
||||||
|
debugContext: 'sixel/mainMenu',
|
||||||
|
);
|
||||||
_blitVgaImage(optionsLabel, optionsX, 0);
|
_blitVgaImage(optionsLabel, optionsX, 0);
|
||||||
} else {
|
} else {
|
||||||
_drawHeaderBarStack(
|
_drawHeaderBarStack(
|
||||||
@@ -520,7 +532,7 @@ class SixelRenderer extends CliRendererBackend<String> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final cursor = art.mappedPic(
|
final cursor = menu.mappedPic(
|
||||||
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
||||||
);
|
);
|
||||||
const int rowYStart = 55;
|
const int rowYStart = 55;
|
||||||
@@ -546,31 +558,43 @@ class SixelRenderer extends CliRendererBackend<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (engine.menuManager.activeMenu == WolfMenuScreen.gameSelect) {
|
if (engine.menuManager.activeMenu == WolfMenuScreen.gameSelect) {
|
||||||
|
final optionsLabel = menu.optionsLabel;
|
||||||
|
if (optionsLabel != null) {
|
||||||
|
final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319);
|
||||||
|
_drawMainMenuOptionsSideBars(
|
||||||
|
optionsLabel,
|
||||||
|
optionsX,
|
||||||
|
debugContext: 'sixel/gameSelect',
|
||||||
|
);
|
||||||
|
_blitVgaImage(optionsLabel, optionsX, 0);
|
||||||
|
} else {
|
||||||
_drawHeaderBarStack(
|
_drawHeaderBarStack(
|
||||||
headingY200: _headerHeadingY,
|
headingY200: _headerHeadingY,
|
||||||
backgroundColor: bgColor,
|
backgroundColor: bgColor,
|
||||||
barColor: 0,
|
barColor: 0,
|
||||||
);
|
);
|
||||||
_fillRect320(28, 58, 264, 104, panelColor);
|
|
||||||
_drawMenuTextCentered(
|
_drawMenuTextCentered(
|
||||||
'SELECT GAME',
|
'SELECT GAME',
|
||||||
_headerHeadingY,
|
_headerHeadingY,
|
||||||
headingIndex,
|
headingIndex,
|
||||||
scale: 2,
|
scale: 2,
|
||||||
);
|
);
|
||||||
final cursor = art.mappedPic(
|
}
|
||||||
|
_fillRect320(68, 52, 178, 136, panelColor);
|
||||||
|
|
||||||
|
final cursor = menu.mappedPic(
|
||||||
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
||||||
);
|
);
|
||||||
const int rowYStart = 84;
|
const int rowYStart = 55;
|
||||||
const int rowStep = 18;
|
const int rowStep = 13;
|
||||||
for (int i = 0; i < engine.availableGames.length; i++) {
|
for (int i = 0; i < engine.availableGames.length; i++) {
|
||||||
final bool isSelected = i == engine.menuManager.selectedGameIndex;
|
final bool isSelected = i == engine.menuManager.selectedGameIndex;
|
||||||
if (isSelected && cursor != null) {
|
if (isSelected && cursor != null) {
|
||||||
_blitVgaImage(cursor, 38, (rowYStart + (i * rowStep)) - 2);
|
_blitVgaImage(cursor, 72, (rowYStart + (i * rowStep)) - 2);
|
||||||
}
|
}
|
||||||
_drawMenuText(
|
_drawMenuText(
|
||||||
_gameTitle(engine.availableGames[i].version),
|
MenuHeaderBand.gameTitle(engine.availableGames[i].version),
|
||||||
70,
|
100,
|
||||||
rowYStart + (i * rowStep),
|
rowYStart + (i * rowStep),
|
||||||
isSelected ? selectedTextIndex : unselectedTextIndex,
|
isSelected ? selectedTextIndex : unselectedTextIndex,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
@@ -593,7 +617,7 @@ class SixelRenderer extends CliRendererBackend<String> {
|
|||||||
headingIndex,
|
headingIndex,
|
||||||
scale: 2,
|
scale: 2,
|
||||||
);
|
);
|
||||||
final cursor = art.mappedPic(
|
final cursor = menu.mappedPic(
|
||||||
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
||||||
);
|
);
|
||||||
const int rowYStart = 30;
|
const int rowYStart = 30;
|
||||||
@@ -604,7 +628,7 @@ class SixelRenderer extends CliRendererBackend<String> {
|
|||||||
if (isSelected && cursor != null) {
|
if (isSelected && cursor != null) {
|
||||||
_blitVgaImage(cursor, 16, y + 2);
|
_blitVgaImage(cursor, 16, y + 2);
|
||||||
}
|
}
|
||||||
final image = art.episodeOption(i);
|
final image = menu.episodeOption(i);
|
||||||
if (image != null) {
|
if (image != null) {
|
||||||
_blitVgaImage(image, 40, y);
|
_blitVgaImage(image, 40, y);
|
||||||
}
|
}
|
||||||
@@ -633,12 +657,16 @@ class SixelRenderer extends CliRendererBackend<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (engine.menuManager.activeMenu == WolfMenuScreen.changeView) {
|
if (engine.menuManager.activeMenu == WolfMenuScreen.changeView) {
|
||||||
_drawCustomizeMenuHeader(art, headingIndex, bgColor);
|
_drawCustomizeMenuHeader(
|
||||||
final cursor = art.mappedPic(
|
menu,
|
||||||
|
headingIndex,
|
||||||
|
bgColor,
|
||||||
|
);
|
||||||
|
final cursor = menu.mappedPic(
|
||||||
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
||||||
);
|
);
|
||||||
final selectedMarker = art.selectedMarker;
|
final selectedMarker = menu.selectedMarker;
|
||||||
final unselectedMarker = art.unselectedMarker;
|
final unselectedMarker = menu.unselectedMarker;
|
||||||
const int rowYStart = 66;
|
const int rowYStart = 66;
|
||||||
const int rowStep = 18;
|
const int rowStep = 18;
|
||||||
const int cursorX = 62;
|
const int cursorX = 62;
|
||||||
@@ -726,7 +754,11 @@ class SixelRenderer extends CliRendererBackend<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (engine.menuManager.activeMenu == WolfMenuScreen.rendererOptions) {
|
if (engine.menuManager.activeMenu == WolfMenuScreen.rendererOptions) {
|
||||||
_drawCustomizeMenuHeader(art, headingIndex, bgColor);
|
_drawCustomizeMenuHeader(
|
||||||
|
menu,
|
||||||
|
headingIndex,
|
||||||
|
bgColor,
|
||||||
|
);
|
||||||
_fillRect320(56, 52, 208, 120, panelColor);
|
_fillRect320(56, 52, 208, 120, panelColor);
|
||||||
_drawMenuTextCentered(
|
_drawMenuTextCentered(
|
||||||
engine.menuManager.rendererOptionsTitle,
|
engine.menuManager.rendererOptionsTitle,
|
||||||
@@ -735,11 +767,11 @@ class SixelRenderer extends CliRendererBackend<String> {
|
|||||||
scale: 1,
|
scale: 1,
|
||||||
);
|
);
|
||||||
|
|
||||||
final cursor = art.mappedPic(
|
final cursor = menu.mappedPic(
|
||||||
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
||||||
);
|
);
|
||||||
final selectedMarker = art.selectedMarker;
|
final selectedMarker = menu.selectedMarker;
|
||||||
final unselectedMarker = art.unselectedMarker;
|
final unselectedMarker = menu.unselectedMarker;
|
||||||
const int rowYStart = 68;
|
const int rowYStart = 68;
|
||||||
const int rowStep = 20;
|
const int rowStep = 20;
|
||||||
const int cursorX = 62;
|
const int cursorX = 62;
|
||||||
@@ -781,7 +813,12 @@ class SixelRenderer extends CliRendererBackend<String> {
|
|||||||
);
|
);
|
||||||
_fillRect320(28, 70, 264, 82, panelColor);
|
_fillRect320(28, 70, 264, 82, panelColor);
|
||||||
if (_useCompactMenuLayout) {
|
if (_useCompactMenuLayout) {
|
||||||
_drawCompactMenu(selectedDifficultyIndex, headingIndex, panelColor);
|
_drawCompactMenu(
|
||||||
|
selectedDifficultyIndex,
|
||||||
|
headingIndex,
|
||||||
|
panelColor,
|
||||||
|
menu,
|
||||||
|
);
|
||||||
_applyMenuTransition(engine.menuManager, bgColor);
|
_applyMenuTransition(engine.menuManager, bgColor);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -793,14 +830,14 @@ class SixelRenderer extends CliRendererBackend<String> {
|
|||||||
scale: _menuHeadingScale,
|
scale: _menuHeadingScale,
|
||||||
);
|
);
|
||||||
|
|
||||||
final face = art.difficultyOption(
|
final face = menu.difficultyOption(
|
||||||
Difficulty.values[selectedDifficultyIndex],
|
Difficulty.values[selectedDifficultyIndex],
|
||||||
);
|
);
|
||||||
if (face != null) {
|
if (face != null) {
|
||||||
_blitVgaImage(face, 28 + 264 - face.width - 10, 92);
|
_blitVgaImage(face, 28 + 264 - face.width - 10, 92);
|
||||||
}
|
}
|
||||||
|
|
||||||
final cursor = art.mappedPic(
|
final cursor = menu.mappedPic(
|
||||||
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
||||||
);
|
);
|
||||||
const rowYStart = 86;
|
const rowYStart = 86;
|
||||||
@@ -827,7 +864,7 @@ class SixelRenderer extends CliRendererBackend<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _drawCustomizeMenuHeader(
|
void _drawCustomizeMenuHeader(
|
||||||
WolfClassicMenuArt art,
|
WolfMenuPresentation menu,
|
||||||
int headingIndex,
|
int headingIndex,
|
||||||
int backgroundColor,
|
int backgroundColor,
|
||||||
) {
|
) {
|
||||||
@@ -837,9 +874,14 @@ class SixelRenderer extends CliRendererBackend<String> {
|
|||||||
barColor: 0,
|
barColor: 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
final VgaImage? heading = art.customizeLabel ?? art.optionsLabel;
|
final VgaImage? heading = menu.customizeLabel ?? menu.optionsLabel;
|
||||||
if (heading != null) {
|
if (heading != null) {
|
||||||
final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319);
|
final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319);
|
||||||
|
_drawMainMenuOptionsSideBars(
|
||||||
|
heading,
|
||||||
|
headingX,
|
||||||
|
debugContext: 'sixel/customizeHeader',
|
||||||
|
);
|
||||||
_blitVgaImage(heading, headingX, 0);
|
_blitVgaImage(heading, headingX, 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -865,8 +907,8 @@ class SixelRenderer extends CliRendererBackend<String> {
|
|||||||
_drawMenuTextCentered(text, y200 + 2, textColor, scale: 1);
|
_drawMenuTextCentered(text, y200 + 2, textColor, scale: 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drawMenuFooterArt(WolfClassicMenuArt art) {
|
void _drawMenuFooterArt(WolfMenuPresentation menu) {
|
||||||
final bottom = art.mappedPic(15);
|
final bottom = menu.mappedPic(15);
|
||||||
if (bottom == null) {
|
if (bottom == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -877,24 +919,11 @@ class SixelRenderer extends CliRendererBackend<String> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _gameTitle(GameVersion version) {
|
void _drawIntroSplash(WolfEngine engine, WolfMenuPresentation menu) {
|
||||||
switch (version) {
|
|
||||||
case GameVersion.shareware:
|
|
||||||
return 'SHAREWARE';
|
|
||||||
case GameVersion.retail:
|
|
||||||
return 'RETAIL';
|
|
||||||
case GameVersion.spearOfDestiny:
|
|
||||||
return 'SPEAR OF DESTINY';
|
|
||||||
case GameVersion.spearOfDestinyDemo:
|
|
||||||
return 'SOD DEMO';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _drawIntroSplash(WolfEngine engine, WolfClassicMenuArt art) {
|
|
||||||
final image = switch (engine.menuManager.currentIntroSlide) {
|
final image = switch (engine.menuManager.currentIntroSlide) {
|
||||||
WolfIntroSlide.retailWarning => null,
|
WolfIntroSlide.retailWarning => null,
|
||||||
WolfIntroSlide.pg13 => art.mappedPic(WolfMenuPic.pg13),
|
WolfIntroSlide.pg13 => menu.mappedPic(WolfMenuPic.pg13),
|
||||||
WolfIntroSlide.title => art.mappedPic(WolfMenuPic.title),
|
WolfIntroSlide.title => menu.mappedPic(WolfMenuPic.title),
|
||||||
};
|
};
|
||||||
|
|
||||||
int splashBg = _rgbToPaletteIndex(engine.menuManager.menuBackgroundRgb);
|
int splashBg = _rgbToPaletteIndex(engine.menuManager.menuBackgroundRgb);
|
||||||
@@ -1105,17 +1134,37 @@ class SixelRenderer extends CliRendererBackend<String> {
|
|||||||
_fillRect320(0, mainBarTop, 320, 22, barColor);
|
_fillRect320(0, mainBarTop, 320, 22, barColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drawMainMenuOptionsSideBars(VgaImage optionsLabel, int optionsX320) {
|
void _drawMainMenuOptionsSideBars(
|
||||||
_mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
|
VgaImage optionsLabel,
|
||||||
_drawScaledColumnBand(_mainMenuBandFirstColumn!);
|
int optionsX320, {
|
||||||
|
required String debugContext,
|
||||||
|
}) {
|
||||||
|
_mainMenuBandFirstColumn = MenuHeaderBand.firstColumn(optionsLabel);
|
||||||
|
MenuHeaderBand.applyFromHeadingImage(
|
||||||
|
image: optionsLabel,
|
||||||
|
imageX320: optionsX320,
|
||||||
|
debugContext: debugContext,
|
||||||
|
fillSideEdgesRow:
|
||||||
|
(
|
||||||
|
int y200,
|
||||||
|
int leftWidth320,
|
||||||
|
int rightStartX320,
|
||||||
|
int paletteIndex,
|
||||||
|
) {
|
||||||
|
if (leftWidth320 > 0) {
|
||||||
|
_fillRect320(0, y200, leftWidth320, 1, paletteIndex);
|
||||||
}
|
}
|
||||||
|
if (rightStartX320 < 320) {
|
||||||
List<int> _cacheFirstColumn(VgaImage image) {
|
_fillRect320(
|
||||||
final List<int> column = List<int>.filled(image.height, 0);
|
rightStartX320,
|
||||||
for (int y = 0; y < image.height; y++) {
|
y200,
|
||||||
column[y] = image.decodePixel(0, y);
|
320 - rightStartX320,
|
||||||
|
1,
|
||||||
|
paletteIndex,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return column;
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drawScaledColumnBand(List<int> column) {
|
void _drawScaledColumnBand(List<int> column) {
|
||||||
@@ -1123,6 +1172,18 @@ class SixelRenderer extends CliRendererBackend<String> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int firstBlack = -1;
|
||||||
|
int lastBlack = -1;
|
||||||
|
for (int y = 0; y < column.length; y++) {
|
||||||
|
if (column[y] == 0) {
|
||||||
|
firstBlack = firstBlack == -1 ? y : firstBlack;
|
||||||
|
lastBlack = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (firstBlack == -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final double scaleY = height / 200.0;
|
final double scaleY = height / 200.0;
|
||||||
final int destHeight = math.max(1, (column.length * scaleY).toInt());
|
final int destHeight = math.max(1, (column.length * scaleY).toInt());
|
||||||
|
|
||||||
@@ -1136,8 +1197,10 @@ class SixelRenderer extends CliRendererBackend<String> {
|
|||||||
0,
|
0,
|
||||||
column.length - 1,
|
column.length - 1,
|
||||||
);
|
);
|
||||||
final int paletteIndex = column[srcY];
|
if (srcY < firstBlack || srcY > lastBlack) {
|
||||||
final int fillIndex = paletteIndex == 0 ? 0 : paletteIndex;
|
continue;
|
||||||
|
}
|
||||||
|
final int fillIndex = column[srcY];
|
||||||
final int rowStart = drawY * width;
|
final int rowStart = drawY * width;
|
||||||
for (int drawX = 0; drawX < width; drawX++) {
|
for (int drawX = 0; drawX < width; drawX++) {
|
||||||
_screen[rowStart + drawX] = fillIndex;
|
_screen[rowStart + drawX] = fillIndex;
|
||||||
@@ -1157,6 +1220,7 @@ class SixelRenderer extends CliRendererBackend<String> {
|
|||||||
int selectedDifficultyIndex,
|
int selectedDifficultyIndex,
|
||||||
int headingIndex,
|
int headingIndex,
|
||||||
int panelColor,
|
int panelColor,
|
||||||
|
WolfMenuPresentation menu,
|
||||||
) {
|
) {
|
||||||
_fillRect320(16, 52, 288, 112, panelColor);
|
_fillRect320(16, 52, 288, 112, panelColor);
|
||||||
_drawMenuTextCentered(Difficulty.menuText, 60, headingIndex, scale: 1);
|
_drawMenuTextCentered(Difficulty.menuText, 60, headingIndex, scale: 1);
|
||||||
@@ -1170,9 +1234,7 @@ class SixelRenderer extends CliRendererBackend<String> {
|
|||||||
prefix + Difficulty.values[i].title,
|
prefix + Difficulty.values[i].title,
|
||||||
42,
|
42,
|
||||||
rowYStart + (i * rowStep),
|
rowYStart + (i * rowStep),
|
||||||
isSelected
|
isSelected ? menu.selectedTextIndex : menu.unselectedTextIndex,
|
||||||
? WolfMenuPalette.selectedTextIndex
|
|
||||||
: WolfMenuPalette.unselectedTextIndex,
|
|
||||||
scale: 1,
|
scale: 1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1540,4 +1602,18 @@ class SixelRenderer extends CliRendererBackend<String> {
|
|||||||
int _rgbToPaletteIndex(int rgb) {
|
int _rgbToPaletteIndex(int rgb) {
|
||||||
return ColorPalette.findClosestPaletteIndex(rgb);
|
return ColorPalette.findClosestPaletteIndex(rgb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _drawTiledMenuBackdrop(VgaImage image, int fallbackColor) {
|
||||||
|
if (image.width <= 0 || image.height <= 0) {
|
||||||
|
for (int i = 0; i < _screen.length; i++) {
|
||||||
|
_screen[i] = fallbackColor;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (int y = 0; y < 200; y += image.height) {
|
||||||
|
for (int x = 0; x < 320; x += image.width) {
|
||||||
|
_blitVgaImage(image, x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'ascii_renderer.dart';
|
||||||
|
|
||||||
|
/// Web-safe stub used when dart:io is unavailable.
|
||||||
|
class SixelRenderer extends AsciiRenderer {
|
||||||
|
SixelRenderer() : super(mode: AsciiRendererMode.terminalAnsi);
|
||||||
|
|
||||||
|
bool isSixelSupported = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool isTerminalSizeSupported(int columns, int rows) => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get terminalSizeRequirement =>
|
||||||
|
'Sixel renderer is unavailable on this platform.';
|
||||||
|
|
||||||
|
static Future<bool> checkTerminalSixelSupport({
|
||||||
|
Stream<List<int>>? inputStream,
|
||||||
|
}) async {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'dart:typed_data';
|
|||||||
import 'package:wolf_3d_dart/src/menu/menu_manager.dart';
|
import 'package:wolf_3d_dart/src/menu/menu_manager.dart';
|
||||||
import 'package:wolf_3d_dart/src/rendering/fizzle_fade.dart';
|
import 'package:wolf_3d_dart/src/rendering/fizzle_fade.dart';
|
||||||
import 'package:wolf_3d_dart/src/rendering/menu_font.dart';
|
import 'package:wolf_3d_dart/src/rendering/menu_font.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/rendering/menu_header_band.dart';
|
||||||
import 'package:wolf_3d_dart/src/rendering/renderer_backend.dart';
|
import 'package:wolf_3d_dart/src/rendering/renderer_backend.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';
|
||||||
@@ -254,31 +255,38 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
|
|||||||
void drawMenu(WolfEngine engine) {
|
void drawMenu(WolfEngine engine) {
|
||||||
final int bgColor = _rgbToFrameColor(engine.menuManager.menuBackgroundRgb);
|
final int bgColor = _rgbToFrameColor(engine.menuManager.menuBackgroundRgb);
|
||||||
final int panelColor = _rgbToFrameColor(engine.menuPanelRgb);
|
final int panelColor = _rgbToFrameColor(engine.menuPanelRgb);
|
||||||
final int headingColor = WolfMenuPalette.headerTextColor;
|
final menu = WolfMenuPresentation(engine.data);
|
||||||
final int selectedTextColor = WolfMenuPalette.selectedTextColor;
|
final bool isSpearVariant = MenuHeaderBand.isSpearVariant(
|
||||||
final int unselectedTextColor = WolfMenuPalette.unselectedTextColor;
|
engine.data.version,
|
||||||
final int disabledTextColor = WolfMenuPalette.disabledTextColor;
|
);
|
||||||
|
final int headingColor = menu.headerTextColor;
|
||||||
|
final int selectedTextColor = menu.selectedTextColor;
|
||||||
|
final int unselectedTextColor = menu.unselectedTextColor;
|
||||||
|
final int disabledTextColor = menu.disabledTextColor;
|
||||||
|
|
||||||
|
if (isSpearVariant && menu.heading != null) {
|
||||||
|
_drawTiledMenuBackdrop(menu.heading!, bgColor);
|
||||||
|
} else {
|
||||||
for (int i = 0; i < _buffer.pixels.length; i++) {
|
for (int i = 0; i < _buffer.pixels.length; i++) {
|
||||||
_buffer.pixels[i] = bgColor;
|
_buffer.pixels[i] = bgColor;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final art = WolfClassicMenuArt(engine.data);
|
final optionsLabel = menu.optionsLabel;
|
||||||
final optionsLabel = art.optionsLabel;
|
|
||||||
if (optionsLabel != null) {
|
if (optionsLabel != null) {
|
||||||
_mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
|
_mainMenuBandFirstColumn = MenuHeaderBand.firstColumn(optionsLabel);
|
||||||
}
|
}
|
||||||
// Draw footer first so menu panels can clip overlap in the center.
|
// Draw footer first so menu panels can clip overlap in the center.
|
||||||
_drawCenteredMenuFooter(art);
|
_drawCenteredMenuFooter(menu);
|
||||||
|
|
||||||
switch (engine.menuManager.activeMenu) {
|
switch (engine.menuManager.activeMenu) {
|
||||||
case WolfMenuScreen.introSplash:
|
case WolfMenuScreen.introSplash:
|
||||||
_drawIntroSplash(engine, art);
|
_drawIntroSplash(engine, menu);
|
||||||
break;
|
break;
|
||||||
case WolfMenuScreen.mainMenu:
|
case WolfMenuScreen.mainMenu:
|
||||||
_drawMainMenu(
|
_drawMainMenu(
|
||||||
engine,
|
engine,
|
||||||
art,
|
menu,
|
||||||
panelColor,
|
panelColor,
|
||||||
headingColor,
|
headingColor,
|
||||||
selectedTextColor,
|
selectedTextColor,
|
||||||
@@ -289,7 +297,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
|
|||||||
case WolfMenuScreen.gameSelect:
|
case WolfMenuScreen.gameSelect:
|
||||||
_drawGameSelectMenu(
|
_drawGameSelectMenu(
|
||||||
engine,
|
engine,
|
||||||
art,
|
menu,
|
||||||
panelColor,
|
panelColor,
|
||||||
headingColor,
|
headingColor,
|
||||||
selectedTextColor,
|
selectedTextColor,
|
||||||
@@ -299,7 +307,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
|
|||||||
case WolfMenuScreen.episodeSelect:
|
case WolfMenuScreen.episodeSelect:
|
||||||
_drawEpisodeSelectMenu(
|
_drawEpisodeSelectMenu(
|
||||||
engine,
|
engine,
|
||||||
art,
|
menu,
|
||||||
panelColor,
|
panelColor,
|
||||||
headingColor,
|
headingColor,
|
||||||
selectedTextColor,
|
selectedTextColor,
|
||||||
@@ -309,7 +317,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
|
|||||||
case WolfMenuScreen.difficultySelect:
|
case WolfMenuScreen.difficultySelect:
|
||||||
_drawDifficultyMenu(
|
_drawDifficultyMenu(
|
||||||
engine,
|
engine,
|
||||||
art,
|
menu,
|
||||||
panelColor,
|
panelColor,
|
||||||
headingColor,
|
headingColor,
|
||||||
selectedTextColor,
|
selectedTextColor,
|
||||||
@@ -319,7 +327,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
|
|||||||
case WolfMenuScreen.changeView:
|
case WolfMenuScreen.changeView:
|
||||||
_drawChangeViewMenu(
|
_drawChangeViewMenu(
|
||||||
engine,
|
engine,
|
||||||
art,
|
menu,
|
||||||
panelColor,
|
panelColor,
|
||||||
headingColor,
|
headingColor,
|
||||||
selectedTextColor,
|
selectedTextColor,
|
||||||
@@ -330,7 +338,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
|
|||||||
case WolfMenuScreen.rendererOptions:
|
case WolfMenuScreen.rendererOptions:
|
||||||
_drawRendererOptionsMenu(
|
_drawRendererOptionsMenu(
|
||||||
engine,
|
engine,
|
||||||
art,
|
menu,
|
||||||
panelColor,
|
panelColor,
|
||||||
headingColor,
|
headingColor,
|
||||||
selectedTextColor,
|
selectedTextColor,
|
||||||
@@ -343,11 +351,11 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
|
|||||||
_applyMenuTransition(engine.menuManager, bgColor);
|
_applyMenuTransition(engine.menuManager, bgColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drawIntroSplash(WolfEngine engine, WolfClassicMenuArt art) {
|
void _drawIntroSplash(WolfEngine engine, WolfMenuPresentation menu) {
|
||||||
final image = switch (engine.menuManager.currentIntroSlide) {
|
final image = switch (engine.menuManager.currentIntroSlide) {
|
||||||
WolfIntroSlide.retailWarning => null,
|
WolfIntroSlide.retailWarning => null,
|
||||||
WolfIntroSlide.pg13 => art.mappedPic(WolfMenuPic.pg13),
|
WolfIntroSlide.pg13 => menu.mappedPic(WolfMenuPic.pg13),
|
||||||
WolfIntroSlide.title => art.mappedPic(WolfMenuPic.title),
|
WolfIntroSlide.title => menu.mappedPic(WolfMenuPic.title),
|
||||||
};
|
};
|
||||||
|
|
||||||
int splashBgColor = _rgbToFrameColor(engine.menuManager.menuBackgroundRgb);
|
int splashBgColor = _rgbToFrameColor(engine.menuManager.menuBackgroundRgb);
|
||||||
@@ -478,7 +486,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
|
|||||||
|
|
||||||
void _drawMainMenu(
|
void _drawMainMenu(
|
||||||
WolfEngine engine,
|
WolfEngine engine,
|
||||||
WolfClassicMenuArt art,
|
WolfMenuPresentation menu,
|
||||||
int panelColor,
|
int panelColor,
|
||||||
int headingColor,
|
int headingColor,
|
||||||
int selectedTextColor,
|
int selectedTextColor,
|
||||||
@@ -491,10 +499,14 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
|
|||||||
const int panelH = 136;
|
const int panelH = 136;
|
||||||
_fillCanonicalRect(panelX, panelY, panelW, panelH, panelColor);
|
_fillCanonicalRect(panelX, panelY, panelW, panelH, panelColor);
|
||||||
|
|
||||||
final optionsLabel = art.optionsLabel;
|
final optionsLabel = menu.optionsLabel;
|
||||||
if (optionsLabel != null) {
|
if (optionsLabel != null) {
|
||||||
final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319);
|
final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319);
|
||||||
_drawMainMenuOptionsSideBars(optionsLabel, optionsX);
|
_drawMainMenuOptionsSideBars(
|
||||||
|
optionsLabel,
|
||||||
|
optionsX,
|
||||||
|
debugContext: 'software/mainMenu',
|
||||||
|
);
|
||||||
_blitVgaImage(optionsLabel, optionsX, 0);
|
_blitVgaImage(optionsLabel, optionsX, 0);
|
||||||
} else {
|
} else {
|
||||||
_drawHeaderBarStack(
|
_drawHeaderBarStack(
|
||||||
@@ -510,7 +522,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final cursor = art.mappedPic(
|
final cursor = menu.mappedPic(
|
||||||
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
||||||
);
|
);
|
||||||
const int rowYStart = 55;
|
const int rowYStart = 55;
|
||||||
@@ -538,7 +550,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
|
|||||||
|
|
||||||
void _drawChangeViewMenu(
|
void _drawChangeViewMenu(
|
||||||
WolfEngine engine,
|
WolfEngine engine,
|
||||||
WolfClassicMenuArt art,
|
WolfMenuPresentation menu,
|
||||||
int panelColor,
|
int panelColor,
|
||||||
int headingColor,
|
int headingColor,
|
||||||
int selectedTextColor,
|
int selectedTextColor,
|
||||||
@@ -557,9 +569,14 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
|
|||||||
const int optionsPanelX = 46;
|
const int optionsPanelX = 46;
|
||||||
const int optionsPanelW = 228;
|
const int optionsPanelW = 228;
|
||||||
|
|
||||||
final VgaImage? heading = art.customizeLabel ?? art.optionsLabel;
|
final VgaImage? heading = menu.customizeLabel ?? menu.optionsLabel;
|
||||||
if (heading != null) {
|
if (heading != null) {
|
||||||
final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319);
|
final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319);
|
||||||
|
_drawMainMenuOptionsSideBars(
|
||||||
|
heading,
|
||||||
|
headingX,
|
||||||
|
debugContext: 'software/changeView',
|
||||||
|
);
|
||||||
_blitVgaImage(heading, headingX, 0);
|
_blitVgaImage(heading, headingX, 0);
|
||||||
} else {
|
} else {
|
||||||
_drawCanonicalMenuTextCentered(
|
_drawCanonicalMenuTextCentered(
|
||||||
@@ -570,9 +587,9 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final VgaImage? selectedMarker = art.selectedMarker;
|
final VgaImage? selectedMarker = menu.selectedMarker;
|
||||||
final VgaImage? unselectedMarker = art.unselectedMarker;
|
final VgaImage? unselectedMarker = menu.unselectedMarker;
|
||||||
final VgaImage? cursor = art.mappedPic(
|
final VgaImage? cursor = menu.mappedPic(
|
||||||
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -684,7 +701,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
|
|||||||
|
|
||||||
void _drawRendererOptionsMenu(
|
void _drawRendererOptionsMenu(
|
||||||
WolfEngine engine,
|
WolfEngine engine,
|
||||||
WolfClassicMenuArt art,
|
WolfMenuPresentation menu,
|
||||||
int panelColor,
|
int panelColor,
|
||||||
int headingColor,
|
int headingColor,
|
||||||
int selectedTextColor,
|
int selectedTextColor,
|
||||||
@@ -703,9 +720,14 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
|
|||||||
const int panelH = 120;
|
const int panelH = 120;
|
||||||
_fillCanonicalRect(panelX, panelY, panelW, panelH, panelColor);
|
_fillCanonicalRect(panelX, panelY, panelW, panelH, panelColor);
|
||||||
|
|
||||||
final VgaImage? heading = art.customizeLabel ?? art.optionsLabel;
|
final VgaImage? heading = menu.customizeLabel ?? menu.optionsLabel;
|
||||||
if (heading != null) {
|
if (heading != null) {
|
||||||
final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319);
|
final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319);
|
||||||
|
_drawMainMenuOptionsSideBars(
|
||||||
|
heading,
|
||||||
|
headingX,
|
||||||
|
debugContext: 'software/rendererOptions',
|
||||||
|
);
|
||||||
_blitVgaImage(heading, headingX, 0);
|
_blitVgaImage(heading, headingX, 0);
|
||||||
} else {
|
} else {
|
||||||
_drawCanonicalMenuTextCentered(
|
_drawCanonicalMenuTextCentered(
|
||||||
@@ -716,9 +738,9 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final VgaImage? selectedMarker = art.selectedMarker;
|
final VgaImage? selectedMarker = menu.selectedMarker;
|
||||||
final VgaImage? unselectedMarker = art.unselectedMarker;
|
final VgaImage? unselectedMarker = menu.unselectedMarker;
|
||||||
final VgaImage? cursor = art.mappedPic(
|
final VgaImage? cursor = menu.mappedPic(
|
||||||
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -766,48 +788,58 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
|
|||||||
|
|
||||||
void _drawGameSelectMenu(
|
void _drawGameSelectMenu(
|
||||||
WolfEngine engine,
|
WolfEngine engine,
|
||||||
WolfClassicMenuArt art,
|
WolfMenuPresentation menu,
|
||||||
int panelColor,
|
int panelColor,
|
||||||
int headingColor,
|
int headingColor,
|
||||||
int selectedTextColor,
|
int selectedTextColor,
|
||||||
int unselectedTextColor,
|
int unselectedTextColor,
|
||||||
) {
|
) {
|
||||||
|
final optionsLabel = menu.optionsLabel;
|
||||||
|
if (optionsLabel != null) {
|
||||||
|
final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319);
|
||||||
|
_drawMainMenuOptionsSideBars(
|
||||||
|
optionsLabel,
|
||||||
|
optionsX,
|
||||||
|
debugContext: 'software/gameSelect',
|
||||||
|
);
|
||||||
|
_blitVgaImage(optionsLabel, optionsX, 0);
|
||||||
|
} else {
|
||||||
_drawHeaderBarStack(
|
_drawHeaderBarStack(
|
||||||
headingY200: _headerHeadingY,
|
headingY200: _headerHeadingY,
|
||||||
backgroundColor: _rgbToFrameColor(engine.menuManager.menuBackgroundRgb),
|
backgroundColor: _rgbToFrameColor(engine.menuManager.menuBackgroundRgb),
|
||||||
barColor: ColorPalette.vga32Bit[0],
|
barColor: ColorPalette.vga32Bit[0],
|
||||||
);
|
);
|
||||||
|
|
||||||
const int panelX = 28;
|
|
||||||
const int panelY = 58;
|
|
||||||
const int panelW = 264;
|
|
||||||
const int panelH = 104;
|
|
||||||
_fillCanonicalRect(panelX, panelY, panelW, panelH, panelColor);
|
|
||||||
|
|
||||||
_drawCanonicalMenuTextCentered(
|
_drawCanonicalMenuTextCentered(
|
||||||
'SELECT GAME',
|
'SELECT GAME',
|
||||||
_headerHeadingY,
|
_headerHeadingY,
|
||||||
headingColor,
|
headingColor,
|
||||||
scale: 2,
|
scale: 2,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final cursor = art.mappedPic(
|
const int panelX = 68;
|
||||||
|
const int panelY = 52;
|
||||||
|
const int panelW = 178;
|
||||||
|
const int panelH = 136;
|
||||||
|
_fillCanonicalRect(panelX, panelY, panelW, panelH, panelColor);
|
||||||
|
|
||||||
|
final cursor = menu.mappedPic(
|
||||||
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
||||||
);
|
);
|
||||||
|
|
||||||
const int rowYStart = 78;
|
const int rowYStart = 55;
|
||||||
const int rowStep = 20;
|
const int rowStep = 13;
|
||||||
const int textX = 70;
|
const int textX = 100;
|
||||||
final int selectedIndex = engine.menuManager.selectedGameIndex;
|
final int selectedIndex = engine.menuManager.selectedGameIndex;
|
||||||
|
|
||||||
for (int i = 0; i < engine.availableGames.length; i++) {
|
for (int i = 0; i < engine.availableGames.length; i++) {
|
||||||
final bool isSelected = i == selectedIndex;
|
final bool isSelected = i == selectedIndex;
|
||||||
final int y = rowYStart + (i * rowStep);
|
final int y = rowYStart + (i * rowStep);
|
||||||
if (isSelected && cursor != null) {
|
if (isSelected && cursor != null) {
|
||||||
_blitVgaImage(cursor, panelX + 10, y - 2);
|
_blitVgaImage(cursor, panelX + 4, y - 2);
|
||||||
}
|
}
|
||||||
_drawCanonicalMenuText(
|
_drawCanonicalMenuText(
|
||||||
_gameTitle(engine.availableGames[i].version),
|
MenuHeaderBand.gameTitle(engine.availableGames[i].version),
|
||||||
textX,
|
textX,
|
||||||
y,
|
y,
|
||||||
isSelected ? selectedTextColor : unselectedTextColor,
|
isSelected ? selectedTextColor : unselectedTextColor,
|
||||||
@@ -837,7 +869,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
|
|||||||
|
|
||||||
void _drawEpisodeSelectMenu(
|
void _drawEpisodeSelectMenu(
|
||||||
WolfEngine engine,
|
WolfEngine engine,
|
||||||
WolfClassicMenuArt art,
|
WolfMenuPresentation menu,
|
||||||
int panelColor,
|
int panelColor,
|
||||||
int headingColor,
|
int headingColor,
|
||||||
int selectedTextColor,
|
int selectedTextColor,
|
||||||
@@ -862,7 +894,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
|
|||||||
scale: 2,
|
scale: 2,
|
||||||
);
|
);
|
||||||
|
|
||||||
final cursor = art.mappedPic(
|
final cursor = menu.mappedPic(
|
||||||
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
||||||
);
|
);
|
||||||
const int rowYStart = 30;
|
const int rowYStart = 30;
|
||||||
@@ -879,7 +911,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
|
|||||||
_blitVgaImage(cursor, 16, y + 2);
|
_blitVgaImage(cursor, 16, y + 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
final image = art.episodeOption(i);
|
final image = menu.episodeOption(i);
|
||||||
if (image != null) {
|
if (image != null) {
|
||||||
_blitVgaImage(image, imageX, y);
|
_blitVgaImage(image, imageX, y);
|
||||||
}
|
}
|
||||||
@@ -904,8 +936,8 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drawCenteredMenuFooter(WolfClassicMenuArt art) {
|
void _drawCenteredMenuFooter(WolfMenuPresentation menu) {
|
||||||
final bottom = art.mappedPic(15);
|
final bottom = menu.mappedPic(15);
|
||||||
if (bottom != null) {
|
if (bottom != null) {
|
||||||
final int x = ((320 - bottom.width) ~/ 2).clamp(0, 319);
|
final int x = ((320 - bottom.width) ~/ 2).clamp(0, 319);
|
||||||
final int y = (200 - bottom.height - _menuFooterBottomMargin).clamp(
|
final int y = (200 - bottom.height - _menuFooterBottomMargin).clamp(
|
||||||
@@ -954,7 +986,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
|
|||||||
|
|
||||||
void _drawDifficultyMenu(
|
void _drawDifficultyMenu(
|
||||||
WolfEngine engine,
|
WolfEngine engine,
|
||||||
WolfClassicMenuArt art,
|
WolfMenuPresentation menu,
|
||||||
int panelColor,
|
int panelColor,
|
||||||
int headingColor,
|
int headingColor,
|
||||||
int selectedTextColor,
|
int selectedTextColor,
|
||||||
@@ -981,14 +1013,14 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
|
|||||||
scale: 2,
|
scale: 2,
|
||||||
);
|
);
|
||||||
|
|
||||||
final face = art.difficultyOption(
|
final face = menu.difficultyOption(
|
||||||
Difficulty.values[selectedDifficultyIndex],
|
Difficulty.values[selectedDifficultyIndex],
|
||||||
);
|
);
|
||||||
if (face != null) {
|
if (face != null) {
|
||||||
_blitVgaImage(face, panelX + panelW - face.width - 10, panelY + 22);
|
_blitVgaImage(face, panelX + panelW - face.width - 10, panelY + 22);
|
||||||
}
|
}
|
||||||
|
|
||||||
final cursor = art.mappedPic(
|
final cursor = menu.mappedPic(
|
||||||
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
||||||
);
|
);
|
||||||
const int rowYStart = panelY + 16;
|
const int rowYStart = panelY + 16;
|
||||||
@@ -1052,19 +1084,6 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
|
|||||||
_buffer.pixels[(y * width) + x] = color;
|
_buffer.pixels[(y * width) + x] = color;
|
||||||
}
|
}
|
||||||
|
|
||||||
String _gameTitle(GameVersion version) {
|
|
||||||
switch (version) {
|
|
||||||
case GameVersion.shareware:
|
|
||||||
return 'SHAREWARE';
|
|
||||||
case GameVersion.retail:
|
|
||||||
return 'RETAIL';
|
|
||||||
case GameVersion.spearOfDestiny:
|
|
||||||
return 'SPEAR OF DESTINY';
|
|
||||||
case GameVersion.spearOfDestinyDemo:
|
|
||||||
return 'SOD DEMO';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _applyMenuTransition(MenuManager menuManager, int coverColor) {
|
void _applyMenuTransition(MenuManager menuManager, int coverColor) {
|
||||||
switch (menuManager.transitionEffect) {
|
switch (menuManager.transitionEffect) {
|
||||||
case WolfTransitionEffect.none:
|
case WolfTransitionEffect.none:
|
||||||
@@ -1215,10 +1234,24 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
|
|||||||
final List<int>? cachedColumn = _mainMenuBandFirstColumn;
|
final List<int>? cachedColumn = _mainMenuBandFirstColumn;
|
||||||
if (cachedColumn != null && cachedColumn.isNotEmpty) {
|
if (cachedColumn != null && cachedColumn.isNotEmpty) {
|
||||||
final int bandHeight = cachedColumn.length.clamp(0, 200);
|
final int bandHeight = cachedColumn.length.clamp(0, 200);
|
||||||
|
int firstBlack = -1;
|
||||||
|
int lastBlack = -1;
|
||||||
for (int y = 0; y < bandHeight; y++) {
|
for (int y = 0; y < bandHeight; y++) {
|
||||||
final int paletteIndex = cachedColumn[y];
|
if (cachedColumn[y] == 0) {
|
||||||
final int fillIndex = paletteIndex == 0 ? 0 : paletteIndex;
|
firstBlack = firstBlack == -1 ? y : firstBlack;
|
||||||
_fillCanonicalRect(0, y, 320, 1, ColorPalette.vga32Bit[fillIndex]);
|
lastBlack = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (firstBlack != -1) {
|
||||||
|
for (int y = firstBlack; y <= lastBlack; y++) {
|
||||||
|
_fillCanonicalRect(
|
||||||
|
0,
|
||||||
|
y,
|
||||||
|
320,
|
||||||
|
1,
|
||||||
|
ColorPalette.vga32Bit[cachedColumn[y]],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1266,22 +1299,52 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drawMainMenuOptionsSideBars(VgaImage optionsLabel, int optionsX320) {
|
void _drawTiledMenuBackdrop(VgaImage image, int fallbackColor) {
|
||||||
_mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
|
if (image.width <= 0 || image.height <= 0) {
|
||||||
final List<int> firstColumn = _mainMenuBandFirstColumn!;
|
for (int i = 0; i < _buffer.pixels.length; i++) {
|
||||||
for (int y = 0; y < optionsLabel.height; y++) {
|
_buffer.pixels[i] = fallbackColor;
|
||||||
final int paletteIndex = firstColumn[y];
|
}
|
||||||
final int fillIndex = paletteIndex == 0 ? 0 : paletteIndex;
|
return;
|
||||||
_fillCanonicalRect(0, y, 320, 1, ColorPalette.vga32Bit[fillIndex]);
|
}
|
||||||
|
for (int y = 0; y < 200; y += image.height) {
|
||||||
|
for (int x = 0; x < 320; x += image.width) {
|
||||||
|
_blitVgaImage(image, x, y);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<int> _cacheFirstColumn(VgaImage image) {
|
void _drawMainMenuOptionsSideBars(
|
||||||
final List<int> column = List<int>.filled(image.height, 0);
|
VgaImage optionsLabel,
|
||||||
for (int y = 0; y < image.height; y++) {
|
int optionsX320, {
|
||||||
column[y] = image.decodePixel(0, y);
|
required String debugContext,
|
||||||
|
}) {
|
||||||
|
_mainMenuBandFirstColumn = MenuHeaderBand.firstColumn(optionsLabel);
|
||||||
|
MenuHeaderBand.applyFromHeadingImage(
|
||||||
|
image: optionsLabel,
|
||||||
|
imageX320: optionsX320,
|
||||||
|
debugContext: debugContext,
|
||||||
|
fillSideEdgesRow:
|
||||||
|
(
|
||||||
|
int y200,
|
||||||
|
int leftWidth320,
|
||||||
|
int rightStartX320,
|
||||||
|
int paletteIndex,
|
||||||
|
) {
|
||||||
|
final int color = ColorPalette.vga32Bit[paletteIndex];
|
||||||
|
if (leftWidth320 > 0) {
|
||||||
|
_fillCanonicalRect(0, y200, leftWidth320, 1, color);
|
||||||
}
|
}
|
||||||
return column;
|
if (rightStartX320 < 320) {
|
||||||
|
_fillCanonicalRect(
|
||||||
|
rightStartX320,
|
||||||
|
y200,
|
||||||
|
320 - rightStartX320,
|
||||||
|
1,
|
||||||
|
color,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drawCanonicalMenuText(
|
void _drawCanonicalMenuText(
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
library;
|
||||||
|
|
||||||
|
export 'src/engine/audio/debug_music_player.dart' show DebugMusicPlayer;
|
||||||
|
export 'src/engine/audio/native_subprocess_audio_stub.dart'
|
||||||
|
if (dart.library.io) 'src/engine/audio/native_subprocess_audio_io.dart'
|
||||||
|
show NativeSubprocessAudio;
|
||||||
@@ -26,12 +26,12 @@ export 'src/data_types/wolf_level.dart' show WolfLevel;
|
|||||||
export 'src/data_types/wolfenstein_data.dart' show WolfensteinData;
|
export 'src/data_types/wolfenstein_data.dart' show WolfensteinData;
|
||||||
// Registry public surface
|
// Registry public surface
|
||||||
export 'src/registry/asset_registry.dart' show AssetRegistry;
|
export 'src/registry/asset_registry.dart' show AssetRegistry;
|
||||||
export 'src/registry/built_in/retail_asset_registry.dart'
|
export 'src/registry/built_in/menu/spear/spear_demo_asset_registry.dart'
|
||||||
show RetailAssetRegistry;
|
|
||||||
export 'src/registry/built_in/shareware_asset_registry.dart'
|
|
||||||
show SharewareAssetRegistry;
|
|
||||||
export 'src/registry/built_in/spear_demo_asset_registry.dart'
|
|
||||||
show SpearDemoAssetRegistry;
|
show SpearDemoAssetRegistry;
|
||||||
|
export 'src/registry/built_in/menu/wolf/retail_asset_registry.dart'
|
||||||
|
show RetailAssetRegistry;
|
||||||
|
export 'src/registry/built_in/menu/wolf/shareware_asset_registry.dart'
|
||||||
|
show SharewareAssetRegistry;
|
||||||
export 'src/registry/keys/entity_key.dart' show EntityKey;
|
export 'src/registry/keys/entity_key.dart' show EntityKey;
|
||||||
export 'src/registry/keys/hud_key.dart' show HudKey;
|
export 'src/registry/keys/hud_key.dart' show HudKey;
|
||||||
export 'src/registry/keys/menu_pic_key.dart' show MenuPicKey;
|
export 'src/registry/keys/menu_pic_key.dart' show MenuPicKey;
|
||||||
@@ -42,6 +42,8 @@ export 'src/registry/modules/entity_asset_module.dart'
|
|||||||
export 'src/registry/modules/hud_module.dart' show HudModule, HudAssetRef;
|
export 'src/registry/modules/hud_module.dart' show HudModule, HudAssetRef;
|
||||||
export 'src/registry/modules/menu_pic_module.dart'
|
export 'src/registry/modules/menu_pic_module.dart'
|
||||||
show MenuPicModule, MenuPicRef;
|
show MenuPicModule, MenuPicRef;
|
||||||
|
export 'src/registry/modules/menu_presentation_module.dart'
|
||||||
|
show MenuPresentationModule;
|
||||||
export 'src/registry/modules/music_module.dart' show MusicModule, MusicRoute;
|
export 'src/registry/modules/music_module.dart' show MusicModule, MusicRoute;
|
||||||
export 'src/registry/modules/sfx_module.dart' show SfxModule, SoundAssetRef;
|
export 'src/registry/modules/sfx_module.dart' show SfxModule, SoundAssetRef;
|
||||||
export 'src/registry/registry_resolver.dart'
|
export 'src/registry/registry_resolver.dart'
|
||||||
|
|||||||
@@ -6,15 +6,18 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
export 'src/engine/audio/engine_audio.dart';
|
export 'src/engine/audio/engine_audio.dart';
|
||||||
export 'src/engine/audio/silent_renderer.dart';
|
export 'src/engine/audio/silent_audio.dart';
|
||||||
export 'src/engine/input/engine_input.dart';
|
export 'src/engine/input/engine_input.dart';
|
||||||
export 'src/engine/managers/door_manager.dart';
|
export 'src/engine/managers/door_manager.dart';
|
||||||
export 'src/engine/managers/pushwall_manager.dart';
|
export 'src/engine/managers/pushwall_manager.dart';
|
||||||
export 'src/engine/player/player.dart';
|
export 'src/engine/player/player.dart';
|
||||||
export 'src/engine/player_locomotion_constants.dart';
|
export 'src/engine/player_locomotion_constants.dart';
|
||||||
|
export 'src/engine/rendering/default_renderer_settings_persistence.dart';
|
||||||
export 'src/engine/rendering/renderer_settings.dart';
|
export 'src/engine/rendering/renderer_settings.dart';
|
||||||
export 'src/engine/rendering/renderer_settings_persistence.dart';
|
export 'src/engine/rendering/renderer_settings_persistence.dart';
|
||||||
|
export 'src/engine/save/default_save_game_persistence.dart';
|
||||||
export 'src/engine/save/game_session_snapshot.dart';
|
export 'src/engine/save/game_session_snapshot.dart';
|
||||||
export 'src/engine/save/save_game_codec.dart';
|
export 'src/engine/save/save_game_codec.dart';
|
||||||
export 'src/engine/save/save_game_persistence.dart';
|
export 'src/engine/save/save_game_persistence.dart';
|
||||||
|
export 'src/engine/wolf_3d_engine.dart';
|
||||||
export 'src/engine/wolf_3d_engine_base.dart';
|
export 'src/engine/wolf_3d_engine_base.dart';
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/// Shared host-facing helpers for Wolf3D app shells.
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'src/host/cli_game_loop.dart' show CliGameLoop;
|
||||||
|
export 'src/host/managers/game_persistence_manager.dart'
|
||||||
|
show GamePersistenceManager;
|
||||||
|
export 'src/host/managers/game_renderer_mode_manager.dart'
|
||||||
|
show GameRendererMode, gameRendererModeFromSettings, handleGlslUnavailable;
|
||||||
@@ -1,219 +1,5 @@
|
|||||||
/// Shared menu helpers for Wolf3D hosts.
|
/// Shared menu helpers for Wolf3D hosts.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
export 'src/menu/wolf_menu_pic.dart';
|
||||||
|
export 'src/menu/wolf_menu_presentation.dart';
|
||||||
/// Known VGA picture indexes used by the original Wolf3D control-panel menus.
|
|
||||||
///
|
|
||||||
/// Values below are picture-table indexes (not raw chunk ids).
|
|
||||||
/// For example, `C_CONTROLPIC` is chunk 26 in `GFXV_WL6.H`, so its picture
|
|
||||||
/// index is `26 - STARTPICS(3) = 23`.
|
|
||||||
abstract class WolfMenuPic {
|
|
||||||
static const int hBj = 0; // H_BJPIC
|
|
||||||
static const int hTopWindow = 3; // H_TOPWINDOWPIC
|
|
||||||
static const int cOptions = 7; // C_OPTIONSPIC
|
|
||||||
static const int cCursor1 = 8; // C_CURSOR1PIC
|
|
||||||
static const int cCursor2 = 9; // C_CURSOR2PIC
|
|
||||||
static const int cNotSelected = 10; // C_NOTSELECTEDPIC
|
|
||||||
static const int cSelected = 11; // C_SELECTEDPIC
|
|
||||||
static const int cBabyMode = 16; // C_BABYMODEPIC
|
|
||||||
static const int cEasy = 17; // C_EASYPIC
|
|
||||||
static const int cNormal = 18; // C_NORMALPIC
|
|
||||||
static const int cHard = 19; // C_HARDPIC
|
|
||||||
static const int cControl = 23; // C_CONTROLPIC
|
|
||||||
static const int cCustomize = 24; // C_CUSTOMIZEPIC
|
|
||||||
static const int cEpisode1 = 27; // C_EPISODE1PIC
|
|
||||||
static const int cEpisode2 = 28; // C_EPISODE2PIC
|
|
||||||
static const int cEpisode3 = 29; // C_EPISODE3PIC
|
|
||||||
static const int cEpisode4 = 30; // C_EPISODE4PIC
|
|
||||||
static const int cEpisode5 = 31; // C_EPISODE5PIC
|
|
||||||
static const int cEpisode6 = 32; // C_EPISODE6PIC
|
|
||||||
static const int statusBar = 83; // STATUSBARPIC
|
|
||||||
static const int title = 84; // TITLEPIC
|
|
||||||
static const int pg13 = 85; // PG13PIC
|
|
||||||
static const int credits = 86; // CREDITSPIC
|
|
||||||
static const int highScores = 87; // HIGHSCORESPIC
|
|
||||||
|
|
||||||
static const List<int> episodePics = [
|
|
||||||
cEpisode1,
|
|
||||||
cEpisode2,
|
|
||||||
cEpisode3,
|
|
||||||
cEpisode4,
|
|
||||||
cEpisode5,
|
|
||||||
cEpisode6,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shared menu text colors resolved from the VGA palette.
|
|
||||||
///
|
|
||||||
/// Keep menu color choices centralized so renderers don't duplicate
|
|
||||||
/// hard-coded palette slots or RGB conversion logic.
|
|
||||||
abstract class WolfMenuPalette {
|
|
||||||
static const int selectedTextIndex = 19;
|
|
||||||
static const int unselectedTextIndex = 23;
|
|
||||||
static const int disabledTextIndex = 4;
|
|
||||||
static const int _headerTargetRgb = 0xFFF700;
|
|
||||||
|
|
||||||
static int? _cachedHeaderTextIndex;
|
|
||||||
|
|
||||||
static int get headerTextIndex =>
|
|
||||||
_cachedHeaderTextIndex ??= _nearestPaletteIndex(_headerTargetRgb);
|
|
||||||
|
|
||||||
static int get selectedTextColor => ColorPalette.vga32Bit[selectedTextIndex];
|
|
||||||
|
|
||||||
static int get unselectedTextColor =>
|
|
||||||
ColorPalette.vga32Bit[unselectedTextIndex];
|
|
||||||
|
|
||||||
static int get disabledTextColor => ColorPalette.vga32Bit[disabledTextIndex];
|
|
||||||
|
|
||||||
static int get headerTextColor => ColorPalette.vga32Bit[headerTextIndex];
|
|
||||||
|
|
||||||
static int _nearestPaletteIndex(int rgb) {
|
|
||||||
final int targetR = (rgb >> 16) & 0xFF;
|
|
||||||
final int targetG = (rgb >> 8) & 0xFF;
|
|
||||||
final int targetB = rgb & 0xFF;
|
|
||||||
|
|
||||||
int bestIndex = 0;
|
|
||||||
int bestDistance = 1 << 30;
|
|
||||||
|
|
||||||
for (int i = 0; i < 256; i++) {
|
|
||||||
final int color = ColorPalette.vga32Bit[i];
|
|
||||||
final int r = color & 0xFF;
|
|
||||||
final int g = (color >> 8) & 0xFF;
|
|
||||||
final int b = (color >> 16) & 0xFF;
|
|
||||||
|
|
||||||
final int dr = targetR - r;
|
|
||||||
final int dg = targetG - g;
|
|
||||||
final int db = targetB - b;
|
|
||||||
final int distance = (dr * dr) + (dg * dg) + (db * db);
|
|
||||||
if (distance < bestDistance) {
|
|
||||||
bestDistance = distance;
|
|
||||||
bestIndex = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bestIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Structured accessors for classic Wolf3D menu art.
|
|
||||||
class WolfClassicMenuArt {
|
|
||||||
final WolfensteinData data;
|
|
||||||
|
|
||||||
WolfClassicMenuArt(this.data);
|
|
||||||
|
|
||||||
VgaImage? get controlBackground {
|
|
||||||
return _imageForKey(MenuPicKey.controlBackground);
|
|
||||||
}
|
|
||||||
|
|
||||||
VgaImage? get title => _imageForKey(MenuPicKey.title);
|
|
||||||
|
|
||||||
VgaImage? get heading => _imageForKey(MenuPicKey.heading);
|
|
||||||
|
|
||||||
VgaImage? get selectedMarker => _imageForKey(MenuPicKey.markerSelected);
|
|
||||||
|
|
||||||
VgaImage? get unselectedMarker => _imageForKey(MenuPicKey.markerUnselected);
|
|
||||||
|
|
||||||
VgaImage? get optionsLabel => _imageForKey(MenuPicKey.optionsLabel);
|
|
||||||
|
|
||||||
VgaImage? get customizeLabel => _imageForKey(MenuPicKey.customizeLabel);
|
|
||||||
|
|
||||||
VgaImage? get credits => _imageForKey(MenuPicKey.credits);
|
|
||||||
|
|
||||||
VgaImage? episodeOption(int episodeIndex) {
|
|
||||||
if (episodeIndex < 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final key = data.registry.menu.episodeKey(episodeIndex);
|
|
||||||
return _imageForKey(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
VgaImage? difficultyOption(Difficulty difficulty) {
|
|
||||||
final key = data.registry.menu.difficultyKey(difficulty);
|
|
||||||
return _imageForKey(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Legacy numeric lookup API retained for existing renderer call sites.
|
|
||||||
///
|
|
||||||
/// Known legacy indices are mapped through symbolic registry keys first.
|
|
||||||
/// Unknown indices fall back to direct picture-table indexing.
|
|
||||||
VgaImage? mappedPic(int index) {
|
|
||||||
final key = _legacyKeyForIndex(index);
|
|
||||||
if (key != null) {
|
|
||||||
return _imageForKey(key);
|
|
||||||
}
|
|
||||||
return pic(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
VgaImage? pic(int index) {
|
|
||||||
if (index < 0 || index >= data.vgaImages.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final image = data.vgaImages[index];
|
|
||||||
|
|
||||||
if (image.width <= 0 || image.height <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return image;
|
|
||||||
}
|
|
||||||
|
|
||||||
VgaImage? _imageForKey(MenuPicKey key) {
|
|
||||||
final ref = data.registry.menu.resolve(key);
|
|
||||||
if (ref == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return pic(ref.pictureIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
MenuPicKey? _legacyKeyForIndex(int index) {
|
|
||||||
switch (index) {
|
|
||||||
case WolfMenuPic.hTopWindow:
|
|
||||||
return MenuPicKey.heading;
|
|
||||||
case WolfMenuPic.cOptions:
|
|
||||||
return MenuPicKey.optionsLabel;
|
|
||||||
case WolfMenuPic.cCursor1:
|
|
||||||
return MenuPicKey.cursorActive;
|
|
||||||
case WolfMenuPic.cCursor2:
|
|
||||||
return MenuPicKey.cursorInactive;
|
|
||||||
case WolfMenuPic.cNotSelected:
|
|
||||||
return MenuPicKey.markerUnselected;
|
|
||||||
case WolfMenuPic.cSelected:
|
|
||||||
return MenuPicKey.markerSelected;
|
|
||||||
case 15:
|
|
||||||
return MenuPicKey.footer;
|
|
||||||
case WolfMenuPic.cBabyMode:
|
|
||||||
return MenuPicKey.difficultyBaby;
|
|
||||||
case WolfMenuPic.cEasy:
|
|
||||||
return MenuPicKey.difficultyEasy;
|
|
||||||
case WolfMenuPic.cNormal:
|
|
||||||
return MenuPicKey.difficultyNormal;
|
|
||||||
case WolfMenuPic.cHard:
|
|
||||||
return MenuPicKey.difficultyHard;
|
|
||||||
case WolfMenuPic.cControl:
|
|
||||||
return MenuPicKey.controlBackground;
|
|
||||||
case WolfMenuPic.cCustomize:
|
|
||||||
return MenuPicKey.customizeLabel;
|
|
||||||
case WolfMenuPic.cEpisode1:
|
|
||||||
return MenuPicKey.episode1;
|
|
||||||
case WolfMenuPic.cEpisode2:
|
|
||||||
return MenuPicKey.episode2;
|
|
||||||
case WolfMenuPic.cEpisode3:
|
|
||||||
return MenuPicKey.episode3;
|
|
||||||
case WolfMenuPic.cEpisode4:
|
|
||||||
return MenuPicKey.episode4;
|
|
||||||
case WolfMenuPic.cEpisode5:
|
|
||||||
return MenuPicKey.episode5;
|
|
||||||
case WolfMenuPic.cEpisode6:
|
|
||||||
return MenuPicKey.episode6;
|
|
||||||
case WolfMenuPic.title:
|
|
||||||
return MenuPicKey.title;
|
|
||||||
case WolfMenuPic.pg13:
|
|
||||||
return MenuPicKey.pg13;
|
|
||||||
case WolfMenuPic.credits:
|
|
||||||
return MenuPicKey.credits;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ export 'src/rendering/ascii_renderer.dart'
|
|||||||
show AsciiRenderer, AsciiRendererMode, AsciiTheme, AsciiThemes, ColoredChar;
|
show AsciiRenderer, AsciiRendererMode, AsciiTheme, AsciiThemes, ColoredChar;
|
||||||
export 'src/rendering/cli_renderer_backend.dart';
|
export 'src/rendering/cli_renderer_backend.dart';
|
||||||
export 'src/rendering/renderer_backend.dart';
|
export 'src/rendering/renderer_backend.dart';
|
||||||
export 'src/rendering/sixel_renderer.dart';
|
export 'src/rendering/sixel_renderer_stub.dart'
|
||||||
|
if (dart.library.io) 'src/rendering/sixel_renderer.dart';
|
||||||
export 'src/rendering/software_renderer.dart';
|
export 'src/rendering/software_renderer.dart';
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
/// Entry point for the sound synthesis module of Wolf3D.
|
/// Entry point for the sound synthesis module of Wolf3D.
|
||||||
///
|
///
|
||||||
/// This library provides access to audio functionalities, primarily by exporting
|
/// This library provides access to audio synthesis and WAV encoding helpers.
|
||||||
/// the [WolfAudio] class.
|
|
||||||
library;
|
library;
|
||||||
|
|
||||||
export 'src/synth/wolf_3d_audio.dart' show WolfAudio;
|
export 'src/synth/imf_renderer.dart' show ImfRenderer;
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ environment:
|
|||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
arcane_helper_utils: ^1.4.7
|
arcane_helper_utils: ^1.4.7
|
||||||
audioplayers: ^6.6.0
|
|
||||||
crypto: ^3.0.7
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
lints: ^6.0.0
|
lints: ^6.0.0
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import 'package:test/test.dart';
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('DataVersion.fromChecksum', () {
|
||||||
|
test('resolves all known checksum constants', () {
|
||||||
|
for (final version in DataVersion.values.where(
|
||||||
|
(version) => version != DataVersion.unknown,
|
||||||
|
)) {
|
||||||
|
expect(DataVersion.fromChecksum(version.checksum), version);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns unknown for unrecognized checksum', () {
|
||||||
|
expect(
|
||||||
|
DataVersion.fromChecksum('ffffffffffffffffffffffffffffffff'),
|
||||||
|
DataVersion.unknown,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/data/md5_hash.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('md5HexLower', () {
|
||||||
|
test('matches canonical RFC vectors', () {
|
||||||
|
expect(md5HexLower(const <int>[]), 'd41d8cd98f00b204e9800998ecf8427e');
|
||||||
|
expect(
|
||||||
|
md5HexLower(utf8.encode('abc')),
|
||||||
|
'900150983cd24fb0d6963f7d28e17f72',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
md5HexLower(utf8.encode('The quick brown fox jumps over the lazy dog')),
|
||||||
|
'9e107d9d372bb6826bd81d3542a419d6',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
md5HexLower(
|
||||||
|
utf8.encode(
|
||||||
|
'12345678901234567890123456789012345678901234567890123456789012345678901234567890',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'57edf4a22be3c955ac49da2e2107b67a',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns lowercase 32-character hex output', () {
|
||||||
|
final digest = md5HexLower(utf8.encode('Wolf3D'));
|
||||||
|
expect(digest, hasLength(32));
|
||||||
|
expect(digest, matches(RegExp(r'^[0-9a-f]{32}$')));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_asset_registry.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
import 'package:wolf_3d_dart/wolf_3d_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_entities.dart';
|
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_input.dart';
|
import 'package:wolf_3d_dart/wolf_3d_input.dart';
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test(
|
test(
|
||||||
@@ -122,6 +124,104 @@ void main() {
|
|||||||
expect(engine.entities.last, isA<SmallAmmoCollectible>());
|
expect(engine.entities.last, isA<SmallAmmoCollectible>());
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test('restoreSaveState applies menu theme from restored active game', () {
|
||||||
|
final engine = _buildEngineWithTwoGames();
|
||||||
|
engine.init();
|
||||||
|
|
||||||
|
final GameSessionSnapshot snapshot = engine.captureSaveState();
|
||||||
|
final GameSessionSnapshot restoredSnapshot = GameSessionSnapshot(
|
||||||
|
currentGameIndex: 1,
|
||||||
|
currentEpisodeIndex: snapshot.currentEpisodeIndex,
|
||||||
|
currentLevelIndex: snapshot.currentLevelIndex,
|
||||||
|
returnLevelIndex: snapshot.returnLevelIndex,
|
||||||
|
difficulty: snapshot.difficulty,
|
||||||
|
timeAliveMs: snapshot.timeAliveMs,
|
||||||
|
lastAcousticAlertTime: snapshot.lastAcousticAlertTime,
|
||||||
|
isMapOverlayVisible: snapshot.isMapOverlayVisible,
|
||||||
|
isMenuOverlayVisible: snapshot.isMenuOverlayVisible,
|
||||||
|
player: snapshot.player,
|
||||||
|
currentLevel: snapshot.currentLevel,
|
||||||
|
areaGrid: snapshot.areaGrid,
|
||||||
|
areasByPlayer: snapshot.areasByPlayer,
|
||||||
|
entities: snapshot.entities,
|
||||||
|
doors: snapshot.doors,
|
||||||
|
pushwalls: snapshot.pushwalls,
|
||||||
|
);
|
||||||
|
|
||||||
|
engine.restoreSaveState(restoredSnapshot);
|
||||||
|
|
||||||
|
final presentation = WolfMenuPresentation(engine.data);
|
||||||
|
final bool isSpear =
|
||||||
|
engine.data.version == GameVersion.spearOfDestiny ||
|
||||||
|
engine.data.version == GameVersion.spearOfDestinyDemo;
|
||||||
|
|
||||||
|
final int expectedBackground = isSpear
|
||||||
|
? _rgb24FromVgaIndex(
|
||||||
|
_resolvedMenuColorIndex(
|
||||||
|
presentation.backgroundIndex,
|
||||||
|
engine.data.version,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: _paletteMappedRgb24(0x890000);
|
||||||
|
|
||||||
|
final int expectedPanel = isSpear
|
||||||
|
? 0x000359
|
||||||
|
: _paletteMappedRgb24(0x590002);
|
||||||
|
expect(engine.currentGameIndex, 1);
|
||||||
|
expect(engine.menuBackgroundRgb, expectedBackground);
|
||||||
|
expect(engine.menuPanelRgb, expectedPanel);
|
||||||
|
expect(engine.menuManager.menuBackgroundRgb, expectedBackground);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
int _rgb24FromVgaIndex(int paletteIndex) {
|
||||||
|
final int argb = ColorPalette.argbFromVgaIndex(paletteIndex);
|
||||||
|
final int r = (argb >> 16) & 0xFF;
|
||||||
|
final int g = (argb >> 8) & 0xFF;
|
||||||
|
final int b = argb & 0xFF;
|
||||||
|
return (r << 16) | (g << 8) | b;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _resolvedMenuColorIndex(int paletteIndex, GameVersion version) {
|
||||||
|
final bool isSpear =
|
||||||
|
version == GameVersion.spearOfDestiny ||
|
||||||
|
version == GameVersion.spearOfDestinyDemo;
|
||||||
|
if (!isSpear && paletteIndex >= 0x20 && paletteIndex <= 0x2F) {
|
||||||
|
return paletteIndex + 0x70;
|
||||||
|
}
|
||||||
|
return paletteIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _paletteMappedRgb24(int rgb) {
|
||||||
|
final int index = _closestVgaIndexForRgb24(rgb);
|
||||||
|
return _rgb24FromVgaIndex(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _closestVgaIndexForRgb24(int rgb24) {
|
||||||
|
final int targetR = (rgb24 >> 16) & 0xFF;
|
||||||
|
final int targetG = (rgb24 >> 8) & 0xFF;
|
||||||
|
final int targetB = rgb24 & 0xFF;
|
||||||
|
|
||||||
|
int bestIndex = 0;
|
||||||
|
int bestDistance = 0x7FFFFFFF;
|
||||||
|
|
||||||
|
for (int index = 0; index < 256; index++) {
|
||||||
|
final int argb = ColorPalette.argbFromVgaIndex(index);
|
||||||
|
final int r = (argb >> 16) & 0xFF;
|
||||||
|
final int g = (argb >> 8) & 0xFF;
|
||||||
|
final int b = argb & 0xFF;
|
||||||
|
final int dr = targetR - r;
|
||||||
|
final int dg = targetG - g;
|
||||||
|
final int db = targetB - b;
|
||||||
|
final int distance = (dr * dr) + (dg * dg) + (db * db);
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
bestDistance = distance;
|
||||||
|
bestIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TestInput extends Wolf3dInput {
|
class _TestInput extends Wolf3dInput {
|
||||||
@@ -162,6 +262,35 @@ class _SilentAudio implements EngineAudio {
|
|||||||
}
|
}
|
||||||
|
|
||||||
WolfEngine _buildEngine() {
|
WolfEngine _buildEngine() {
|
||||||
|
final data = _buildTestData(version: GameVersion.retail);
|
||||||
|
|
||||||
|
return WolfEngine(
|
||||||
|
data: data,
|
||||||
|
difficulty: Difficulty.medium,
|
||||||
|
startingEpisode: 0,
|
||||||
|
frameBuffer: FrameBuffer(64, 64),
|
||||||
|
input: _TestInput(),
|
||||||
|
onGameWon: () {},
|
||||||
|
engineAudio: _SilentAudio(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
WolfEngine _buildEngineWithTwoGames() {
|
||||||
|
final retail = _buildTestData(version: GameVersion.retail);
|
||||||
|
final spear = _buildTestData(version: GameVersion.spearOfDestiny);
|
||||||
|
|
||||||
|
return WolfEngine(
|
||||||
|
availableGames: <WolfensteinData>[retail, spear],
|
||||||
|
difficulty: Difficulty.medium,
|
||||||
|
startingEpisode: 0,
|
||||||
|
frameBuffer: FrameBuffer(64, 64),
|
||||||
|
input: _TestInput(),
|
||||||
|
onGameWon: () {},
|
||||||
|
engineAudio: _SilentAudio(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
WolfensteinData _buildTestData({required GameVersion version}) {
|
||||||
final wallGrid = _buildGrid();
|
final wallGrid = _buildGrid();
|
||||||
final objectGrid = _buildGrid();
|
final objectGrid = _buildGrid();
|
||||||
_fillBoundaries(wallGrid, 2);
|
_fillBoundaries(wallGrid, 2);
|
||||||
@@ -171,11 +300,13 @@ WolfEngine _buildEngine() {
|
|||||||
wallGrid[2][3] = 90;
|
wallGrid[2][3] = 90;
|
||||||
wallGrid[4][4] = 5;
|
wallGrid[4][4] = 5;
|
||||||
|
|
||||||
return WolfEngine(
|
return WolfensteinData(
|
||||||
data: WolfensteinData(
|
version: version,
|
||||||
version: GameVersion.retail,
|
|
||||||
dataVersion: DataVersion.unknown,
|
dataVersion: DataVersion.unknown,
|
||||||
registry: RetailAssetRegistry(),
|
registry: switch (version) {
|
||||||
|
GameVersion.spearOfDestiny => SpearAssetRegistry(),
|
||||||
|
_ => RetailAssetRegistry(),
|
||||||
|
},
|
||||||
walls: [
|
walls: [
|
||||||
_solidSprite(1),
|
_solidSprite(1),
|
||||||
_solidSprite(1),
|
_solidSprite(1),
|
||||||
@@ -201,13 +332,6 @@ WolfEngine _buildEngine() {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
difficulty: Difficulty.medium,
|
|
||||||
startingEpisode: 0,
|
|
||||||
frameBuffer: FrameBuffer(64, 64),
|
|
||||||
input: _TestInput(),
|
|
||||||
onGameWon: () {},
|
|
||||||
engineAudio: _SilentAudio(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -402,7 +402,130 @@ void main() {
|
|||||||
expect(quitCalls, 0);
|
expect(quitCalls, 0);
|
||||||
expect(exitCalls, 1);
|
expect(exitCalls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('load availability is scoped to active game version', () async {
|
||||||
|
final persistence = _InMemorySaveGamePersistence(
|
||||||
|
saves: {
|
||||||
|
_InMemorySaveGamePersistence.key(
|
||||||
|
slot: 0,
|
||||||
|
version: GameVersion.shareware,
|
||||||
|
): Uint8List.fromList(const <int>[
|
||||||
|
1,
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final retailEngine = WolfEngine(
|
||||||
|
data: _buildTestData(gameVersion: GameVersion.retail),
|
||||||
|
difficulty: null,
|
||||||
|
startingEpisode: 0,
|
||||||
|
frameBuffer: FrameBuffer(64, 64),
|
||||||
|
input: _TestInput(),
|
||||||
|
engineAudio: _SilentAudio(),
|
||||||
|
saveGamePersistence: persistence,
|
||||||
|
onGameWon: () {},
|
||||||
|
);
|
||||||
|
retailEngine.init();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
final sharewareEngine = WolfEngine(
|
||||||
|
data: _buildTestData(gameVersion: GameVersion.shareware),
|
||||||
|
difficulty: null,
|
||||||
|
startingEpisode: 0,
|
||||||
|
frameBuffer: FrameBuffer(64, 64),
|
||||||
|
input: _TestInput(),
|
||||||
|
engineAudio: _SilentAudio(),
|
||||||
|
saveGamePersistence: persistence,
|
||||||
|
onGameWon: () {},
|
||||||
|
);
|
||||||
|
sharewareEngine.init();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(retailEngine.hasLoadableSave, isFalse);
|
||||||
|
expect(sharewareEngine.hasLoadableSave, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'loadFromSlot prefers save metadata over stale snapshot game index',
|
||||||
|
() async {
|
||||||
|
final input = _TestInput();
|
||||||
|
final persistence = _InMemorySaveGamePersistence();
|
||||||
|
final engine = _buildMultiGameEngine(
|
||||||
|
input: input,
|
||||||
|
difficulty: Difficulty.medium,
|
||||||
|
saveGamePersistence: persistence,
|
||||||
|
);
|
||||||
|
|
||||||
|
engine.init();
|
||||||
|
|
||||||
|
final GameSessionSnapshot retailSnapshot = engine.captureSaveState();
|
||||||
|
final GameSessionSnapshot staleIndexSnapshot =
|
||||||
|
_copySnapshotWithGameIndex(
|
||||||
|
retailSnapshot,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
final SaveGameFile mismatchedFile = SaveGameFile(
|
||||||
|
slot: 0,
|
||||||
|
gameVersion: GameVersion.retail,
|
||||||
|
dataVersionName: DataVersion.unknown.name,
|
||||||
|
description: 'Retail save with stale snapshot index',
|
||||||
|
createdAtMs: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
snapshot: staleIndexSnapshot,
|
||||||
|
checksum: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
await persistence.save(
|
||||||
|
slot: 0,
|
||||||
|
version: GameVersion.retail,
|
||||||
|
bytes: engine.saveGameCodec.encode(mismatchedFile),
|
||||||
|
);
|
||||||
|
|
||||||
|
final bool loaded = await engine.loadFromSlot(0);
|
||||||
|
expect(loaded, isTrue);
|
||||||
|
expect(engine.data.version, GameVersion.retail);
|
||||||
|
expect(engine.currentGameIndex, 0);
|
||||||
|
|
||||||
|
final bool saved = await engine.saveToSlot(
|
||||||
|
1,
|
||||||
|
description: 'After load',
|
||||||
|
);
|
||||||
|
expect(saved, isTrue);
|
||||||
|
expect(
|
||||||
|
await persistence.exists(slot: 1, version: GameVersion.retail),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await persistence.exists(slot: 1, version: GameVersion.shareware),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
GameSessionSnapshot _copySnapshotWithGameIndex(
|
||||||
|
GameSessionSnapshot snapshot,
|
||||||
|
int gameIndex,
|
||||||
|
) {
|
||||||
|
return GameSessionSnapshot(
|
||||||
|
currentGameIndex: gameIndex,
|
||||||
|
currentEpisodeIndex: snapshot.currentEpisodeIndex,
|
||||||
|
currentLevelIndex: snapshot.currentLevelIndex,
|
||||||
|
returnLevelIndex: snapshot.returnLevelIndex,
|
||||||
|
difficulty: snapshot.difficulty,
|
||||||
|
timeAliveMs: snapshot.timeAliveMs,
|
||||||
|
lastAcousticAlertTime: snapshot.lastAcousticAlertTime,
|
||||||
|
isMapOverlayVisible: snapshot.isMapOverlayVisible,
|
||||||
|
isMenuOverlayVisible: snapshot.isMenuOverlayVisible,
|
||||||
|
player: snapshot.player,
|
||||||
|
currentLevel: snapshot.currentLevel,
|
||||||
|
areaGrid: snapshot.areaGrid,
|
||||||
|
areasByPlayer: snapshot.areasByPlayer,
|
||||||
|
entities: snapshot.entities,
|
||||||
|
doors: snapshot.doors,
|
||||||
|
pushwalls: snapshot.pushwalls,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
WolfEngine _buildMultiGameEngine({
|
WolfEngine _buildMultiGameEngine({
|
||||||
@@ -410,6 +533,7 @@ WolfEngine _buildMultiGameEngine({
|
|||||||
required Difficulty? difficulty,
|
required Difficulty? difficulty,
|
||||||
void Function()? onMenuExit,
|
void Function()? onMenuExit,
|
||||||
void Function()? onQuit,
|
void Function()? onQuit,
|
||||||
|
SaveGamePersistence? saveGamePersistence,
|
||||||
}) {
|
}) {
|
||||||
final WolfensteinData retail = _buildTestData(
|
final WolfensteinData retail = _buildTestData(
|
||||||
gameVersion: GameVersion.retail,
|
gameVersion: GameVersion.retail,
|
||||||
@@ -425,6 +549,7 @@ WolfEngine _buildMultiGameEngine({
|
|||||||
frameBuffer: FrameBuffer(64, 64),
|
frameBuffer: FrameBuffer(64, 64),
|
||||||
input: input,
|
input: input,
|
||||||
engineAudio: _SilentAudio(),
|
engineAudio: _SilentAudio(),
|
||||||
|
saveGamePersistence: saveGamePersistence,
|
||||||
onGameWon: () {},
|
onGameWon: () {},
|
||||||
onMenuExit: onMenuExit,
|
onMenuExit: onMenuExit,
|
||||||
onQuit: onQuit,
|
onQuit: onQuit,
|
||||||
@@ -585,10 +710,43 @@ class _SilentAudio implements EngineAudio {
|
|||||||
void dispose() {}
|
void dispose() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _InMemorySaveGamePersistence implements SaveGamePersistence {
|
||||||
|
_InMemorySaveGamePersistence({Map<String, Uint8List>? saves})
|
||||||
|
: _saves = saves ?? <String, Uint8List>{};
|
||||||
|
|
||||||
|
final Map<String, Uint8List> _saves;
|
||||||
|
|
||||||
|
static String key({required int slot, required GameVersion version}) =>
|
||||||
|
'${version.name}:$slot';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Uint8List?> load({
|
||||||
|
required int slot,
|
||||||
|
required GameVersion version,
|
||||||
|
}) async {
|
||||||
|
return _saves[key(slot: slot, version: version)];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> exists({required int slot, required GameVersion version}) async {
|
||||||
|
final Uint8List? bytes = _saves[key(slot: slot, version: version)];
|
||||||
|
return bytes != null && bytes.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> save({
|
||||||
|
required int slot,
|
||||||
|
required GameVersion version,
|
||||||
|
required Uint8List bytes,
|
||||||
|
}) async {
|
||||||
|
_saves[key(slot: slot, version: version)] = Uint8List.fromList(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _dismissIntroSplash(WolfEngine engine, _TestInput input) {
|
void _dismissIntroSplash(WolfEngine engine, _TestInput input) {
|
||||||
int safety = 0;
|
int safety = 0;
|
||||||
while (engine.menuManager.activeMenu == WolfMenuScreen.introSplash &&
|
while (engine.menuManager.activeMenu == WolfMenuScreen.introSplash &&
|
||||||
safety < 160) {
|
safety < 600) {
|
||||||
input.isInteracting = safety.isEven;
|
input.isInteracting = safety.isEven;
|
||||||
engine.tick(const Duration(milliseconds: 16));
|
engine.tick(const Duration(milliseconds: 16));
|
||||||
safety++;
|
safety++;
|
||||||
|
|||||||
@@ -109,10 +109,10 @@ void main() {
|
|||||||
expect(decoded.createdAtMs, 999);
|
expect(decoded.createdAtMs, 999);
|
||||||
expect(decoded.gameVersion, engine.data.version);
|
expect(decoded.gameVersion, engine.data.version);
|
||||||
expect(
|
expect(
|
||||||
decoded.snapshot.activeEpisodeIndex,
|
decoded.snapshot.currentEpisodeIndex,
|
||||||
file.snapshot.activeEpisodeIndex,
|
file.snapshot.currentEpisodeIndex,
|
||||||
);
|
);
|
||||||
expect(decoded.snapshot.activeLevelIndex, file.snapshot.activeLevelIndex);
|
expect(decoded.snapshot.currentLevelIndex, file.snapshot.currentLevelIndex);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('OriginalLayoutEnvelopeSaveGameCodec rejects invalid checksum', () {
|
test('OriginalLayoutEnvelopeSaveGameCodec rejects invalid checksum', () {
|
||||||
@@ -140,6 +140,151 @@ void main() {
|
|||||||
throwsA(isA<FormatException>()),
|
throwsA(isA<FormatException>()),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('CompatibleSaveGameCodec decodes legacy W3DS saves', () {
|
||||||
|
final WolfEngine engine = _buildEngine();
|
||||||
|
engine.init();
|
||||||
|
|
||||||
|
final SaveGameCodec legacyCodec = SaveGameCodec();
|
||||||
|
final SaveGameFile file = SaveGameFile(
|
||||||
|
slot: 1,
|
||||||
|
gameVersion: engine.data.version,
|
||||||
|
dataVersionName: engine.data.dataVersion.name,
|
||||||
|
description: 'Legacy Save',
|
||||||
|
createdAtMs: 777,
|
||||||
|
snapshot: engine.captureSaveState(),
|
||||||
|
checksum: 0,
|
||||||
|
);
|
||||||
|
final Uint8List legacyBytes = legacyCodec.encode(file);
|
||||||
|
|
||||||
|
final CompatibleSaveGameCodec compatibleCodec = CompatibleSaveGameCodec();
|
||||||
|
final SaveGameFile decoded = compatibleCodec.decode(legacyBytes);
|
||||||
|
|
||||||
|
expect(decoded.slot, anyOf(0, 1));
|
||||||
|
expect(decoded.description, 'Legacy Save');
|
||||||
|
expect(decoded.createdAtMs, anyOf(0, 777));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CompatibleSaveGameCodec round-trips with block payload format', () {
|
||||||
|
final WolfEngine engine = _buildEngine();
|
||||||
|
engine.init();
|
||||||
|
|
||||||
|
final CompatibleSaveGameCodec codec = CompatibleSaveGameCodec();
|
||||||
|
final SaveGameFile file = SaveGameFile(
|
||||||
|
slot: 2,
|
||||||
|
gameVersion: engine.data.version,
|
||||||
|
dataVersionName: engine.data.dataVersion.name,
|
||||||
|
description: 'Compatible Block Save',
|
||||||
|
createdAtMs: 1234,
|
||||||
|
snapshot: engine.captureSaveState(),
|
||||||
|
checksum: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
final Uint8List encoded = codec.encode(file);
|
||||||
|
final SaveGameFile decoded = codec.decode(encoded);
|
||||||
|
|
||||||
|
expect(decoded.slot, 2);
|
||||||
|
expect(decoded.description, 'Compatible Block Save');
|
||||||
|
expect(decoded.createdAtMs, 1234);
|
||||||
|
expect(decoded.dataVersionName, file.dataVersionName);
|
||||||
|
expect(
|
||||||
|
decoded.snapshot.currentEpisodeIndex,
|
||||||
|
file.snapshot.currentEpisodeIndex,
|
||||||
|
);
|
||||||
|
expect(decoded.snapshot.currentLevelIndex, file.snapshot.currentLevelIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CompatibleSaveGameCodec preserves entity state fidelity', () {
|
||||||
|
final WolfEngine engine = _buildEngine();
|
||||||
|
engine.init();
|
||||||
|
|
||||||
|
final Guard guard =
|
||||||
|
EntityRegistry.spawn(
|
||||||
|
MapObject.guardStart,
|
||||||
|
8.5,
|
||||||
|
7.5,
|
||||||
|
Difficulty.medium,
|
||||||
|
engine.data.sprites.length,
|
||||||
|
registry: engine.data.registry,
|
||||||
|
)!
|
||||||
|
as Guard
|
||||||
|
..health = 5
|
||||||
|
..state = EntityState.dead;
|
||||||
|
engine.entities = <Entity>[guard];
|
||||||
|
|
||||||
|
final CompatibleSaveGameCodec codec = CompatibleSaveGameCodec();
|
||||||
|
final SaveGameFile decoded = codec.decode(
|
||||||
|
codec.encode(
|
||||||
|
SaveGameFile(
|
||||||
|
slot: 0,
|
||||||
|
gameVersion: engine.data.version,
|
||||||
|
dataVersionName: engine.data.dataVersion.name,
|
||||||
|
description: 'Entity Fidelity',
|
||||||
|
createdAtMs: 1,
|
||||||
|
snapshot: engine.captureSaveState(),
|
||||||
|
checksum: 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(decoded.snapshot.entities, hasLength(1));
|
||||||
|
expect(decoded.snapshot.entities.first.kind, 'Guard');
|
||||||
|
expect(decoded.snapshot.entities.first.state, EntityState.dead);
|
||||||
|
expect(decoded.snapshot.entities.first.extraData['health'], 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CompatibleSaveGameCodec decodes old envelope payload format', () {
|
||||||
|
final WolfEngine engine = _buildEngine();
|
||||||
|
engine.init();
|
||||||
|
|
||||||
|
final OriginalLayoutEnvelopeSaveGameCodec oldEnvelopeCodec =
|
||||||
|
OriginalLayoutEnvelopeSaveGameCodec();
|
||||||
|
final SaveGameFile file = SaveGameFile(
|
||||||
|
slot: 4,
|
||||||
|
gameVersion: engine.data.version,
|
||||||
|
dataVersionName: engine.data.dataVersion.name,
|
||||||
|
description: 'Old Envelope Save',
|
||||||
|
createdAtMs: 333,
|
||||||
|
snapshot: engine.captureSaveState(),
|
||||||
|
checksum: 0,
|
||||||
|
);
|
||||||
|
final Uint8List oldEncoded = oldEnvelopeCodec.encode(file);
|
||||||
|
|
||||||
|
final CompatibleSaveGameCodec compatibleCodec = CompatibleSaveGameCodec();
|
||||||
|
final SaveGameFile decoded = compatibleCodec.decode(oldEncoded);
|
||||||
|
|
||||||
|
expect(decoded.slot, anyOf(0, 4));
|
||||||
|
expect(decoded.description, 'Old Envelope Save');
|
||||||
|
expect(decoded.createdAtMs, anyOf(0, 333));
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'CompatibleSaveGameCodec writes DOS-style description-prefixed files',
|
||||||
|
() {
|
||||||
|
final WolfEngine engine = _buildEngine();
|
||||||
|
engine.init();
|
||||||
|
|
||||||
|
final CompatibleSaveGameCodec codec = CompatibleSaveGameCodec();
|
||||||
|
final SaveGameFile file = SaveGameFile(
|
||||||
|
slot: 0,
|
||||||
|
gameVersion: engine.data.version,
|
||||||
|
dataVersionName: engine.data.dataVersion.name,
|
||||||
|
description: 'DOS Layout Save',
|
||||||
|
createdAtMs: 222,
|
||||||
|
snapshot: engine.captureSaveState(),
|
||||||
|
checksum: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
final Uint8List encoded = codec.encode(file);
|
||||||
|
|
||||||
|
expect(encoded.length, greaterThan(32));
|
||||||
|
expect(String.fromCharCodes(encoded.sublist(0, 3)), 'DOS');
|
||||||
|
expect(
|
||||||
|
encoded[32],
|
||||||
|
isNot(equals(0x57)),
|
||||||
|
); // not WLFS signature at offset 32
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TestInput extends Wolf3dInput {
|
class _TestInput extends Wolf3dInput {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import 'package:test/test.dart';
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_input.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('CliInput shortcuts', () {
|
||||||
|
test('matches default FPS toggle shortcut with backtick', () {
|
||||||
|
final input = CliInput();
|
||||||
|
|
||||||
|
expect(input.matchesFpsToggleShortcut(const [96]), isTrue);
|
||||||
|
expect(input.matchesFpsToggleShortcut(const [126]), isFalse);
|
||||||
|
expect(input.matchesFpsToggleShortcut(const [114]), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matches renderer shortcut case-insensitively', () {
|
||||||
|
final input = CliInput();
|
||||||
|
|
||||||
|
expect(input.matchesRendererToggleShortcut(const [114]), isTrue);
|
||||||
|
expect(input.matchesRendererToggleShortcut(const [82]), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import 'package:test/test.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/menu/menu_manager.dart';
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('MenuManager', () {
|
||||||
|
test('main menu row enablement reflects resumable and loadable state', () {
|
||||||
|
final manager = MenuManager();
|
||||||
|
|
||||||
|
manager.showMainMenu(hasResumableGame: false, hasLoadableSave: false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
_entryFor(manager, WolfMenuMainAction.loadGame).isEnabled,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
_entryFor(manager, WolfMenuMainAction.saveGame).isEnabled,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
_entryFor(manager, WolfMenuMainAction.changeView).isEnabled,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.setLoadGameAvailable(true);
|
||||||
|
|
||||||
|
expect(_entryFor(manager, WolfMenuMainAction.loadGame).isEnabled, isTrue);
|
||||||
|
expect(
|
||||||
|
_entryFor(manager, WolfMenuMainAction.saveGame).isEnabled,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.showMainMenu(hasResumableGame: true, hasLoadableSave: true);
|
||||||
|
|
||||||
|
expect(_entryFor(manager, WolfMenuMainAction.saveGame).isEnabled, isTrue);
|
||||||
|
expect(_entryFor(manager, WolfMenuMainAction.endGame).isEnabled, isTrue);
|
||||||
|
expect(
|
||||||
|
manager.mainMenuEntries.any(
|
||||||
|
(entry) => entry.action == WolfMenuMainAction.backToGame,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('change-view navigation skips disabled renderer rows and options', () {
|
||||||
|
final manager = MenuManager();
|
||||||
|
|
||||||
|
manager.setChangeViewEntries(const <WolfMenuRendererEntry>[
|
||||||
|
WolfMenuRendererEntry(
|
||||||
|
mode: WolfRendererMode.software,
|
||||||
|
label: 'SOFTWARE',
|
||||||
|
hasOptions: false,
|
||||||
|
isEnabled: false,
|
||||||
|
),
|
||||||
|
WolfMenuRendererEntry(
|
||||||
|
mode: WolfRendererMode.ascii,
|
||||||
|
label: 'ASCII',
|
||||||
|
hasOptions: true,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
manager.setRendererOptionEntries(
|
||||||
|
title: 'ASCII OPTIONS',
|
||||||
|
entries: const <WolfMenuRendererOptionEntry>[
|
||||||
|
WolfMenuRendererOptionEntry(
|
||||||
|
id: WolfRendererOptionId.asciiTheme,
|
||||||
|
label: 'THEME',
|
||||||
|
isEnabled: false,
|
||||||
|
),
|
||||||
|
WolfMenuRendererOptionEntry(
|
||||||
|
id: WolfRendererOptionId.fpsCounter,
|
||||||
|
label: 'FPS',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.showChangeViewMenu();
|
||||||
|
|
||||||
|
expect(manager.selectedChangeViewIndex, 1);
|
||||||
|
|
||||||
|
manager.updateChangeViewMenu(const EngineInput(isMovingBackward: true));
|
||||||
|
manager.updateChangeViewMenu(const EngineInput());
|
||||||
|
|
||||||
|
expect(manager.selectedChangeViewIndex, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'renderer option selection is preserved by option id when entries refresh',
|
||||||
|
() {
|
||||||
|
final manager = MenuManager();
|
||||||
|
|
||||||
|
manager.setChangeViewEntries(const <WolfMenuRendererEntry>[
|
||||||
|
WolfMenuRendererEntry(
|
||||||
|
mode: WolfRendererMode.ascii,
|
||||||
|
label: 'ASCII',
|
||||||
|
hasOptions: true,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
manager.setRendererOptionEntries(
|
||||||
|
title: 'ASCII OPTIONS',
|
||||||
|
entries: const <WolfMenuRendererOptionEntry>[
|
||||||
|
WolfMenuRendererOptionEntry(
|
||||||
|
id: WolfRendererOptionId.asciiTheme,
|
||||||
|
label: 'THEME',
|
||||||
|
),
|
||||||
|
WolfMenuRendererOptionEntry(
|
||||||
|
id: WolfRendererOptionId.fpsCounter,
|
||||||
|
label: 'FPS',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.showRendererOptionsMenu();
|
||||||
|
manager.updateRendererOptionsMenu(
|
||||||
|
const EngineInput(isMovingBackward: true),
|
||||||
|
);
|
||||||
|
manager.updateRendererOptionsMenu(const EngineInput());
|
||||||
|
|
||||||
|
expect(manager.selectedRendererOptionIndex, 1);
|
||||||
|
|
||||||
|
manager.setRendererOptionEntries(
|
||||||
|
title: 'ASCII OPTIONS',
|
||||||
|
entries: const <WolfMenuRendererOptionEntry>[
|
||||||
|
WolfMenuRendererOptionEntry(
|
||||||
|
id: WolfRendererOptionId.fpsCounter,
|
||||||
|
label: 'FPS',
|
||||||
|
),
|
||||||
|
WolfMenuRendererOptionEntry(
|
||||||
|
id: WolfRendererOptionId.asciiTheme,
|
||||||
|
label: 'THEME',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(manager.selectedRendererOptionIndex, 0);
|
||||||
|
expect(
|
||||||
|
manager.rendererOptionEntries[manager.selectedRendererOptionIndex].id,
|
||||||
|
WolfRendererOptionId.fpsCounter,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('transition locks navigation and reports none when idle', () {
|
||||||
|
final manager = MenuManager();
|
||||||
|
|
||||||
|
manager.showMainMenu(hasResumableGame: false);
|
||||||
|
manager.startTransition(WolfMenuScreen.difficultySelect);
|
||||||
|
|
||||||
|
final result = manager.updateMainMenu(
|
||||||
|
const EngineInput(isMovingBackward: true, isInteracting: true),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.selected, isNull);
|
||||||
|
expect(result.goBack, isFalse);
|
||||||
|
expect(manager.transitionEffect, WolfTransitionEffect.normalFade);
|
||||||
|
|
||||||
|
manager.tickTransition(MenuManager.transitionDurationMs);
|
||||||
|
|
||||||
|
expect(manager.isTransitioning, isFalse);
|
||||||
|
expect(manager.transitionEffect, WolfTransitionEffect.none);
|
||||||
|
expect(manager.activeMenu, WolfMenuScreen.difficultySelect);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('spear variant main menu omits READ THIS row', () {
|
||||||
|
final manager = MenuManager();
|
||||||
|
|
||||||
|
manager.beginSelectionFlow(
|
||||||
|
gameCount: 1,
|
||||||
|
initialGameIsSpear: true,
|
||||||
|
);
|
||||||
|
manager.showMainMenu(hasResumableGame: false, hasLoadableSave: true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
manager.mainMenuEntries.any(
|
||||||
|
(entry) => entry.action == WolfMenuMainAction.readThis,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
WolfMenuMainEntry _entryFor(MenuManager manager, WolfMenuMainAction action) {
|
||||||
|
return manager.mainMenuEntries.firstWhere((entry) => entry.action == action);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import 'package:test/test.dart';
|
||||||
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/classic_menu_presentation_module.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('classic menu palette matches canonical WL_MENU.H constants', () {
|
||||||
|
const module = ClassicMenuPresentationModule();
|
||||||
|
|
||||||
|
expect(module.backgroundIndex, 0x2D); // BKGDCOLOR
|
||||||
|
expect(module.panelIndex, 0x23); // BORD2COLOR
|
||||||
|
expect(module.borderIndex, 0x29); // BORDCOLOR
|
||||||
|
expect(module.disabledTextIndex, 0x2B); // DEACTIVE
|
||||||
|
|
||||||
|
expect(module.unselectedTextIndex, 0x17); // TEXTCOLOR
|
||||||
|
expect(module.selectedTextIndex, 0x13); // HIGHLIGHT
|
||||||
|
expect(module.headerTextIndex, 0x47); // READHCOLOR
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
import 'package:wolf_3d_dart/src/registry/built_in/shareware_hud_module.dart';
|
import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/shareware_hud_module.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user