Play (wrong) door sound effect when the door opens or closes

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-15 21:13:41 +01:00
parent b3b909a9b6
commit b0852543b0
18 changed files with 268 additions and 485 deletions

View File

@@ -30,6 +30,7 @@ class _DifficultyScreenState extends State<DifficultyScreen> {
Wolf3d.I.activeGame, Wolf3d.I.activeGame,
difficulty: difficulty, difficulty: difficulty,
startingEpisode: Wolf3d.I.activeEpisode, startingEpisode: Wolf3d.I.activeEpisode,
audio: Wolf3d.I.audio,
), ),
), ),
); );

View File

@@ -1,11 +0,0 @@
import 'package:wolf_3d_entities/wolf_3d_entities.dart';
class EngineInput {
bool isMovingForward = false;
bool isMovingBackward = false;
bool isTurningLeft = false;
bool isTurningRight = false;
bool isInteracting = false;
bool isFiring = false;
WeaponType? requestedWeapon;
}

View File

@@ -3,6 +3,5 @@
/// More dartdocs go here. /// More dartdocs go here.
library; library;
export 'src/io/engine_input.dart';
export 'src/wl_parser.dart' show WLParser; export 'src/wl_parser.dart' show WLParser;
export 'src/wolfenstein_loader.dart' show WolfensteinLoader; export 'src/wolfenstein_loader.dart' show WolfensteinLoader;

View File

@@ -46,3 +46,63 @@ class ImfMusic {
} }
typedef WolfMusicMap = List<int>; typedef WolfMusicMap = List<int>;
/// Maps to the original sound indices from the Wolfenstein 3D source code.
/// Use these to index into `activeGame.sounds[id]`.
abstract class WolfSound {
// --- Doors & Environment ---
static const int openDoor = 18;
static const int closeDoor = 19;
static const int pushWall = 46; // Secret sliding walls
// --- Weapons & Combat ---
static const int knifeAttack = 23;
static const int pistolFire = 24;
static const int machineGunFire = 26;
static const int gatlingFire = 32; // Historically SHOOTSND in the source
static const int naziFire = 58; // Enemy gunshots
// --- Pickups & Items ---
static const int getMachineGun = 30;
static const int getAmmo = 31;
static const int getGatling = 38;
static const int healthSmall = 33; // Dog food / Meals
static const int healthLarge = 34; // First Aid
static const int treasure1 = 35; // Cross
static const int treasure2 = 36; // Chalice
static const int treasure3 = 37; // Chest
static const int treasure4 = 45; // Crown
static const int extraLife = 44; // 1-Up
// --- Enemies: Standard ---
static const int guardHalt = 21; // "Halt!"
static const int dogBark = 41;
static const int dogDeath = 62;
static const int dogAttack = 68;
static const int deathScream1 = 29;
static const int deathScream2 = 22;
static const int deathScream3 = 25;
static const int ssSchutzstaffel = 51; // "Schutzstaffel!"
static const int ssMeinGott = 63; // SS Death
// --- Enemies: Bosses (Retail Episodes 1-6) ---
static const int bossActive = 49;
static const int mutti = 50; // Hans Grosse Death
static const int ahhhg = 52;
static const int eva = 54; // Dr. Schabbs Death
static const int gutenTag = 55; // Hitler Greeting
static const int leben = 56;
static const int scheist = 57; // Hitler Death
static const int schabbsHas = 64; // Dr. Schabbs
static const int hitlerHas = 65;
static const int spion = 66; // Otto Giftmacher
static const int neinSoVass = 67; // Gretel Grosse Death
static const int mechSteps = 70; // Mecha-Hitler walking
// --- UI & Progression ---
static const int levelDone = 40;
static const int endBonus1 = 42;
static const int endBonus2 = 43;
static const int noBonus = 47;
static const int percent100 = 48;
}

View File

@@ -12,7 +12,8 @@ export 'src/game_file.dart' show GameFile;
export 'src/game_version.dart' show GameVersion; export 'src/game_version.dart' show GameVersion;
export 'src/image.dart' show VgaImage; export 'src/image.dart' show VgaImage;
export 'src/map_objects.dart' show MapObject; export 'src/map_objects.dart' show MapObject;
export 'src/sound.dart' show PcmSound, ImfMusic, ImfInstruction, WolfMusicMap; export 'src/sound.dart'
show PcmSound, ImfMusic, ImfInstruction, WolfMusicMap, WolfSound;
export 'src/sprite.dart' hide Matrix; export 'src/sprite.dart' hide Matrix;
export 'src/sprite_frame_range.dart' show SpriteFrameRange; export 'src/sprite_frame_range.dart' show SpriteFrameRange;
export 'src/wolf_level.dart' show WolfLevel; export 'src/wolf_level.dart' show WolfLevel;

View File

@@ -1,7 +1,11 @@
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart'; import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
abstract class EngineAudio { abstract class EngineAudio {
WolfensteinData? activeGame;
void playMenuMusic();
void playLevelMusic(WolfLevel level); void playLevelMusic(WolfLevel level);
void stopMusic(); void stopMusic();
// You can easily add things like void playSoundEffect(SoundId id); later! void playSoundEffect(int sfxId);
Future<void> init();
void dispose();
} }

