diff --git a/lib/features/entities/map_objects.dart b/lib/features/entities/map_objects.dart index 7dadc8f..881dae1 100644 --- a/lib/features/entities/map_objects.dart +++ b/lib/features/entities/map_objects.dart @@ -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; diff --git a/lib/features/renderer/renderer.dart b/lib/features/renderer/renderer.dart index 1c1f055..3fe5bd4 100644 --- a/lib/features/renderer/renderer.dart +++ b/lib/features/renderer/renderer.dart @@ -54,8 +54,9 @@ class _WolfRendererState extends State double damageFlashOpacity = 0.0; - late int _currentMapIndex; - late WolfLevel _currentLevel; + late int _currentEpisodeIndex; + late int _currentLevelIndex; + int? _returnLevelIndex; List entities = []; @@ -65,52 +66,47 @@ class _WolfRendererState extends State _initGame(); } - void _loadLevel(int mapIndex) { - // Grab the specific level from the singleton - _currentLevel = Wolf3d.I.levels[mapIndex]; + Future _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 _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 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 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 } 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, diff --git a/lib/features/screens/episode_screen.dart b/lib/features/screens/episode_screen.dart index 51d9621..128b95f 100644 --- a/lib/features/screens/episode_screen.dart +++ b/lib/features/screens/episode_screen.dart @@ -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 { - final List _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 { @override Widget build(BuildContext context) { - final int numberOfEpisodes = Wolf3d.I.activeGame.numberOfEpisodes; + final List episodes = Wolf3d.I.activeGame.episodes; return Scaffold( backgroundColor: Colors.black, @@ -56,8 +48,9 @@ class _EpisodeScreenState extends State { 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 { ), onPressed: () => _selectEpisode(index), child: Text( - _episodeNames[index], + episode.name, textAlign: TextAlign.center, style: const TextStyle(fontSize: 18), ), diff --git a/lib/wolf_3d.dart b/lib/wolf_3d.dart index 672647a..a0fa8de 100644 --- a/lib/wolf_3d.dart +++ b/lib/wolf_3d.dart @@ -34,7 +34,7 @@ class Wolf3d { } // Convenience getters for the active game's assets - List get levels => activeGame.levels; + List get levels => activeGame.episodes[activeEpisode].levels; List get walls => activeGame.walls; List get sprites => activeGame.sprites; List get sounds => activeGame.sounds; diff --git a/packages/wolf_3d_data/lib/src/wl_parser.dart b/packages/wolf_3d_data/lib/src/wl_parser.dart index 19100b7..48b476c 100644 --- a/packages/wolf_3d_data/lib/src/wl_parser.dart +++ b/packages/wolf_3d_data/lib/src/wl_parser.dart @@ -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 _sharewareEpisodeNames = [ + "Episode 1\nEscape from Wolfenstein", + ]; + + static const List _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 parseMaps( + static List parseEpisodes( ByteData mapHead, ByteData gameMaps, { bool isShareware = true, }) { - List levels = []; - - // 1. Select the correct map based on the version - final activeMusicMap = isShareware ? _sharewareMusicMap : _retailMusicMap; - + List 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 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 flatObjectGrid = _expandRlew(carmackExpandedObjects, rlewTag); - // --- BUILD GRIDS --- + // --- BUILD 64x64 GRIDS --- List> wallGrid = []; List> 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 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. diff --git a/packages/wolf_3d_data_types/lib/src/episode.dart b/packages/wolf_3d_data_types/lib/src/episode.dart new file mode 100644 index 0000000..035a54b --- /dev/null +++ b/packages/wolf_3d_data_types/lib/src/episode.dart @@ -0,0 +1,8 @@ +import 'package:wolf_3d_data_types/wolf_3d_data_types.dart'; + +class Episode { + final String name; + final List levels; + + const Episode({required this.name, required this.levels}); +} diff --git a/packages/wolf_3d_data_types/lib/src/wolfenstein_data.dart b/packages/wolf_3d_data_types/lib/src/wolfenstein_data.dart index 2cc058d..1bf3bb6 100644 --- a/packages/wolf_3d_data_types/lib/src/wolfenstein_data.dart +++ b/packages/wolf_3d_data_types/lib/src/wolfenstein_data.dart @@ -7,13 +7,8 @@ class WolfensteinData { final List sounds; final List adLibSounds; final List music; - final List levels; final List 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 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, }); } diff --git a/packages/wolf_3d_data_types/lib/wolf_3d_data_types.dart b/packages/wolf_3d_data_types/lib/wolf_3d_data_types.dart index 48c59cd..2b3d11b 100644 --- a/packages/wolf_3d_data_types/lib/wolf_3d_data_types.dart +++ b/packages/wolf_3d_data_types/lib/wolf_3d_data_types.dart @@ -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;