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 normalExitTrigger = 100;
// --- Wall Textures (From VSWAP/MAPHEAD) ---
static const int normalElevatorSwitch = 21;
static const int secretElevatorSwitch = 41;
// Bosses (Shared between WL1 and WL6)
static const int bossHansGrosse = 214;

View File

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

View File

@@ -1,4 +1,5 @@
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/wolf_3d.dart';
@@ -10,15 +11,6 @@ class EpisodeScreen extends StatefulWidget {
}
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
void initState() {
super.initState();
@@ -36,7 +28,7 @@ class _EpisodeScreenState extends State<EpisodeScreen> {
@override
Widget build(BuildContext context) {
final int numberOfEpisodes = Wolf3d.I.activeGame.numberOfEpisodes;
final List<Episode> episodes = Wolf3d.I.activeGame.episodes;
return Scaffold(
backgroundColor: Colors.black,
@@ -56,8 +48,9 @@ class _EpisodeScreenState extends State<EpisodeScreen> {
const SizedBox(height: 40),
ListView.builder(
shrinkWrap: true,
itemCount: numberOfEpisodes,
itemCount: episodes.length,
itemBuilder: (context, index) {
final Episode episode = episodes[index];
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0,
@@ -74,7 +67,7 @@ class _EpisodeScreenState extends State<EpisodeScreen> {
),
onPressed: () => _selectEpisode(index),
child: Text(
_episodeNames[index],
episode.name,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 18),
),

View File

@@ -34,7 +34,7 @@ class Wolf3d {
}
// 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 sprites => activeGame.sprites;
List<PcmSound> get sounds => activeGame.sounds;

View File

@@ -83,7 +83,7 @@ abstract class WLParser {
walls: parseWalls(vswap),
sprites: parseSprites(vswap),
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),
adLibSounds: audio.adLib,
music: audio.music,
@@ -236,22 +236,39 @@ abstract class WLParser {
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.
static List<WolfLevel> parseMaps(
static List<Episode> parseEpisodes(
ByteData mapHead,
ByteData gameMaps, {
bool isShareware = true,
}) {
List<WolfLevel> levels = [];
// 1. Select the correct map based on the version
final activeMusicMap = isShareware ? _sharewareMusicMap : _retailMusicMap;
List<WolfLevel> allLevels = [];
int rlewTag = mapHead.getUint16(0, Endian.little);
// Select the correct music map based on the version
final activeMusicMap = isShareware ? _sharewareMusicMap : _retailMusicMap;
final episodeNames = isShareware
? _sharewareEpisodeNames
: _retailEpisodeNames;
// The game allows for up to 100 maps per file
for (int i = 0; i < 100; i++) {
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 plane1Offset = gameMaps.getUint32(mapOffset + 4, Endian.little);
@@ -259,13 +276,15 @@ abstract class WLParser {
int plane0Length = gameMaps.getUint16(mapOffset + 12, 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 = [];
for (int n = 0; n < 16; n++) {
int charCode = gameMaps.getUint8(mapOffset + 22 + n);
if (charCode == 0) break;
if (charCode == 0) break; // Stop at the null-terminator
nameBytes.add(charCode);
}
String name = ascii.decode(nameBytes);
String parsedName = ascii.decode(nameBytes);
// --- DECOMPRESS PLANES ---
final compressedWallData = gameMaps.buffer.asUint8List(
@@ -282,7 +301,7 @@ abstract class WLParser {
Uint16List carmackExpandedObjects = _expandCarmack(compressedObjectData);
List<int> flatObjectGrid = _expandRlew(carmackExpandedObjects, rlewTag);
// --- BUILD GRIDS ---
// --- BUILD 64x64 GRIDS ---
List<List<int>> wallGrid = [];
List<List<int>> objectGrid = [];
@@ -297,17 +316,14 @@ abstract class WLParser {
objectGrid.add(objectRow);
}
// Determine music track index.
// 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.
// --- ASSIGN MUSIC ---
int trackIndex = (i < activeMusicMap.length)
? activeMusicMap[i]
: activeMusicMap[i % activeMusicMap.length];
levels.add(
allLevels.add(
WolfLevel(
name: name,
name: parsedName,
wallGrid: wallGrid,
objectGrid: objectGrid,
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.

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<AdLibSound> adLibSounds;
final List<ImfMusic> music;
final List<WolfLevel> levels;
final List<VgaImage> vgaImages;
// --- 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);
final List<Episode> episodes;
const WolfensteinData({
required this.version,
@@ -22,7 +17,7 @@ class WolfensteinData {
required this.sounds,
required this.adLibSounds,
required this.music,
required this.levels,
required this.vgaImages,
required this.episodes,
});
}

View File

@@ -3,6 +3,7 @@
/// More dartdocs go here.
library;
export 'src/episode.dart' show Episode;
export 'src/game_file.dart' show GameFile;
export 'src/game_version.dart' show GameVersion;
export 'src/image.dart' show VgaImage;