import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:wolf_dart/classes/linear_coordinates.dart'; import 'package:wolf_dart/classes/matrix.dart'; import 'package:wolf_dart/features/difficulty/difficulty.dart'; import 'package:wolf_dart/features/entities/collectible.dart'; import 'package:wolf_dart/features/entities/enemies/enemy.dart'; import 'package:wolf_dart/features/entities/entity.dart'; import 'package:wolf_dart/features/entities/entity_registry.dart'; import 'package:wolf_dart/features/map/wolf_map.dart'; import 'package:wolf_dart/features/player/player.dart'; import 'package:wolf_dart/features/renderer/raycast_painter.dart'; import 'package:wolf_dart/features/renderer/weapon_painter.dart'; import 'package:wolf_dart/features/ui/hud.dart'; import 'package:wolf_dart/sprite_gallery.dart'; class WolfRenderer extends StatefulWidget { const WolfRenderer({ super.key, required this.difficulty, this.showSpriteGallery = false, this.isDemo = true, }); final Difficulty difficulty; final bool showSpriteGallery; final bool isDemo; @override State createState() => _WolfRendererState(); } class _WolfRendererState extends State with SingleTickerProviderStateMixin { late Ticker _gameLoop; final FocusNode _focusNode = FocusNode(); late WolfMap gameMap; late Matrix currentLevel; final double fov = math.pi / 3; late Player player; bool _isLoading = true; bool _spaceWasPressed = false; double damageFlashOpacity = 0.0; List entities = []; // Track door animations // Key is "X,Y" coordinate. Value is how far open it is (0.0 to 1.0) Map doorOffsets = {}; Map doorStates = {}; // 1 = opening, 2 = fully open double moveStepX = 0; double moveStepY = 0; @override void initState() { super.initState(); _initGame(demo: widget.isDemo); } Future _initGame({bool demo = true}) async { // 1. Load the entire WL1 (demo)/WL6 (retail) data gameMap = demo ? await WolfMap.loadDemo() : await WolfMap.load(); // 2. Extract Level 1 (E1M1) currentLevel = gameMap.levels[0].wallGrid; final Matrix objectLevel = gameMap.levels[0].objectGrid; // 1. SCAN FOR PLAYER SPAWN & ENTITIES for (int y = 0; y < 64; y++) { for (int x = 0; x < 64; x++) { int objId = objectLevel[y][x]; // Player Spawn if (objId >= 19 && objId <= 22) { double spawnAngle = 0.0; switch (objId) { case 19: spawnAngle = 3 * math.pi / 2; break; case 20: spawnAngle = 0.0; break; case 21: spawnAngle = math.pi / 2; break; case 22: spawnAngle = math.pi; break; } 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.level, gameMap.sprites.length, ); if (newEntity != null) { entities.add(newEntity); } } } } // 2. CLEAN UP WALLS / PRESERVE DOORS 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 { currentLevel[y][x] = 0; } } } // 4. Start the game! _bumpPlayerIfStuck(); _gameLoop = createTicker(_tick)..start(); _focusNode.requestFocus(); setState(() { _isLoading = false; }); } @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; LinearCoordinates nearestSafeSpot = (x: 1.5, y: 1.5); for (int y = 0; y < currentLevel.length; y++) { for (int x = 0; x < currentLevel[y].length; x++) { if (currentLevel[y][x] == 0) { double safeX = x + 0.5; double safeY = y + 0.5; double dist = math.sqrt( math.pow(safeX - player.x, 2) + math.pow(safeY - player.y, 2), ); if (dist < shortestDist) { shortestDist = dist; nearestSafeSpot = (x: safeX, y: safeY); } } } } player.x = nearestSafeSpot.x; player.y = nearestSafeSpot.y; } } bool _isWalkable(int x, int y) { if (currentLevel[y][x] == 0) return true; // Empty space if (currentLevel[y][x] >= 90) { String key = '$x,$y'; // Allow the player to walk through if the door is > 70% open if (doorOffsets[key] != null && doorOffsets[key]! > 0.7) { return true; } } return false; } void _tick(Duration elapsed) { const double moveSpeed = 0.12; const double turnSpeed = 0.08; // Reset steps each tick before calculation moveStepX = 0; moveStepY = 0; // 1. ANIMATE DOORS doorStates.forEach((key, state) { if (state == 1) { // If opening doorOffsets[key] = (doorOffsets[key] ?? 0.0) + 0.02; // Slide speed if (doorOffsets[key]! >= 1.0) { doorOffsets[key] = 1.0; doorStates[key] = 2; // Mark as fully open } } }); final pressedKeys = HardwareKeyboard.instance.logicalKeysPressed; if (pressedKeys.contains(LogicalKeyboardKey.keyW)) { moveStepX += math.cos(player.angle) * moveSpeed; moveStepY += math.sin(player.angle) * moveSpeed; } if (pressedKeys.contains(LogicalKeyboardKey.keyS)) { moveStepX -= math.cos(player.angle) * moveSpeed; moveStepY -= math.sin(player.angle) * moveSpeed; } if (pressedKeys.contains(LogicalKeyboardKey.keyA)) { player.angle -= turnSpeed; } if (pressedKeys.contains(LogicalKeyboardKey.keyD)) { player.angle += turnSpeed; } if (player.angle < 0) player.angle += 2 * math.pi; if (player.angle > 2 * math.pi) player.angle -= 2 * math.pi; // 2. UPDATED WALL COLLISION const double margin = 0.3; double newX = player.x + moveStepX; int checkX = (moveStepX > 0) ? (newX + margin).toInt() : (newX - margin).toInt(); if (_isWalkable(checkX, player.y.toInt())) { player.x = newX; } double newY = player.y + moveStepY; int checkY = (moveStepY > 0) ? (newY + margin).toInt() : (newY - margin).toInt(); if (_isWalkable(player.x.toInt(), checkY)) { player.y = newY; } // 3. UPDATED DOOR INTERACTION bool isSpacePressed = pressedKeys.contains(LogicalKeyboardKey.space); if (isSpacePressed && !_spaceWasPressed) { int targetX = (player.x + math.cos(player.angle)).toInt(); int targetY = (player.y + math.sin(player.angle)).toInt(); if (targetY > 0 && targetY < currentLevel.length && targetX > 0 && targetX < currentLevel[0].length) { if (currentLevel[targetY][targetX] >= 90) { String key = '$targetX,$targetY'; // Start the animation if it isn't already opening! if (!doorStates.containsKey(key) || doorStates[key] == 0) { doorStates[key] = 1; } } } } _spaceWasPressed = isSpacePressed; // --- 4. UPDATE ENTITY LOGIC --- List itemsToRemove = []; // Collect items to delete after the loop for (Entity entity in entities) { if (entity is Enemy) { entity.update( elapsedMs: elapsed.inMilliseconds, player: player.position, isWalkable: _isWalkable, onDamagePlayer: _takeDamage, ); } // NEW: Add Collectible Interaction Logic else if (entity is Collectible) { double dx = player.x - entity.x; double dy = player.y - entity.y; double dist = math.sqrt(dx * dx + dy * dy); // If player is close enough to the item if (dist < 0.5) { if (player.tryPickup(entity)) { itemsToRemove.add(entity); } } } } // Remove the items that were successfully picked up if (itemsToRemove.isNotEmpty) { setState(() { entities.removeWhere((e) => itemsToRemove.contains(e)); }); } // 5. Weapon // Update weapon animation and check for flash frame bool shouldCheckHit = player.updateWeapon(elapsed.inMilliseconds); if (shouldCheckHit) { _performRaycastAttack(elapsed); } // Input to trigger firing if (pressedKeys.contains(LogicalKeyboardKey.controlLeft) && !_spaceWasPressed) { player.fire(elapsed.inMilliseconds); } // Fade out the damage flash smoothly if (damageFlashOpacity > 0) { damageFlashOpacity = math.max(0.0, damageFlashOpacity - 0.05); } setState(() {}); } // A helper method to handle getting shot void _takeDamage(int damage) { player.takeDamage(damage); damageFlashOpacity = 0.5; } void _performRaycastAttack(Duration elapsed) { Enemy? closestEnemy; double minDistance = 15.0; // Maximum range of the gun for (Entity entity in entities) { if (entity is Enemy && entity.state != EntityState.dead) { // 1. Calculate the angle from player to enemy double dx = entity.x - player.x; double dy = entity.y - player.y; double angleToEnemy = math.atan2(dy, dx); // 2. Check if that angle is close to our player's aiming angle double angleDiff = player.angle - angleToEnemy; while (angleDiff <= -math.pi) angleDiff += 2 * math.pi; while (angleDiff > math.pi) angleDiff -= 2 * math.pi; // 3. Simple bounding box check (approx 0.4 units wide) double dist = math.sqrt(dx * dx + dy * dy); double threshold = 0.2 / dist; // Smaller threshold as they get further away if (angleDiff.abs() < threshold) { // 4. Ensure there is no wall between you and the enemy if (_hasLineOfSightToEnemy(entity, dist)) { if (dist < minDistance) { minDistance = dist; closestEnemy = entity; } } } } } if (closestEnemy != null) { closestEnemy.takeDamage( player.currentWeapon.damage, elapsed.inMilliseconds, ); // If the shot was fatal, reward the player if (closestEnemy.state == EntityState.dead) { player.score += 100; } } } bool _hasLineOfSightToEnemy(Enemy enemy, double distance) { double dirX = math.cos(player.angle); double dirY = math.sin(player.angle); for (double i = 0.5; i < distance; i += 0.2) { int checkX = (player.x + dirX * i).toInt(); int checkY = (player.y + dirY * i).toInt(); if (!_isWalkable(checkX, checkY)) return false; } return true; } @override Widget build(BuildContext context) { if (_isLoading) { return const Center(child: CircularProgressIndicator(color: Colors.teal)); } if (widget.showSpriteGallery) { return SpriteGallery(sprites: gameMap.sprites); } return Scaffold( backgroundColor: Colors.black, body: KeyboardListener( focusNode: _focusNode, autofocus: true, onKeyEvent: (_) {}, child: Column( children: [ // Game view Expanded( child: LayoutBuilder( builder: (context, constraints) { return Stack( children: [ CustomPaint( size: Size(constraints.maxWidth, constraints.maxHeight), painter: RaycasterPainter( map: currentLevel, textures: gameMap.textures, player: player, fov: fov, doorOffsets: doorOffsets, entities: entities, sprites: gameMap.sprites, ), ), // Weapon Viewmodel Positioned( bottom: -20, left: 0, right: 0, child: Center( child: Transform.translate( offset: Offset( 0, // Bobbing math: only moves if velocity is > 0 (moveStepX.abs() + moveStepY.abs()) > 0 ? math.sin( DateTime.now() .millisecondsSinceEpoch / 100, ) * 12 : 0, ), child: SizedBox( width: 500, height: 500, child: CustomPaint( painter: WeaponPainter( sprite: gameMap.sprites[player .currentWeapon .currentSprite], ), ), ), ), ), ), if (damageFlashOpacity > 0) Positioned.fill( child: Container( color: Colors.red.withValues( alpha: damageFlashOpacity, ), ), ), ], ); }, ), ), // HUD Hud(player: player), ], ), ), ); } }