import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.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_entities/wolf_3d_entities.dart'; import 'package:wolf_3d_input/wolf_3d_input.dart'; import 'package:wolf_3d_renderer/hud.dart'; import 'package:wolf_3d_renderer/raycast_painter.dart'; import 'package:wolf_3d_renderer/weapon_painter.dart'; class WolfRenderer extends StatefulWidget { const WolfRenderer( this.data, { required this.difficulty, required this.startingEpisode, super.key, }); final WolfensteinData data; final Difficulty difficulty; final int startingEpisode; @override State createState() => _WolfRendererState(); } class _WolfRendererState extends State with SingleTickerProviderStateMixin { final InputManager inputManager = InputManager(); final DoorManager doorManager = DoorManager(); final PushwallManager pushwallManager = PushwallManager(); late Ticker _gameLoop; final FocusNode _focusNode = FocusNode(); late Level currentLevel; late WolfLevel activeLevel; 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 entities = []; @override void initState() { super.initState(); _initGame(); } Future _initGame() async { // 1. Setup our starting indices _currentEpisodeIndex = widget.startingEpisode; _currentLevelIndex = 0; // 2. Load the first floor! _loadLevel(); _gameLoop = createTicker(_tick)..start(); _focusNode.requestFocus(); setState(() { _isLoading = false; }); } void _loadLevel() { // 1. Clean up the previous level's state entities.clear(); damageFlashOpacity = 0.0; // 2. Grab the exact level from our new Episode hierarchy final episode = widget.data.episodes[_currentEpisodeIndex]; activeLevel = episode.levels[_currentLevelIndex]; // 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); // 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 (!MapObject.shouldSpawn(objId, widget.difficulty)) continue; 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 void dispose() { _gameLoop.dispose(); _focusNode.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 itemsToRemove = []; List 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 Widget build(BuildContext context) { if (_isLoading) { return const Center(child: CircularProgressIndicator(color: Colors.teal)); } return Scaffold( backgroundColor: Colors.black, body: KeyboardListener( focusNode: _focusNode, autofocus: true, onKeyEvent: (_) {}, child: Column( children: [ Expanded( child: LayoutBuilder( builder: (context, constraints) { return Center( child: AspectRatio( aspectRatio: 16 / 10, child: Stack( children: [ CustomPaint( size: Size( constraints.maxWidth, constraints.maxHeight, ), painter: RaycasterPainter( map: currentLevel, textures: widget.data.walls, player: player, fov: fov, doorOffsets: doorManager.getOffsetsForRenderer(), entities: entities, sprites: widget.data.sprites, activePushwall: pushwallManager.activePushwall, ), ), Positioned( bottom: -20, left: 0, right: 0, child: Center( child: Transform.translate( offset: Offset(0, player.weaponAnimOffset), child: SizedBox( width: 500, height: 500, child: CustomPaint( painter: WeaponPainter( sprite: widget.data.sprites[player .currentWeapon .getCurrentSpriteIndex( widget.data.sprites.length, )], ), ), ), ), ), ), if (damageFlashOpacity > 0) Positioned.fill( child: Container( color: Colors.red.withValues( alpha: damageFlashOpacity, ), ), ), ], ), ), ); }, ), ), Hud(player: player), ], ), ), ); } }