View File

@@ -0,0 +1,22 @@
import 'package:wolf_3d_entities/wolf_3d_entities.dart';
/// A pure, framework-agnostic snapshot of the player's intended actions for a single frame.
class EngineInput {
final bool isMovingForward;
final bool isMovingBackward;
final bool isTurningLeft;
final bool isTurningRight;
final bool isFiring;
final bool isInteracting;
final WeaponType? requestedWeapon;
const EngineInput({
this.isMovingForward = false,
this.isMovingBackward = false,
this.isTurningLeft = false,
this.isTurningRight = false,
this.isFiring = false,
this.isInteracting = false,
this.requestedWeapon,
});
}

View File

@@ -7,13 +7,17 @@ class DoorManager {
// Key is '$x,$y' // Key is '$x,$y'
final Map<String, Door> doors = {}; final Map<String, Door> doors = {};
// Callback to play sounds without tightly coupling to the audio engine
final void Function(int sfxId) onPlaySound;
DoorManager({required this.onPlaySound});
void initDoors(Sprite wallGrid) { void initDoors(Sprite wallGrid) {
doors.clear(); doors.clear();
for (int y = 0; y < wallGrid.length; y++) { for (int y = 0; y < wallGrid.length; y++) {
for (int x = 0; x < wallGrid[y].length; x++) { for (int x = 0; x < wallGrid[y].length; x++) {
int id = wallGrid[y][x]; int id = wallGrid[y][x];
if (id >= 90) { if (id >= 90) {
// Assuming 90+ are doors based on your previous code
doors['$x,$y'] = Door(x: x, y: y, mapId: id); doors['$x,$y'] = Door(x: x, y: y, mapId: id);
} }
} }
@@ -22,7 +26,12 @@ class DoorManager {
void update(Duration elapsed) { void update(Duration elapsed) {
for (final door in doors.values) { for (final door in doors.values) {
door.update(elapsed.inMilliseconds); final newState = door.update(elapsed.inMilliseconds);
// The Manager decides: "If a door just started closing, play the close sound."
if (newState == DoorState.closing) {
onPlaySound(WolfSound.closeDoor);
}
} }
} }
@@ -32,7 +41,19 @@ class DoorManager {
String key = '$targetX,$targetY'; String key = '$targetX,$targetY';
if (doors.containsKey(key)) { if (doors.containsKey(key)) {
doors[key]!.interact(); if (doors[key]!.interact()) {
// The Manager decides: "Player successfully opened a door, play the sound."
onPlaySound(WolfSound.openDoor);
}
}
}
void tryOpenDoor(int x, int y) {
String key = '$x,$y';
if (doors.containsKey(key) && doors[key]!.offset == 0.0) {
if (doors[key]!.interact()) {
onPlaySound(WolfSound.openDoor);
}
} }
} }
@@ -47,16 +68,6 @@ class DoorManager {
return offsets; return offsets;
} }
void tryOpenDoor(int x, int y) {
String key = '$x,$y';
if (doors.containsKey(key)) {
// If it's closed or closing, interact() will usually start it opening
if (doors[key]!.offset == 0.0) {
doors[key]!.interact();
}
}
}
bool isDoorOpenEnough(int x, int y) { bool isDoorOpenEnough(int x, int y) {
String key = '$x,$y'; String key = '$x,$y';
if (doors.containsKey(key)) { if (doors.containsKey(key)) {

View File

@@ -1,6 +1,5 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'package:wolf_3d_data/wolf_3d_data.dart';
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart'; import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'package:wolf_3d_engine/wolf_3d_engine.dart'; import 'package:wolf_3d_engine/wolf_3d_engine.dart';
import 'package:wolf_3d_entities/wolf_3d_entities.dart'; import 'package:wolf_3d_entities/wolf_3d_entities.dart';
@@ -12,7 +11,9 @@ class WolfEngine {
required this.startingEpisode, required this.startingEpisode,
required this.onGameWon, required this.onGameWon,
required this.audio, required this.audio,
}); }) : doorManager = DoorManager(
onPlaySound: (sfxId) => audio.playSoundEffect(sfxId),
);
final WolfensteinData data; final WolfensteinData data;
final Difficulty difficulty; final Difficulty difficulty;
@@ -24,7 +25,8 @@ class WolfEngine {
final void Function() onGameWon; final void Function() onGameWon;
// Managers // Managers
final DoorManager doorManager = DoorManager(); final DoorManager doorManager;
final PushwallManager pushwallManager = PushwallManager(); final PushwallManager pushwallManager = PushwallManager();
// State // State
@@ -94,6 +96,7 @@ class WolfEngine {
final Level objectLevel = activeLevel.objectGrid; final Level objectLevel = activeLevel.objectGrid;
doorManager.initDoors(currentLevel); doorManager.initDoors(currentLevel);
pushwallManager.initPushwalls(currentLevel, objectLevel); pushwallManager.initPushwalls(currentLevel, objectLevel);
audio.playLevelMusic(activeLevel); audio.playLevelMusic(activeLevel);

View File

@@ -4,6 +4,7 @@
library; library;
export 'src/engine_audio.dart'; export 'src/engine_audio.dart';
export 'src/engine_input.dart';
export 'src/managers/door_manager.dart'; export 'src/managers/door_manager.dart';
export 'src/managers/pushwall_manager.dart'; export 'src/managers/pushwall_manager.dart';
export 'src/player/player.dart'; export 'src/player/player.dart';

View File

@@ -3,54 +3,47 @@ enum DoorState { closed, opening, open, closing }
class Door { class Door {
final int x; final int x;
final int y; final int y;
final int mapId; // To differentiate between regular doors and elevator doors final int mapId;
DoorState state = DoorState.closed; DoorState state = DoorState.closed;
double offset = 0.0; double offset = 0.0;
int openTime = 0; // When did the door fully open? int openTime = 0;
// How long a door stays open before auto-closing
static const int openDurationMs = 3000; static const int openDurationMs = 3000;
Door({ Door({required this.x, required this.y, required this.mapId});
required this.x,
required this.y,
required this.mapId,
});
// Returns true if the door state changed this frame (useful for playing sounds later)
bool update(int currentTimeMs) {
bool stateChanged = false;
/// Updates animation. Returns the NEW state if it changed this frame, else null.
DoorState? update(int currentTimeMs) {
if (state == DoorState.opening) { if (state == DoorState.opening) {
offset += 0.02; // Slide speed offset += 0.02;
if (offset >= 1.0) { if (offset >= 1.0) {
offset = 1.0; offset = 1.0;
state = DoorState.open; state = DoorState.open;
openTime = currentTimeMs; openTime = currentTimeMs;
stateChanged = true; return DoorState.open;
} }
} else if (state == DoorState.open) { } else if (state == DoorState.open) {
if (currentTimeMs - openTime > openDurationMs) { if (currentTimeMs - openTime > openDurationMs) {
state = DoorState.closing; state = DoorState.closing;
stateChanged = true; return DoorState.closing;
} }
} else if (state == DoorState.closing) { } else if (state == DoorState.closing) {
// Note: We don't check for entities blocking the door yet!
offset -= 0.02; offset -= 0.02;
if (offset <= 0.0) { if (offset <= 0.0) {
offset = 0.0; offset = 0.0;
state = DoorState.closed; state = DoorState.closed;
stateChanged = true; return DoorState.closed;
} }
} }
return null;
return stateChanged;
} }
void interact() { /// Triggers the opening process. Returns true if it successfully started opening.
bool interact() {
if (state == DoorState.closed || state == DoorState.closing) { if (state == DoorState.closed || state == DoorState.closing) {
state = DoorState.opening; state = DoorState.opening;
return true;
} }
return false;
} }
} }

View File

@@ -12,4 +12,34 @@ class FlutterAudioAdapter implements EngineAudio {
void stopMusic() { void stopMusic() {
Wolf3d.I.audio.stopMusic(); Wolf3d.I.audio.stopMusic();
} }
@override
void playSoundEffect(int sfxId) {
Wolf3d.I.audio.playSoundEffect(sfxId);
}
@override
void playMenuMusic() {
Wolf3d.I.audio.playMenuMusic();
}
@override
Future<void> init() async {
await Wolf3d.I.audio.init();
}
@override
void dispose() {
Wolf3d.I.audio.dispose();
}
@override
WolfensteinData? get activeGame => Wolf3d.I.activeGame;
@override
set activeGame(WolfensteinData? value) {
if (value != null) {
Wolf3d.I.setActiveGame(value);
}
}
} }

View File

@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:wolf_3d_data/wolf_3d_data.dart'; import 'package:wolf_3d_data/wolf_3d_data.dart';
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart'; import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'package:wolf_3d_engine/wolf_3d_engine.dart';
import 'package:wolf_3d_synth/wolf_3d_synth.dart'; import 'package:wolf_3d_synth/wolf_3d_synth.dart';
class Wolf3d { class Wolf3d {
@@ -14,7 +15,7 @@ class Wolf3d {
WolfensteinData? _activeGame; WolfensteinData? _activeGame;
// --- Core Systems --- // --- Core Systems ---
final WolfAudio audio = WolfAudio(); final EngineAudio audio = WolfAudio();
// --- Getters --- // --- Getters ---
WolfensteinData get activeGame { WolfensteinData get activeGame {

View File

@@ -1,48 +1,34 @@
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:wolf_3d_engine/wolf_3d_engine.dart';
import 'package:wolf_3d_entities/wolf_3d_entities.dart'; import 'package:wolf_3d_entities/wolf_3d_entities.dart';
class InputManager { class WolfInput {
Set<LogicalKeyboardKey> _previousKeys = {}; Set<LogicalKeyboardKey> _previousKeys = {};
bool isMovingForward = false; bool isMovingForward = false;
bool isMovingBackward = false; bool isMovingBackward = false;
bool isTurningLeft = false; bool isTurningLeft = false;
bool isTurningRight = false; bool isTurningRight = false;
// Discrete (triggers once per press)
bool isInteracting = false; bool isInteracting = false;
// Continuous
bool isFiring = false; bool isFiring = false;
WeaponType? requestedWeapon; WeaponType? requestedWeapon;
void update() { void update() {
final pressedKeys = HardwareKeyboard.instance.logicalKeysPressed; final pressedKeys = HardwareKeyboard.instance.logicalKeysPressed;
// Calculate all keys that were pressed exactly on this frame
final newlyPressedKeys = pressedKeys.difference(_previousKeys); final newlyPressedKeys = pressedKeys.difference(_previousKeys);
// * Movement
isMovingForward = pressedKeys.contains(LogicalKeyboardKey.keyW); isMovingForward = pressedKeys.contains(LogicalKeyboardKey.keyW);
isMovingBackward = pressedKeys.contains(LogicalKeyboardKey.keyS); isMovingBackward = pressedKeys.contains(LogicalKeyboardKey.keyS);
isTurningLeft = pressedKeys.contains(LogicalKeyboardKey.keyA); isTurningLeft = pressedKeys.contains(LogicalKeyboardKey.keyA);
isTurningRight = pressedKeys.contains(LogicalKeyboardKey.keyD); isTurningRight = pressedKeys.contains(LogicalKeyboardKey.keyD);
// * Interaction (Space)
// Much simpler now using the newlyPressedKeys set
isInteracting = newlyPressedKeys.contains(LogicalKeyboardKey.space); isInteracting = newlyPressedKeys.contains(LogicalKeyboardKey.space);
// * Firing (Left Control)
// - Keeping this continuous for machine guns
isFiring = isFiring =
pressedKeys.contains(LogicalKeyboardKey.controlLeft) && pressedKeys.contains(LogicalKeyboardKey.controlLeft) &&
!pressedKeys.contains(LogicalKeyboardKey.space); !pressedKeys.contains(LogicalKeyboardKey.space);
// * Manual Weapon Switching
requestedWeapon = null; requestedWeapon = null;
// Iterate through newly pressed keys and switch on them
for (final LogicalKeyboardKey key in newlyPressedKeys) { for (final LogicalKeyboardKey key in newlyPressedKeys) {
switch (key) { switch (key) {
case LogicalKeyboardKey.digit1: case LogicalKeyboardKey.digit1:
@@ -56,7 +42,17 @@ class InputManager {
} }
} }
// * Save state for next tick
_previousKeys = Set.from(pressedKeys); _previousKeys = Set.from(pressedKeys);
} }
/// Exports the current state as a clean DTO for the engine
EngineInput get currentInput => EngineInput(
isMovingForward: isMovingForward,
isMovingBackward: isMovingBackward,
isTurningLeft: isTurningLeft,
isTurningRight: isTurningRight,
isFiring: isFiring,
isInteracting: isInteracting,
requestedWeapon: requestedWeapon,
);
} }

View File

@@ -13,6 +13,7 @@ dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
wolf_3d_entities: any wolf_3d_entities: any
wolf_3d_engine: any
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart'; import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'package:wolf_3d_engine/wolf_3d_engine.dart'; import 'package:wolf_3d_engine/wolf_3d_engine.dart';
import 'package:wolf_3d_entities/wolf_3d_entities.dart';
import 'package:wolf_3d_input/wolf_3d_input.dart'; import 'package:wolf_3d_input/wolf_3d_input.dart';
import 'package:wolf_3d_renderer/hud.dart'; import 'package:wolf_3d_renderer/hud.dart';
import 'package:wolf_3d_renderer/raycast_painter.dart'; import 'package:wolf_3d_renderer/raycast_painter.dart';
@@ -15,12 +14,14 @@ class WolfRenderer extends StatefulWidget {
this.data, { this.data, {
required this.difficulty, required this.difficulty,
required this.startingEpisode, required this.startingEpisode,
required this.audio,
super.key, super.key,
}); });
final WolfensteinData data; final WolfensteinData data;
final Difficulty difficulty; final Difficulty difficulty;
final int startingEpisode; final int startingEpisode;
final EngineAudio audio;
@override @override
State<WolfRenderer> createState() => _WolfRendererState(); State<WolfRenderer> createState() => _WolfRendererState();
@@ -28,143 +29,49 @@ class WolfRenderer extends StatefulWidget {
class _WolfRendererState extends State<WolfRenderer> class _WolfRendererState extends State<WolfRenderer>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
final InputManager inputManager = InputManager(); // 1. The input reader
final DoorManager doorManager = DoorManager(); final WolfInput inputManager = WolfInput();
final PushwallManager pushwallManager = PushwallManager();
// 2. The central brain of the game
late final WolfEngine engine;
late Ticker _gameLoop; late Ticker _gameLoop;
final FocusNode _focusNode = FocusNode(); final FocusNode _focusNode = FocusNode();
late Level currentLevel;
late WolfLevel activeLevel;
final double fov = math.pi / 3; final double fov = math.pi / 3;
late Player player;
bool _isLoading = true;
double damageFlashOpacity = 0.0;
late int _currentEpisodeIndex;
late int _currentLevelIndex;
int? _returnLevelIndex;
List<Entity> entities = [];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_initGame();
}
Future<void> _initGame() async { // Initialize the engine and hand over all the data and dependencies
// 1. Setup our starting indices engine = WolfEngine(
_currentEpisodeIndex = widget.startingEpisode; data: widget.data,
_currentLevelIndex = 0; difficulty: widget.difficulty,
startingEpisode: widget.startingEpisode,
audio: widget.audio,
onGameWon: () {
Navigator.of(context).pop();
},
);
// 2. Load the first floor! engine.init();
_loadLevel();
// Start the loop
_gameLoop = createTicker(_tick)..start(); _gameLoop = createTicker(_tick)..start();
_focusNode.requestFocus(); _focusNode.requestFocus();
setState(() {
_isLoading = false;
});
} }
void _loadLevel() { // --- ORCHESTRATOR ---
// 1. Clean up the previous level's state void _tick(Duration elapsed) {
entities.clear(); // 1. Read the keyboard state
damageFlashOpacity = 0.0; inputManager.update();
// 2. Grab the exact level from our new Episode hierarchy // 2. Let the engine do all the math, physics, collision, and logic!
final episode = widget.data.episodes[_currentEpisodeIndex]; engine.tick(elapsed, inputManager.currentInput);
activeLevel = episode.levels[_currentLevelIndex];
// 3. DEEP COPY the wall grid! If we don't do this, destroying walls/doors // 3. Force a UI repaint using the newly updated engine state
// will permanently corrupt the map data in the Wolf3d singleton. setState(() {});
currentLevel = List.generate(64, (y) => List.from(activeLevel.wallGrid[y]));
final Level objectLevel = activeLevel.objectGrid;
// 4. Initialize Managers
doorManager.initDoors(currentLevel);
pushwallManager.initPushwalls(currentLevel, objectLevel);
// 6. Spawn Player and Entities
for (int y = 0; y < 64; y++) {
for (int x = 0; x < 64; x++) {
int objId = objectLevel[y][x];
if (objId >= MapObject.playerNorth && objId <= MapObject.playerWest) {
double spawnAngle = 0.0;
if (objId == MapObject.playerNorth) {
spawnAngle = 3 * math.pi / 2;
} else if (objId == MapObject.playerEast) {
spawnAngle = 0.0;
} else if (objId == MapObject.playerSouth) {
spawnAngle = math.pi / 2;
} else if (objId == MapObject.playerWest) {
spawnAngle = math.pi;
}
player = Player(x: x + 0.5, y: y + 0.5, angle: spawnAngle);
} else {
Entity? newEntity = EntityRegistry.spawn(
objId,
x + 0.5,
y + 0.5,
widget.difficulty,
widget.data.sprites.length,
isSharewareMode: widget.data.version == GameVersion.shareware,
);
if (newEntity != null) entities.add(newEntity);
}
}
}
// 7. Clear non-solid blocks from the collision grid
for (int y = 0; y < 64; y++) {
for (int x = 0; x < 64; x++) {
int id = currentLevel[y][x];
if (!((id >= 1 && id <= 63) || (id >= 90 && id <= 101))) {
currentLevel[y][x] = 0;
}
}
}
_bumpPlayerIfStuck();
debugPrint("Loaded Floor: ${_currentLevelIndex + 1} - ${activeLevel.name}");
}
void _onLevelCompleted({bool isSecretExit = false}) {
setState(() {
final currentEpisode = widget.data.episodes[_currentEpisodeIndex];
if (isSecretExit) {
// Save the next normal map index so we can return to it later
_returnLevelIndex = _currentLevelIndex + 1;
_currentLevelIndex = 9; // Jump to the secret map
debugPrint("Found the Secret Exit!");
} else {
// Are we currently ON the secret map, and need to return?
if (_currentLevelIndex == 9 && _returnLevelIndex != null) {
_currentLevelIndex = _returnLevelIndex!;
_returnLevelIndex = null;
} else {
_currentLevelIndex++; // Normal progression
}
}
// Did we just beat the last map in the episode (Map 9) or the secret map (Map 10)?
if (_currentLevelIndex >= currentEpisode.levels.length ||
_currentLevelIndex > 9) {
debugPrint("Episode Completed! You win!");
Navigator.of(context).pop();
} else {
_loadLevel();
}
});
} }
@override @override
@@ -174,285 +81,10 @@ class _WolfRendererState extends State<WolfRenderer>
super.dispose(); super.dispose();
} }
void _bumpPlayerIfStuck() {
int pX = player.x.toInt();
int pY = player.y.toInt();
if (pY < 0 ||
pY >= currentLevel.length ||
pX < 0 ||
pX >= currentLevel[0].length ||
currentLevel[pY][pX] > 0) {
double shortestDist = double.infinity;
Coordinate2D nearestSafeSpot = Coordinate2D(1.5, 1.5);
for (int y = 0; y < currentLevel.length; y++) {
for (int x = 0; x < currentLevel[y].length; x++) {
if (currentLevel[y][x] == 0) {
Coordinate2D safeSpot = Coordinate2D(x + 0.5, y + 0.5);
double dist = safeSpot.distanceTo(player.position);
if (dist < shortestDist) {
shortestDist = dist;
nearestSafeSpot = safeSpot;
}
}
}
}
player.x = nearestSafeSpot.x;
player.y = nearestSafeSpot.y;
}
}
bool _isWalkable(int x, int y) {
if (currentLevel[y][x] == 0) return true;
if (currentLevel[y][x] >= 90) {
return doorManager.isDoorOpenEnough(x, y);
}
return false;
}
// --- ORCHESTRATOR ---
void _tick(Duration elapsed) {
// 1. Process intentions and receive movement vectors
final inputResult = _processInputs(elapsed);
doorManager.update(elapsed);
pushwallManager.update(elapsed, currentLevel);
// 2. Explicit State Updates
player.updateWeaponSwitch();
player.angle += inputResult.dAngle;
// Keep the angle neatly clamped between 0 and 2*PI
if (player.angle < 0) player.angle += 2 * math.pi;
if (player.angle >= 2 * math.pi) player.angle -= 2 * math.pi;
final Coordinate2D validatedPos = _calculateValidatedPosition(
player.position,
inputResult.movement,
);
player.x = validatedPos.x;
player.y = validatedPos.y;
_updateEntities(elapsed);
// Explicit reassignment from a pure(r) function
damageFlashOpacity = _calculateScreenEffects(damageFlashOpacity);
// 3. Combat
player.updateWeapon(
currentTime: elapsed.inMilliseconds,
entities: entities,
isWalkable: _isWalkable,
);
// 4. Render
setState(() {});
}
// Returns a Record containing both movement delta and rotation delta
({Coordinate2D movement, double dAngle}) _processInputs(Duration elapsed) {
inputManager.update();
const double moveSpeed = 0.14;
const double turnSpeed = 0.10;
Coordinate2D movement = const Coordinate2D(0, 0);
double dAngle = 0.0;
if (inputManager.requestedWeapon != null) {
player.requestWeaponSwitch(inputManager.requestedWeapon!);
}
if (inputManager.isFiring) {
player.fire(elapsed.inMilliseconds);
} else {
player.releaseTrigger();
}
// Calculate intended rotation
if (inputManager.isTurningLeft) dAngle -= turnSpeed;
if (inputManager.isTurningRight) dAngle += turnSpeed;
// Calculate intended movement based on CURRENT angle
Coordinate2D forwardVec = Coordinate2D(
math.cos(player.angle),
math.sin(player.angle),
);
if (inputManager.isMovingForward) {
movement += forwardVec * moveSpeed;
}
if (inputManager.isMovingBackward) {
movement -= forwardVec * moveSpeed;
}
if (inputManager.isInteracting) {
// 1. Calculate the tile exactly 1 block in front of the player
int targetX = (player.x + math.cos(player.angle)).toInt();
int targetY = (player.y + math.sin(player.angle)).toInt();
// Ensure we don't check outside the map bounds
if (targetX >= 0 && targetX < 64 && targetY >= 0 && targetY < 64) {
// 2. Check the WALL grid for the physical switch texture
int wallId = currentLevel[targetY][targetX];
if (wallId == MapObject.normalElevatorSwitch) {
// Player hit the switch!
_onLevelCompleted(isSecretExit: false);
return (movement: const Coordinate2D(0, 0), dAngle: 0.0);
} else if (wallId == MapObject.secretElevatorSwitch) {
_onLevelCompleted(isSecretExit: true);
return (movement: const Coordinate2D(0, 0), dAngle: 0.0);
}
// 3. Check the OBJECT grid for invisible floor triggers
// (Some custom maps use these instead of wall switches)
int objId = activeLevel.objectGrid[targetY][targetX];
if (objId == MapObject.normalExitTrigger) {
_onLevelCompleted(isSecretExit: false);
return (movement: movement, dAngle: dAngle);
} else if (objId == MapObject.secretExitTrigger) {
_onLevelCompleted(isSecretExit: true);
return (movement: movement, dAngle: dAngle);
}
}
// 4. If it wasn't an elevator, try opening a door or pushing a wall
doorManager.handleInteraction(player.x, player.y, player.angle);
pushwallManager.handleInteraction(
player.x,
player.y,
player.angle,
currentLevel,
);
}
return (movement: movement, dAngle: dAngle);
}
Coordinate2D _calculateValidatedPosition(
Coordinate2D currentPos,
Coordinate2D movement,
) {
const double margin = 0.3;
double newX = currentPos.x;
double newY = currentPos.y;
// Calculate potential new coordinates
Coordinate2D target = currentPos + movement;
// Validate X (allows sliding along walls)
if (movement.x != 0) {
int checkX = (movement.x > 0)
? (target.x + margin).toInt()
: (target.x - margin).toInt();
if (_isWalkable(checkX, currentPos.y.toInt())) {
newX = target.x;
}
}
// Validate Y
if (movement.y != 0) {
int checkY = (movement.y > 0)
? (target.y + margin).toInt()
: (target.y - margin).toInt();
if (_isWalkable(newX.toInt(), checkY)) {
newY = target.y;
}
}
return Coordinate2D(newX, newY);
}
void _updateEntities(Duration elapsed) {
List<Entity> itemsToRemove = [];
List<Entity> itemsToAdd = []; // NEW: Buffer for dropped items
for (Entity entity in entities) {
if (entity is Enemy) {
// 1. Get Intent (Now passing tryOpenDoor!)
final intent = entity.update(
elapsedMs: elapsed.inMilliseconds,
playerPosition: player.position,
isWalkable: _isWalkable,
tryOpenDoor: doorManager.tryOpenDoor,
onDamagePlayer: (int damage) {
player.takeDamage(damage);
damageFlashOpacity = 0.5;
},
);
// 2. Update Angle
entity.angle = intent.newAngle;
// 3. Resolve Movement
// We NO LONGER use _calculateValidatedPosition here!
// The enemy's internal getValidMovement already did the math perfectly.
entity.x += intent.movement.x;
entity.y += intent.movement.y;
// 4. Handle Item Drops & Score (Matches KillActor in C code)
if (entity.state == EntityState.dead &&
entity.isDying &&
!entity.hasDroppedItem) {
entity.hasDroppedItem = true;
// Map ID 44 is usually the Ammo Clip in the Object Grid/Registry
Entity? droppedAmmo = EntityRegistry.spawn(
MapObject.ammoClip,
entity.x,
entity.y,
widget.difficulty,
widget.data.sprites.length,
);
if (droppedAmmo != null) {
itemsToAdd.add(droppedAmmo);
}
// You will need to add a `bool hasDroppedItem = false;` to your base Enemy class.
if (entity.runtimeType.toString() == 'BrownGuard') {
// Example: Spawn an ammo clip where the guard died
// itemsToAdd.add(Collectible(x: entity.x, y: entity.y, type: CollectibleType.ammoClip));
} else if (entity.runtimeType.toString() == 'Dog') {
// Dogs don't drop items, but maybe they give different points!
}
}
} else if (entity is Collectible) {
if (player.position.distanceTo(entity.position) < 0.5) {
if (player.tryPickup(entity)) {
itemsToRemove.add(entity);
}
}
}
}
// Clean up dead items and add new drops
if (itemsToRemove.isNotEmpty) {
entities.removeWhere((e) => itemsToRemove.contains(e));
}
if (itemsToAdd.isNotEmpty) {
entities.addAll(itemsToAdd);
}
}
// Takes an input and returns a value instead of implicitly changing state
double _calculateScreenEffects(double currentOpacity) {
if (currentOpacity > 0) {
return math.max(0.0, currentOpacity - 0.05);
}
return currentOpacity;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_isLoading) { // Wait for the engine to finish parsing the level
if (!engine.isInitialized) {
return const Center(child: CircularProgressIndicator(color: Colors.teal)); return const Center(child: CircularProgressIndicator(color: Colors.teal));
} }
@@ -472,36 +104,46 @@ class _WolfRendererState extends State<WolfRenderer>
aspectRatio: 16 / 10, aspectRatio: 16 / 10,
child: Stack( child: Stack(
children: [ children: [
// --- 3D WORLD ---
CustomPaint( CustomPaint(
size: Size( size: Size(
constraints.maxWidth, constraints.maxWidth,
constraints.maxHeight, constraints.maxHeight,
), ),
painter: RaycasterPainter( painter: RaycasterPainter(
map: currentLevel, // Read state directly from the engine
map: engine.currentLevel,
textures: widget.data.walls, textures: widget.data.walls,
player: player, player: engine.player,
fov: fov, fov: fov,
doorOffsets: doorManager.getOffsetsForRenderer(), doorOffsets: engine.doorManager
entities: entities, .getOffsetsForRenderer(),
entities: engine.entities,
sprites: widget.data.sprites, sprites: widget.data.sprites,
activePushwall: pushwallManager.activePushwall, activePushwall:
engine.pushwallManager.activePushwall,
), ),
), ),
// --- FIRST PERSON WEAPON ---
Positioned( Positioned(
bottom: -20, bottom: -20,
left: 0, left: 0,
right: 0, right: 0,
child: Center( child: Center(
child: Transform.translate( child: Transform.translate(
offset: Offset(0, player.weaponAnimOffset), offset: Offset(
0,
engine.player.weaponAnimOffset,
),
child: SizedBox( child: SizedBox(
width: 500, width: 500,
height: 500, height: 500,
child: CustomPaint( child: CustomPaint(
painter: WeaponPainter( painter: WeaponPainter(
sprite: sprite:
widget.data.sprites[player widget.data.sprites[engine
.player
.currentWeapon .currentWeapon
.getCurrentSpriteIndex( .getCurrentSpriteIndex(
widget.data.sprites.length, widget.data.sprites.length,
@@ -512,11 +154,13 @@ class _WolfRendererState extends State<WolfRenderer>
), ),
), ),
), ),
if (damageFlashOpacity > 0)
// --- DAMAGE FLASH ---
if (engine.damageFlashOpacity > 0)
Positioned.fill( Positioned.fill(
child: Container( child: Container(
color: Colors.red.withValues( color: Colors.red.withValues(
alpha: damageFlashOpacity, alpha: engine.damageFlashOpacity,
), ),
), ),
), ),
@@ -527,7 +171,9 @@ class _WolfRendererState extends State<WolfRenderer>
}, },
), ),
), ),
Hud(player: player),
// --- UI ---
Hud(player: engine.player),
], ],
), ),
), ),

View File

@@ -2,9 +2,10 @@ import 'dart:typed_data';
import 'package:audioplayers/audioplayers.dart'; import 'package:audioplayers/audioplayers.dart';
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart'; import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'package:wolf_3d_engine/wolf_3d_engine.dart';
import 'package:wolf_3d_synth/src/imf_renderer.dart'; import 'package:wolf_3d_synth/src/imf_renderer.dart';
class WolfAudio { class WolfAudio implements EngineAudio {
bool _isInitialized = false; bool _isInitialized = false;
// --- Music State --- // --- Music State ---
@@ -16,9 +17,11 @@ class WolfAudio {
final List<AudioPlayer> _sfxPlayers = []; final List<AudioPlayer> _sfxPlayers = [];
int _currentSfxIndex = 0; int _currentSfxIndex = 0;
@override
WolfensteinData? activeGame; WolfensteinData? activeGame;
/// Initializes the audio engine and pre-allocates the SFX pool. /// Initializes the audio engine and pre-allocates the SFX pool.
@override
Future<void> init() async { Future<void> init() async {
if (_isInitialized) return; if (_isInitialized) return;
@@ -45,6 +48,7 @@ class WolfAudio {
} }
/// Disposes of the audio engine and frees resources. /// Disposes of the audio engine and frees resources.
@override
void dispose() { void dispose() {
stopMusic(); stopMusic();
_musicPlayer.dispose(); _musicPlayer.dispose();
@@ -79,6 +83,7 @@ class WolfAudio {
} }
} }
@override
Future<void> stopMusic() async { Future<void> stopMusic() async {
if (!_isInitialized) return; if (!_isInitialized) return;
await _musicPlayer.stop(); await _musicPlayer.stop();
@@ -92,12 +97,14 @@ class WolfAudio {
if (_isInitialized) await _musicPlayer.resume(); if (_isInitialized) await _musicPlayer.resume();
} }
@override
Future<void> playMenuMusic() async { Future<void> playMenuMusic() async {
final data = activeGame; final data = activeGame;
if (data == null || data.music.length <= 1) return; if (data == null || data.music.length <= 1) return;
await playMusic(data.music[1]); await playMusic(data.music[1]);
} }
@override
Future<void> playLevelMusic(WolfLevel level) async { Future<void> playLevelMusic(WolfLevel level) async {
final data = activeGame; final data = activeGame;
if (data == null || data.music.isEmpty) return; if (data == null || data.music.isEmpty) return;
@@ -114,21 +121,38 @@ class WolfAudio {
// SFX MANAGEMENT // SFX MANAGEMENT
// ========================================== // ==========================================
/// Plays a sound effect from a WAV byte array using the round-robin pool. @override
Future<void> playSfx(Uint8List wavBytes) async { Future<void> playSoundEffect(int sfxId) async {
if (!_isInitialized) return; print("Playing sfx id $sfxId");
if (!_isInitialized || activeGame == null) return;
// 1. Get the raw 8-bit unsigned PCM bytes from the game data
final Uint8List raw8bitBytes = activeGame!.sounds[sfxId].bytes;
if (raw8bitBytes.isEmpty) return;
// 2. Convert 8-bit Unsigned PCM -> 16-bit Signed PCM
// Wolf3D raw sounds are biased at 128.
final Int16List converted16bit = Int16List(raw8bitBytes.length);
for (int i = 0; i < raw8bitBytes.length; i++) {
// (sample - 128) shifts it to signed 8-bit range (-128 to 127)
// Multiplying by 256 scales it to 16-bit range (-32768 to 32512)
converted16bit[i] = (raw8bitBytes[i] - 128) * 256;
}
// 3. Wrap in a WAV header (Wolf3D digitized sounds are 7000Hz Mono)
final wavBytes = ImfRenderer.createWavFile(
converted16bit,
sampleRate: 7000,
);
try { try {
// Grab the next available player in the pool
final player = _sfxPlayers[_currentSfxIndex]; final player = _sfxPlayers[_currentSfxIndex];
// Move to the next index, looping back to 0 if we hit the max
_currentSfxIndex = (_currentSfxIndex + 1) % _maxSfxChannels; _currentSfxIndex = (_currentSfxIndex + 1) % _maxSfxChannels;
// Play the sound (this interrupts whatever this specific channel was playing) // Note: We use BytesSource because createWavFile returns Uint8List (the file bytes)
await player.play(BytesSource(wavBytes)); await player.play(BytesSource(wavBytes));
} catch (e) { } catch (e) {
print("WolfAudio: Error playing SFX - $e"); print("WolfAudio: SFX Error - $e");
} }
} }
} }

View File

@@ -11,6 +11,7 @@ resolution: workspace
dependencies: dependencies:
audioplayers: ^6.6.0 audioplayers: ^6.6.0
wolf_3d_data_types: wolf_3d_data_types:
wolf_3d_engine:
dev_dependencies: dev_dependencies:
lints: ^6.0.0 lints: ^6.0.0