Handle exit elevators and secret levels

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-15 15:25:22 +01:00
parent 45ab8e4aed
commit 5f3e3bb823
8 changed files with 187 additions and 105 deletions

View File

@@ -66,6 +66,10 @@ abstract class MapObject {
static const int secretExitTrigger = 99; static const int secretExitTrigger = 99;
static const int normalExitTrigger = 100; static const int normalExitTrigger = 100;
// --- Wall Textures (From VSWAP/MAPHEAD) ---
static const int normalElevatorSwitch = 21;
static const int secretElevatorSwitch = 41;
// Bosses (Shared between WL1 and WL6) // Bosses (Shared between WL1 and WL6)
static const int bossHansGrosse = 214; static const int bossHansGrosse = 214;

View File

@@ -54,8 +54,9 @@ class _WolfRendererState extends State<WolfRenderer>
double damageFlashOpacity = 0.0; double damageFlashOpacity = 0.0;
late int _currentMapIndex; late int _currentEpisodeIndex;
late WolfLevel _currentLevel; late int _currentLevelIndex;
int? _returnLevelIndex;
List<Entity> entities = []; List<Entity> entities = [];
@@ -65,52 +66,47 @@ class _WolfRendererState extends State<WolfRenderer>
_initGame(); _initGame();
} }
void _loadLevel(int mapIndex) { Future<void> _initGame() async {
// Grab the specific level from the singleton // 1. Setup our starting indices
_currentLevel = Wolf3d.I.levels[mapIndex]; _currentEpisodeIndex = widget.startingEpisode;
_currentLevelIndex = 0;
// Play the exact track id Software intended for this level! // 2. Load the first floor!
Wolf3d.I.audio.playLevelMusic(_currentLevel); _loadLevel();
// TODO: Initialize player position, spawn enemies based on difficulty, etc. _gameLoop = createTicker(_tick)..start();
debugPrint("Loaded Level: ${_currentLevel.name}"); _focusNode.requestFocus();
}
void _onLevelCompleted() {
Wolf3d.I.audio.stopMusic();
// When the player hits the elevator switch, advance the map
setState(() { setState(() {
_currentMapIndex++; _isLoading = false;
// Check if they beat the episode (each episode is 10 levels)
int maxLevelForEpisode = (widget.startingEpisode * 10) + 9;
if (_currentMapIndex > maxLevelForEpisode) {
// TODO: Handle episode completion (show victory screen, return to menu)
debugPrint("Episode Completed!");
} else {
_loadLevel(_currentMapIndex);
}
}); });
} }
Future<void> _initGame() async { void _loadLevel() {
// 1. Calculate the starting index // 1. Clean up the previous level's state
_currentMapIndex = widget.startingEpisode * 10; entities.clear();
damageFlashOpacity = 0.0;
// 2. Load the initial level data // 2. Grab the exact level from our new Episode hierarchy
_loadLevel(_currentMapIndex); final episode = widget.data.episodes[_currentEpisodeIndex];
activeLevel = episode.levels[_currentLevelIndex];
// Get the first level out of the data class // 3. DEEP COPY the wall grid! If we don't do this, destroying walls/doors
activeLevel = widget.data.levels.first; // will permanently corrupt the map data in the Wolf3d singleton.
currentLevel = List.generate(
// Set up your grids directly from the active level 64,
currentLevel = activeLevel.wallGrid; (y) => List.from(activeLevel.wallGrid[y]),
);
final Level objectLevel = activeLevel.objectGrid; final Level objectLevel = activeLevel.objectGrid;
// 4. Initialize Managers
doorManager.initDoors(currentLevel); doorManager.initDoors(currentLevel);
pushwallManager.initPushwalls(currentLevel, objectLevel); pushwallManager.initPushwalls(currentLevel, objectLevel);
// 5. Play Music
Wolf3d.I.audio.playLevelMusic(activeLevel);
// 6. Spawn Player and Entities
for (int y = 0; y < 64; y++) { for (int y = 0; y < 64; y++) {
for (int x = 0; x < 64; x++) { for (int x = 0; x < 64; x++) {
int objId = objectLevel[y][x]; int objId = objectLevel[y][x];
@@ -119,25 +115,17 @@ class _WolfRendererState extends State<WolfRenderer>
if (objId >= MapObject.playerNorth && objId <= MapObject.playerWest) { if (objId >= MapObject.playerNorth && objId <= MapObject.playerWest) {
double spawnAngle = 0.0; double spawnAngle = 0.0;
switch (objId) { if (objId == MapObject.playerNorth) {
case MapObject.playerNorth:
spawnAngle = 3 * math.pi / 2; spawnAngle = 3 * math.pi / 2;
break; } else if (objId == MapObject.playerEast) {
case MapObject.playerEast:
spawnAngle = 0.0; spawnAngle = 0.0;
break; } else if (objId == MapObject.playerSouth) {
case MapObject.playerSouth:
spawnAngle = math.pi / 2; spawnAngle = math.pi / 2;
break; } else if (objId == MapObject.playerWest) {
case MapObject.playerWest:
spawnAngle = math.pi; spawnAngle = math.pi;
break;
} }
player = Player(
x: x + 0.5, player = Player(x: x + 0.5, y: y + 0.5, angle: spawnAngle);
y: y + 0.5,
angle: spawnAngle,
);
} else { } else {
Entity? newEntity = EntityRegistry.spawn( Entity? newEntity = EntityRegistry.spawn(
objId, objId,
@@ -147,31 +135,54 @@ class _WolfRendererState extends State<WolfRenderer>
widget.data.sprites.length, widget.data.sprites.length,
isSharewareMode: widget.data.version == GameVersion.shareware, isSharewareMode: widget.data.version == GameVersion.shareware,
); );
if (newEntity != null) entities.add(newEntity);
if (newEntity != null) {
entities.add(newEntity);
}
} }
} }
} }
// 7. Clear non-solid blocks from the collision grid
for (int y = 0; y < 64; y++) { for (int y = 0; y < 64; y++) {
for (int x = 0; x < 64; x++) { for (int x = 0; x < 64; x++) {
int id = currentLevel[y][x]; int id = currentLevel[y][x];
if ((id >= 1 && id <= 63) || (id >= 90 && id <= 101)) { if (!((id >= 1 && id <= 63) || (id >= 90 && id <= 101))) {
// Leave walls and doors solid
} else {
currentLevel[y][x] = 0; currentLevel[y][x] = 0;
} }
} }
} }
_bumpPlayerIfStuck(); _bumpPlayerIfStuck();
_gameLoop = createTicker(_tick)..start(); debugPrint("Loaded Floor: ${_currentLevelIndex + 1} - ${activeLevel.name}");
_focusNode.requestFocus(); }
void _onLevelCompleted({bool isSecretExit = false}) {
Wolf3d.I.audio.stopMusic();
setState(() { setState(() {
_isLoading = false; 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();
}
}); });
} }
@@ -299,11 +310,37 @@ class _WolfRendererState extends State<WolfRenderer>
} }
if (inputManager.isInteracting) { if (inputManager.isInteracting) {
doorManager.handleInteraction( // 1. Calculate the tile exactly 1 block in front of the player
player.x, int targetX = (player.x + math.cos(player.angle)).toInt();
player.y, int targetY = (player.y + math.sin(player.angle)).toInt();
player.angle,
); // 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( pushwallManager.handleInteraction(
player.x, player.x,
player.y, player.y,

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'package:wolf_dart/features/screens/difficulty_screen.dart'; import 'package:wolf_dart/features/screens/difficulty_screen.dart';
import 'package:wolf_dart/wolf_3d.dart'; import 'package:wolf_dart/wolf_3d.dart';
@@ -10,15 +11,6 @@ class EpisodeScreen extends StatefulWidget {
} }
class _EpisodeScreenState extends State<EpisodeScreen> { class _EpisodeScreenState extends State<EpisodeScreen> {
final List<String> _episodeNames = [
"Episode 1\nEscape from Wolfenstein",
"Episode 2\nOperation: Eisenfaust",
"Episode 3\nDie, Fuhrer, Die!",
"Episode 4\nA Dark Secret",
"Episode 5\nTrail of the Madman",
"Episode 6\nConfrontation",
];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -36,7 +28,7 @@ class _EpisodeScreenState extends State<EpisodeScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final int numberOfEpisodes = Wolf3d.I.activeGame.numberOfEpisodes; final List<Episode> episodes = Wolf3d.I.activeGame.episodes;
return Scaffold( return Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
@@ -56,8 +48,9 @@ class _EpisodeScreenState extends State<EpisodeScreen> {
const SizedBox(height: 40), const SizedBox(height: 40),
ListView.builder( ListView.builder(
shrinkWrap: true, shrinkWrap: true,
itemCount: numberOfEpisodes, itemCount: episodes.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final Episode episode = episodes[index];
return Padding( return Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
vertical: 8.0, vertical: 8.0,
@@ -74,7 +67,7 @@ class _EpisodeScreenState extends State<EpisodeScreen> {
), ),
onPressed: () => _selectEpisode(index), onPressed: () => _selectEpisode(index),
child: Text( child: Text(
_episodeNames[index], episode.name,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle(fontSize: 18), style: const TextStyle(fontSize: 18),
), ),

View File

@@ -34,7 +34,7 @@ class Wolf3d {
} }
// Convenience getters for the active game's assets // Convenience getters for the active game's assets
List<WolfLevel> get levels => activeGame.levels; List<WolfLevel> get levels => activeGame.episodes[activeEpisode].levels;
List<Sprite> get walls => activeGame.walls; List<Sprite> get walls => activeGame.walls;
List<Sprite> get sprites => activeGame.sprites; List<Sprite> get sprites => activeGame.sprites;
List<PcmSound> get sounds => activeGame.sounds; List<PcmSound> get sounds => activeGame.sounds;

View File

@@ -83,7 +83,7 @@ abstract class WLParser {
walls: parseWalls(vswap), walls: parseWalls(vswap),
sprites: parseSprites(vswap), sprites: parseSprites(vswap),
sounds: parseSounds(vswap).map((bytes) => PcmSound(bytes)).toList(), sounds: parseSounds(vswap).map((bytes) => PcmSound(bytes)).toList(),
levels: parseMaps(mapHead, gameMaps, isShareware: isShareware), episodes: parseEpisodes(mapHead, gameMaps, isShareware: isShareware),
vgaImages: parseVgaImages(vgaDict, vgaHead, vgaGraph), vgaImages: parseVgaImages(vgaDict, vgaHead, vgaGraph),
adLibSounds: audio.adLib, adLibSounds: audio.adLib,
music: audio.music, music: audio.music,
@@ -236,22 +236,39 @@ abstract class WLParser {
return images; return images;
} }
// --- Episode Names (From the original C Executable) ---
static const List<String> _sharewareEpisodeNames = [
"Episode 1\nEscape from Wolfenstein",
];
static const List<String> _retailEpisodeNames = [
"Episode 1\nEscape from Wolfenstein",
"Episode 2\nOperation: Eisenfaust",
"Episode 3\nDie, Fuhrer, Die!",
"Episode 4\nA Dark Secret",
"Episode 5\nTrail of the Madman",
"Episode 6\nConfrontation",
];
/// Parses MAPHEAD and GAMEMAPS to extract the raw level data. /// Parses MAPHEAD and GAMEMAPS to extract the raw level data.
static List<WolfLevel> parseMaps( static List<Episode> parseEpisodes(
ByteData mapHead, ByteData mapHead,
ByteData gameMaps, { ByteData gameMaps, {
bool isShareware = true, bool isShareware = true,
}) { }) {
List<WolfLevel> levels = []; List<WolfLevel> allLevels = [];
// 1. Select the correct map based on the version
final activeMusicMap = isShareware ? _sharewareMusicMap : _retailMusicMap;
int rlewTag = mapHead.getUint16(0, Endian.little); int rlewTag = mapHead.getUint16(0, Endian.little);
// Select the correct music map based on the version
final activeMusicMap = isShareware ? _sharewareMusicMap : _retailMusicMap;
final episodeNames = isShareware
? _sharewareEpisodeNames
: _retailEpisodeNames;
// The game allows for up to 100 maps per file
for (int i = 0; i < 100; i++) { for (int i = 0; i < 100; i++) {
int mapOffset = mapHead.getUint32(2 + (i * 4), Endian.little); int mapOffset = mapHead.getUint32(2 + (i * 4), Endian.little);
if (mapOffset == 0) continue; if (mapOffset == 0) continue; // Empty map slot
int plane0Offset = gameMaps.getUint32(mapOffset + 0, Endian.little); int plane0Offset = gameMaps.getUint32(mapOffset + 0, Endian.little);
int plane1Offset = gameMaps.getUint32(mapOffset + 4, Endian.little); int plane1Offset = gameMaps.getUint32(mapOffset + 4, Endian.little);
@@ -259,13 +276,15 @@ abstract class WLParser {
int plane0Length = gameMaps.getUint16(mapOffset + 12, Endian.little); int plane0Length = gameMaps.getUint16(mapOffset + 12, Endian.little);
int plane1Length = gameMaps.getUint16(mapOffset + 14, Endian.little); int plane1Length = gameMaps.getUint16(mapOffset + 14, Endian.little);
// --- EXTRACT ACTUAL GAME DATA NAME ---
// The name is exactly 16 bytes long, starting at offset 22
List<int> nameBytes = []; List<int> nameBytes = [];
for (int n = 0; n < 16; n++) { for (int n = 0; n < 16; n++) {
int charCode = gameMaps.getUint8(mapOffset + 22 + n); int charCode = gameMaps.getUint8(mapOffset + 22 + n);
if (charCode == 0) break; if (charCode == 0) break; // Stop at the null-terminator
nameBytes.add(charCode); nameBytes.add(charCode);
} }
String name = ascii.decode(nameBytes); String parsedName = ascii.decode(nameBytes);
// --- DECOMPRESS PLANES --- // --- DECOMPRESS PLANES ---
final compressedWallData = gameMaps.buffer.asUint8List( final compressedWallData = gameMaps.buffer.asUint8List(
@@ -282,7 +301,7 @@ abstract class WLParser {
Uint16List carmackExpandedObjects = _expandCarmack(compressedObjectData); Uint16List carmackExpandedObjects = _expandCarmack(compressedObjectData);
List<int> flatObjectGrid = _expandRlew(carmackExpandedObjects, rlewTag); List<int> flatObjectGrid = _expandRlew(carmackExpandedObjects, rlewTag);
// --- BUILD GRIDS --- // --- BUILD 64x64 GRIDS ---
List<List<int>> wallGrid = []; List<List<int>> wallGrid = [];
List<List<int>> objectGrid = []; List<List<int>> objectGrid = [];
@@ -297,17 +316,14 @@ abstract class WLParser {
objectGrid.add(objectRow); objectGrid.add(objectRow);
} }
// Determine music track index. // --- ASSIGN MUSIC ---
// We use 'i' because it represents the absolute map slot (e.g. Map 12).
// If a map exists past the bounds of our lookup table (custom maps),
// we wrap around safely.
int trackIndex = (i < activeMusicMap.length) int trackIndex = (i < activeMusicMap.length)
? activeMusicMap[i] ? activeMusicMap[i]
: activeMusicMap[i % activeMusicMap.length]; : activeMusicMap[i % activeMusicMap.length];
levels.add( allLevels.add(
WolfLevel( WolfLevel(
name: name, name: parsedName,
wallGrid: wallGrid, wallGrid: wallGrid,
objectGrid: objectGrid, objectGrid: objectGrid,
musicIndex: trackIndex, musicIndex: trackIndex,
@@ -315,7 +331,35 @@ abstract class WLParser {
); );
} }
return levels; // 2. Group the parsed levels into Episodes!
List<Episode> episodes = [];
// Calculate how many episodes we need (10 levels per episode)
int totalEpisodes = (allLevels.length / 10).ceil();
for (int i = 0; i < totalEpisodes; i++) {
int startIndex = i * 10;
int endIndex = startIndex + 10;
// Safety clamp for incomplete episodes at the end of the file
if (endIndex > allLevels.length) {
endIndex = allLevels.length;
}
// If we run out of hardcoded id Software names, generate a custom one!
String epName = (i < episodeNames.length)
? episodeNames[i]
: "Episode ${i + 1}\nCustom Maps";
episodes.add(
Episode(
name: epName,
levels: allLevels.sublist(startIndex, endIndex),
),
);
}
return episodes;
} }
/// Extracts AdLib sounds and IMF music tracks from the audio files. /// Extracts AdLib sounds and IMF music tracks from the audio files.

View File

@@ -0,0 +1,8 @@
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
class Episode {
final String name;
final List<WolfLevel> levels;
const Episode({required this.name, required this.levels});
}

View File

@@ -7,13 +7,8 @@ class WolfensteinData {
final List<PcmSound> sounds; final List<PcmSound> sounds;
final List<AdLibSound> adLibSounds; final List<AdLibSound> adLibSounds;
final List<ImfMusic> music; final List<ImfMusic> music;
final List<WolfLevel> levels;
final List<VgaImage> vgaImages; final List<VgaImage> vgaImages;
final List<Episode> episodes;
// --- Derived Properties ---
/// Calculates the number of available episodes based on the loaded levels.
/// (Each episode consists of exactly 10 levels).
int get numberOfEpisodes => (levels.length / 10).floor().clamp(1, 6);
const WolfensteinData({ const WolfensteinData({
required this.version, required this.version,
@@ -22,7 +17,7 @@ class WolfensteinData {
required this.sounds, required this.sounds,
required this.adLibSounds, required this.adLibSounds,
required this.music, required this.music,
required this.levels,
required this.vgaImages, required this.vgaImages,
required this.episodes,
}); });
} }

View File

@@ -3,6 +3,7 @@
/// More dartdocs go here. /// More dartdocs go here.
library; library;
export 'src/episode.dart' show Episode;
export 'src/game_file.dart' show GameFile; 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;