From 001c7c3131adb0b5aa34a90d75bc77543551ed26 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Sat, 14 Mar 2026 15:34:27 +0100 Subject: [PATCH] Can now open secret walls and pick up machine gun Signed-off-by: Hans Kokx --- lib/features/entities/door_manager.dart | 11 ++ .../entities/enemies/brown_guard.dart | 42 ++++-- lib/features/entities/enemies/dog.dart | 85 ++++++++---- lib/features/entities/enemies/enemy.dart | 111 +++++++++++---- lib/features/entities/entity.dart | 36 +++++ lib/features/entities/entity_registry.dart | 9 +- lib/features/entities/pushwall_manager.dart | 121 ++++++++++++++++ lib/features/player/player.dart | 37 ++++- lib/features/renderer/raycast_painter.dart | 130 ++++++++++++++---- lib/features/renderer/renderer.dart | 64 ++++++--- lib/features/weapon/weapon.dart | 17 ++- lib/features/weapon/weapons/knife.dart | 1 + lib/features/weapon/weapons/pistol.dart | 1 + 13 files changed, 545 insertions(+), 120 deletions(-) create mode 100644 lib/features/entities/pushwall_manager.dart diff --git a/lib/features/entities/door_manager.dart b/lib/features/entities/door_manager.dart index 0dcdc05..10a3537 100644 --- a/lib/features/entities/door_manager.dart +++ b/lib/features/entities/door_manager.dart @@ -46,9 +46,20 @@ class DoorManager { return offsets; } + void tryOpenDoor(int x, int y) { + String key = '$x,$y'; + if (doors.containsKey(key)) { + // If it's closed or closing, interact() will usually start it opening + if (doors[key]!.offset == 0.0) { + doors[key]!.interact(); + } + } + } + bool isDoorOpenEnough(int x, int y) { String key = '$x,$y'; if (doors.containsKey(key)) { + // 0.7 offset means 70% open, similar to the original engine's check return doors[key]!.offset > 0.7; } return false; // Not a door we manage diff --git a/lib/features/entities/enemies/brown_guard.dart b/lib/features/entities/enemies/brown_guard.dart index b1afb51..8425a8c 100644 --- a/lib/features/entities/enemies/brown_guard.dart +++ b/lib/features/entities/enemies/brown_guard.dart @@ -58,18 +58,26 @@ class BrownGuard extends Enemy { required Coordinate2D playerPosition, required bool Function(int x, int y) isWalkable, required void Function(int damage) onDamagePlayer, + required void Function(int x, int y) tryOpenDoor, }) { Coordinate2D movement = const Coordinate2D(0, 0); double newAngle = angle; - // 1. Wake up logic + // 1. Wake up logic (Matches SightPlayer & FirstSighting) if (state == EntityState.idle && hasLineOfSight(playerPosition, isWalkable)) { - state = EntityState.patrolling; - lastActionTime = elapsedMs; + if (reactionTimeMs == 0) { + // Init reaction delay: ~1 to 4 tics in C (1 tic = ~14ms, but plays out longer in engine ticks). + // Let's approximate human-feeling reaction time: 200ms - 800ms + reactionTimeMs = elapsedMs + 200 + math.Random().nextInt(600); + } else if (elapsedMs >= reactionTimeMs) { + state = + EntityState.patrolling; // Equivalent to FirstSighting chase frame + lastActionTime = elapsedMs; + reactionTimeMs = 0; // Reset + } } - // 2. Pre-calculate spatial relations double distance = position.distanceTo(playerPosition); double angleToPlayer = position.angleTo(playerPosition); @@ -77,7 +85,7 @@ class BrownGuard extends Enemy { newAngle = angleToPlayer; } - // Calculate Octant for sprite direction + // Octant logic remains the same double diff = newAngle - angleToPlayer; while (diff <= -math.pi) { diff += 2 * math.pi; @@ -85,6 +93,7 @@ class BrownGuard extends Enemy { while (diff > math.pi) { diff -= 2 * math.pi; } + int octant = ((diff + (math.pi / 8)) / (math.pi / 4)).floor() % 8; if (octant < 0) octant += 8; @@ -96,22 +105,34 @@ class BrownGuard extends Enemy { case EntityState.patrolling: if (distance > 0.8) { - // Calculate movement intent - movement = - Coordinate2D(math.cos(newAngle), math.sin(newAngle)) * speed; + // Jitter fix: Use continuous vector movement instead of single-axis snapping + double moveX = math.cos(angleToPlayer) * speed; + double moveY = math.sin(angleToPlayer) * speed; + + Coordinate2D intendedMovement = Coordinate2D(moveX, moveY); + + // Pass tryOpenDoor down! + movement = getValidMovement( + intendedMovement, + isWalkable, + tryOpenDoor, + ); } + // Animation fix: Update the sprite so he actually turns and walks! int walkFrame = (elapsedMs ~/ 150) % 4; spriteIndex = 58 + (walkFrame * 8) + octant; - if (distance < 5.0 && elapsedMs - lastActionTime > 2000) { + // Shooting fix: Give him permission to stop and shoot you + // (1500ms delay between shots) + if (distance < 6.0 && elapsedMs - lastActionTime > 1500) { if (hasLineOfSight(playerPosition, isWalkable)) { state = EntityState.shooting; lastActionTime = elapsedMs; _hasFiredThisCycle = false; } } - break; + break; // Fallthrough fix: Don't forget the break! case EntityState.shooting: int timeShooting = elapsedMs - lastActionTime; @@ -152,6 +173,7 @@ class BrownGuard extends Enemy { spriteIndex = 95; } break; + default: break; } diff --git a/lib/features/entities/enemies/dog.dart b/lib/features/entities/enemies/dog.dart index 6008442..8877a6d 100644 --- a/lib/features/entities/enemies/dog.dart +++ b/lib/features/entities/enemies/dog.dart @@ -51,21 +51,28 @@ class Dog extends Enemy { required int elapsedMs, required Coordinate2D playerPosition, required bool Function(int x, int y) isWalkable, + required void Function(int x, int y) tryOpenDoor, // NEW required void Function(int damage) onDamagePlayer, }) { Coordinate2D movement = const Coordinate2D(0, 0); double newAngle = angle; + // 1. Wake up logic if (state == EntityState.idle && hasLineOfSight(playerPosition, isWalkable)) { - state = EntityState.patrolling; - lastActionTime = elapsedMs; + if (reactionTimeMs == 0) { + reactionTimeMs = elapsedMs + 100 + math.Random().nextInt(200); + } else if (elapsedMs >= reactionTimeMs) { + state = EntityState.patrolling; + lastActionTime = elapsedMs; + reactionTimeMs = 0; + } } double distance = position.distanceTo(playerPosition); double angleToPlayer = position.angleTo(playerPosition); - if (state == EntityState.patrolling || state == EntityState.shooting) { + if (state != EntityState.idle && state != EntityState.dead) { newAngle = angleToPlayer; } @@ -76,36 +83,60 @@ class Dog extends Enemy { while (diff > math.pi) { diff -= 2 * math.pi; } + int octant = ((diff + (math.pi / 8)) / (math.pi / 4)).floor() % 8; if (octant < 0) octant += 8; - if (state == EntityState.idle) { - spriteIndex = 99 + octant; - } else if (state == EntityState.patrolling) { - if (distance > 0.8) { - movement = Coordinate2D(math.cos(newAngle), math.sin(newAngle)) * speed; - } + // 3. Clean State Machine + switch (state) { + case EntityState.idle: + spriteIndex = 99 + octant; + break; - int walkFrame = (elapsedMs ~/ 100) % 4; - spriteIndex = 107 + (walkFrame * 8) + octant; + case EntityState.patrolling: + if (distance > 0.8) { + double deltaX = playerPosition.x - position.x; + double deltaY = playerPosition.y - position.y; - if (distance < 1.0 && elapsedMs - lastActionTime > 1000) { - state = EntityState.shooting; - lastActionTime = elapsedMs; - _hasBittenThisCycle = false; - } - } else if (state == EntityState.shooting) { - int timeAttacking = elapsedMs - lastActionTime; - if (timeAttacking < 200) { - spriteIndex = 139; - if (!_hasBittenThisCycle) { - onDamagePlayer(5); // DOG BITE - _hasBittenThisCycle = true; + double moveX = deltaX > 0 ? speed : (deltaX < 0 ? -speed : 0); + double moveY = deltaY > 0 ? speed : (deltaY < 0 ? -speed : 0); + + Coordinate2D intendedMovement = Coordinate2D(moveX, moveY); + + // Pass tryOpenDoor down! + movement = getValidMovement( + intendedMovement, + isWalkable, + tryOpenDoor, + ); } - } else { - state = EntityState.patrolling; - lastActionTime = elapsedMs; - } + + int walkFrame = (elapsedMs ~/ 100) % 4; + spriteIndex = 107 + (walkFrame * 8) + octant; + + if (distance < 1.0 && elapsedMs - lastActionTime > 1000) { + state = EntityState.shooting; + lastActionTime = elapsedMs; + _hasBittenThisCycle = false; + } + break; + + case EntityState.shooting: + int timeAttacking = elapsedMs - lastActionTime; + if (timeAttacking < 200) { + spriteIndex = 139; + if (!_hasBittenThisCycle) { + onDamagePlayer(5); + _hasBittenThisCycle = true; + } + } else { + state = EntityState.patrolling; + lastActionTime = elapsedMs; + } + break; + + default: + break; } return (movement: movement, newAngle: newAngle); diff --git a/lib/features/entities/enemies/enemy.dart b/lib/features/entities/enemies/enemy.dart index 3c11218..fc914fe 100644 --- a/lib/features/entities/enemies/enemy.dart +++ b/lib/features/entities/enemies/enemy.dart @@ -14,21 +14,22 @@ abstract class Enemy extends Entity { super.lastActionTime, }); - // Standard guard health int health = 25; int damage = 10; bool isDying = false; + bool hasDroppedItem = false; + + // Replaces ob->temp2 for reaction delays + int reactionTimeMs = 0; void takeDamage(int amount, int currentTime) { if (state == EntityState.dead) return; health -= amount; - // Mark the start of the death/pain lastActionTime = currentTime; if (health <= 0) { state = EntityState.dead; - // This triggers the dying animation isDying = true; } else if (math.Random().nextDouble() < 0.5) { state = EntityState.pain; @@ -37,12 +38,9 @@ abstract class Enemy extends Entity { } } - // Decodes the Map ID to figure out which way the enemy is facing static double getInitialAngle(int objId) { int normalizedId = (objId - 108) % 36; - // 0=East, 1=North, 2=West, 3=South int direction = normalizedId % 4; - switch (direction) { case 0: return 0.0; @@ -57,14 +55,19 @@ abstract class Enemy extends Entity { } } - // The enemy can now check its own line of sight! + // Matches WL_STATE.C's 'CheckLine' using canonical Integer DDA traversal bool hasLineOfSight( Coordinate2D playerPosition, bool Function(int x, int y) isWalkable, ) { - double distance = position.distanceTo(playerPosition); + // 1. Proximity Check (Matches WL_STATE.C 'MINSIGHT') + // If the player is very close, sight is automatic regardless of facing angle. + // This compensates for our lack of a noise/gunshot alert system! + if (position.distanceTo(playerPosition) < 2.0) { + return true; + } - // 1. FOV Check + // 2. FOV Check (Matches original sight angles) double angleToPlayer = position.angleTo(playerPosition); double diff = angle - angleToPlayer; @@ -77,40 +80,92 @@ abstract class Enemy extends Entity { if (diff.abs() > math.pi / 2) return false; - // 2. Map Check - Coordinate2D dir = (playerPosition - position).normalized; - double stepSize = 0.2; + // 3. Map Check (Corrected Integer Bresenham) + int currentX = position.x.toInt(); + int currentY = position.y.toInt(); + int targetX = playerPosition.x.toInt(); + int targetY = playerPosition.y.toInt(); - for (double i = 0; i < distance; i += stepSize) { - Coordinate2D checkPos = position + (dir * i); - if (!isWalkable(checkPos.x.toInt(), checkPos.y.toInt())) return false; + int dx = (targetX - currentX).abs(); + int dy = -(targetY - currentY).abs(); + int sx = currentX < targetX ? 1 : -1; + int sy = currentY < targetY ? 1 : -1; + int err = dx + dy; + + while (true) { + if (!isWalkable(currentX, currentY)) return false; + if (currentX == targetX && currentY == targetY) break; + + int e2 = 2 * err; + if (e2 >= dy) { + err += dy; + currentX += sx; + } + if (e2 <= dx) { + err += dx; + currentY += sy; + } } - return true; } - // The weapon asks the enemy if it is unobstructed from the shooter - bool hasLineOfSightFrom( - Coordinate2D source, - double sourceAngle, - double distance, + Coordinate2D getValidMovement( + Coordinate2D intendedMovement, bool Function(int x, int y) isWalkable, + void Function(int x, int y) tryOpenDoor, ) { - double dirX = math.cos(sourceAngle); - double dirY = math.sin(sourceAngle); + double newX = position.x + intendedMovement.x; + double newY = position.y + intendedMovement.y; - for (double i = 0.5; i < distance; i += 0.2) { - int checkX = (source.x + dirX * i).toInt(); - int checkY = (source.y + dirY * i).toInt(); - if (!isWalkable(checkX, checkY)) return false; + int currentTileX = position.x.toInt(); + int currentTileY = position.y.toInt(); + int targetTileX = newX.toInt(); + int targetTileY = newY.toInt(); + + bool movedX = currentTileX != targetTileX; + bool movedY = currentTileY != targetTileY; + + // 1. Check Diagonal Movement + if (movedX && movedY) { + bool canMoveX = isWalkable(targetTileX, currentTileY); + bool canMoveY = isWalkable(currentTileX, targetTileY); + bool canMoveDiag = isWalkable(targetTileX, targetTileY); + + if (!canMoveX || !canMoveY || !canMoveDiag) { + // Trigger doors if they are blocking the path + if (!canMoveX) tryOpenDoor(targetTileX, currentTileY); + if (!canMoveY) tryOpenDoor(currentTileX, targetTileY); + if (!canMoveDiag) tryOpenDoor(targetTileX, targetTileY); + + if (canMoveX) return Coordinate2D(intendedMovement.x, 0); + if (canMoveY) return Coordinate2D(0, intendedMovement.y); + return const Coordinate2D(0, 0); + } } - return true; + + // 2. Check Cardinal Movement + if (movedX && !movedY) { + if (!isWalkable(targetTileX, currentTileY)) { + tryOpenDoor(targetTileX, currentTileY); // Try to open! + return Coordinate2D(0, intendedMovement.y); + } + } + if (movedY && !movedX) { + if (!isWalkable(currentTileX, targetTileY)) { + tryOpenDoor(currentTileX, targetTileY); // Try to open! + return Coordinate2D(intendedMovement.x, 0); + } + } + + return intendedMovement; } + // Updated Signature ({Coordinate2D movement, double newAngle}) update({ required int elapsedMs, required Coordinate2D playerPosition, required bool Function(int x, int y) isWalkable, + required void Function(int x, int y) tryOpenDoor, // NEW required void Function(int damage) onDamagePlayer, }); } diff --git a/lib/features/entities/entity.dart b/lib/features/entities/entity.dart index 16a8afd..f5f9e04 100644 --- a/lib/features/entities/entity.dart +++ b/lib/features/entities/entity.dart @@ -27,4 +27,40 @@ abstract class Entity { } Coordinate2D get position => Coordinate2D(x, y); + + // NEW: Checks if a projectile or sightline from 'source' can reach this entity + bool hasLineOfSightFrom( + Coordinate2D source, + double sourceAngle, + double distance, + bool Function(int x, int y) isWalkable, + ) { + // Corrected Integer Bresenham Algorithm + int currentX = source.x.toInt(); + int currentY = source.y.toInt(); + int targetX = x.toInt(); + int targetY = y.toInt(); + + int dx = (targetX - currentX).abs(); + int dy = -(targetY - currentY).abs(); + int sx = currentX < targetX ? 1 : -1; + int sy = currentY < targetY ? 1 : -1; + int err = dx + dy; + + while (true) { + if (!isWalkable(currentX, currentY)) return false; + if (currentX == targetX && currentY == targetY) break; + + int e2 = 2 * err; + if (e2 >= dy) { + err += dy; + currentX += sx; + } + if (e2 <= dx) { + err += dx; + currentY += sy; + } + } + return true; + } } diff --git a/lib/features/entities/entity_registry.dart b/lib/features/entities/entity_registry.dart index 94f8c56..12e5ecf 100644 --- a/lib/features/entities/entity_registry.dart +++ b/lib/features/entities/entity_registry.dart @@ -13,12 +13,11 @@ typedef EntitySpawner = ); abstract class EntityRegistry { - // Add future enemies (SSGuard, Dog, etc.) to this list! static final List _spawners = [ - Collectible.trySpawn, // Check collectibles - Decorative.trySpawn, // Then check decorations - BrownGuard.trySpawn, // Then check guards - Dog.trySpawn, // Then check dogs + BrownGuard.trySpawn, + Dog.trySpawn, + Collectible.trySpawn, + Decorative.trySpawn, ]; static Entity? spawn( diff --git a/lib/features/entities/pushwall_manager.dart b/lib/features/entities/pushwall_manager.dart new file mode 100644 index 0000000..278b7a3 --- /dev/null +++ b/lib/features/entities/pushwall_manager.dart @@ -0,0 +1,121 @@ +import 'dart:math' as math; + +import 'package:wolf_dart/classes/matrix.dart'; + +class Pushwall { + int x; + int y; + int mapId; // The wall texture ID + int dirX = 0; + int dirY = 0; + double offset = 0.0; + int tilesMoved = 0; + + Pushwall(this.x, this.y, this.mapId); +} + +class PushwallManager { + final Map pushwalls = {}; + Pushwall? activePushwall; + + void initPushwalls(Matrix wallGrid, Matrix objectGrid) { + pushwalls.clear(); + activePushwall = null; + + for (int y = 0; y < objectGrid.length; y++) { + for (int x = 0; x < objectGrid[y].length; x++) { + // Map ID 98 in the object grid marks a pushwall! + if (objectGrid[y][x] == 98) { + pushwalls['$x,$y'] = Pushwall(x, y, wallGrid[y][x]); + } + } + } + } + + void update(Duration elapsed, Matrix wallGrid) { + if (activePushwall == null) return; + final pw = activePushwall!; + + // Original logic: 1/128 tile per tick. + // At 70 ticks/sec, that is roughly 0.54 tiles per second. + const double originalSpeed = 0.546875; + pw.offset += (elapsed.inMilliseconds / 1000.0) * originalSpeed; + + // Once it crosses a full tile boundary, we update the collision grid! + if (pw.offset >= 1.0) { + pw.offset -= 1.0; + pw.tilesMoved++; + + int nextX = pw.x + pw.dirX; + int nextY = pw.y + pw.dirY; + + // Move the solid block in the physical grid + wallGrid[nextY][nextX] = pw.mapId; + wallGrid[pw.y][pw.x] = 0; // Clear the old space so the player can walk in + + // Update the dictionary key + pushwalls.remove('${pw.x},${pw.y}'); + pw.x = nextX; + pw.y = nextY; + pushwalls['${pw.x},${pw.y}'] = pw; + + // Check if we should keep sliding + bool blocked = false; + int checkX = pw.x + pw.dirX; + int checkY = pw.y + pw.dirY; + + if (checkX < 0 || + checkX >= wallGrid[0].length || + checkY < 0 || + checkY >= wallGrid.length) { + blocked = true; + } else if (wallGrid[checkY][checkX] != 0) { + blocked = true; // Blocked by another wall or a door + } + + // Standard Wolf3D pushwalls move exactly 2 tiles (or 1 if blocked) + if (pw.tilesMoved >= 2 || blocked) { + activePushwall = null; + pw.offset = 0.0; + } + } + } + + void handleInteraction( + double playerX, + double playerY, + double playerAngle, + Matrix wallGrid, + ) { + // Only one pushwall can move at a time in the original engine! + if (activePushwall != null) return; + + int targetX = (playerX + math.cos(playerAngle)).toInt(); + int targetY = (playerY + math.sin(playerAngle)).toInt(); + + String key = '$targetX,$targetY'; + if (pushwalls.containsKey(key)) { + final pw = pushwalls[key]!; + + // Determine the push direction based on the player's relative position + double dx = (targetX + 0.5) - playerX; + double dy = (targetY + 0.5) - playerY; + + if (dx.abs() > dy.abs()) { + pw.dirX = dx > 0 ? 1 : -1; + pw.dirY = 0; + } else { + pw.dirX = 0; + pw.dirY = dy > 0 ? 1 : -1; + } + + // Make sure the tile behind the wall is empty before starting the push + int checkX = targetX + pw.dirX; + int checkY = targetY + pw.dirY; + + if (wallGrid[checkY][checkX] == 0) { + activePushwall = pw; + } + } + } +} diff --git a/lib/features/player/player.dart b/lib/features/player/player.dart index 7a4fdee..920d5bb 100644 --- a/lib/features/player/player.dart +++ b/lib/features/player/player.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'package:wolf_dart/classes/coordinate_2d.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/weapon/weapon.dart'; import 'package:wolf_dart/features/weapon/weapons/chain_gun.dart'; @@ -162,15 +163,20 @@ class Player { case CollectibleType.weapon: if (item.mapId == 50) { - if (!weapons.containsKey(WeaponType.machineGun)) { + // Machine Gun Pickup + if (weapons[WeaponType.machineGun] == null) { weapons[WeaponType.machineGun] = MachineGun(); + hasMachineGun = true; } + // The original game ALWAYS switches to a superior weapon on pickup requestWeaponSwitch(WeaponType.machineGun); pickedUp = true; } if (item.mapId == 51) { - if (!weapons.containsKey(WeaponType.chainGun)) { + // Chain Gun Pickup + if (weapons[WeaponType.chainGun] == null) { weapons[WeaponType.chainGun] = ChainGun(); + hasChainGun = true; } requestWeaponSwitch(WeaponType.chainGun); pickedUp = true; @@ -199,6 +205,10 @@ class Player { } } + void releaseTrigger() { + currentWeapon.releaseTrigger(); + } + /// Returns true only on the specific frame where the hit should be calculated void updateWeapon({ required int currentTime, @@ -212,7 +222,6 @@ class Player { if (currentWeapon.state == WeaponState.firing && oldFrame == 0 && currentWeapon.frameIndex == 1) { - // The weapon handles everything itself! currentWeapon.performHitscan( playerX: x, playerY: y, @@ -220,8 +229,28 @@ class Player { entities: entities, isWalkable: isWalkable, currentTime: currentTime, - onEnemyKilled: (int pointsToAdd) { + onEnemyKilled: (Enemy killedEnemy) { + // Dynamic scoring based on the enemy type! + int pointsToAdd = 0; + + switch (killedEnemy.runtimeType.toString()) { + case 'BrownGuard': + pointsToAdd = 100; + break; + case 'Dog': + pointsToAdd = 200; + break; + // You can easily plug in future enemies here! + // case 'SSOfficer': pointsToAdd = 500; break; + default: + pointsToAdd = 100; // Fallback + } + score += pointsToAdd; + // Optional: Print to console so you can see it working + print( + "Killed ${killedEnemy.runtimeType}! +$pointsToAdd (Score: $score)", + ); }, ); } diff --git a/lib/features/renderer/raycast_painter.dart b/lib/features/renderer/raycast_painter.dart index 7063b66..4ae4009 100644 --- a/lib/features/renderer/raycast_painter.dart +++ b/lib/features/renderer/raycast_painter.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:wolf_dart/classes/coordinate_2d.dart'; import 'package:wolf_dart/classes/matrix.dart'; import 'package:wolf_dart/features/entities/entity.dart'; +import 'package:wolf_dart/features/entities/pushwall_manager.dart'; // NEW IMPORT import 'package:wolf_dart/features/player/player.dart'; import 'package:wolf_dart/features/renderer/color_palette.dart'; @@ -13,6 +14,7 @@ class RaycasterPainter extends CustomPainter { final Player player; final double fov; final Map doorOffsets; + final Pushwall? activePushwall; // NEW final List> sprites; final List entities; @@ -22,6 +24,7 @@ class RaycasterPainter extends CustomPainter { required this.player, required this.fov, required this.doorOffsets, + this.activePushwall, // NEW required this.sprites, required this.entities, }); @@ -49,13 +52,11 @@ class RaycasterPainter extends CustomPainter { List zBuffer = List.filled(renderWidth, 0.0); - // --- Coordinate2D Camera Vectors --- Coordinate2D dir = Coordinate2D( math.cos(player.angle), math.sin(player.angle), ); - // The camera plane is perpendicular to the direction vector, scaled by FOV Coordinate2D plane = Coordinate2D(-dir.y, dir.x) * math.tan(fov / 2); // --- 1. CAST WALLS --- @@ -70,7 +71,7 @@ class RaycasterPainter extends CustomPainter { double sideDistY; double deltaDistX = (rayDir.x == 0) ? 1e30 : (1.0 / rayDir.x).abs(); double deltaDistY = (rayDir.y == 0) ? 1e30 : (1.0 / rayDir.y).abs(); - double perpWallDist; + double perpWallDist = 0.0; int stepX; int stepY; @@ -78,7 +79,9 @@ class RaycasterPainter extends CustomPainter { bool hitOutOfBounds = false; int side = 0; int hitWallId = 0; - double doorOffset = 0.0; + + double textureOffset = 0.0; // Replaces doorOffset to handle both + bool customDistCalculated = false; // Flag to skip standard distance Set ignoredDoors = {}; if (rayDir.x < 0) { @@ -115,9 +118,11 @@ class RaycasterPainter extends CustomPainter { hit = true; hitOutOfBounds = true; } else if (map[mapY][mapX] > 0) { - String doorKey = '$mapX,$mapY'; - if (map[mapY][mapX] >= 90 && !ignoredDoors.contains(doorKey)) { - double currentOffset = doorOffsets[doorKey] ?? 0.0; + String mapKey = '$mapX,$mapY'; + + // --- DOOR LOGIC --- + if (map[mapY][mapX] >= 90 && !ignoredDoors.contains(mapKey)) { + double currentOffset = doorOffsets[mapKey] ?? 0.0; if (currentOffset > 0.0) { double perpWallDistTemp = (side == 0) ? (sideDistX - deltaDistX) @@ -127,23 +132,98 @@ class RaycasterPainter extends CustomPainter { : player.x + perpWallDistTemp * rayDir.x; wallXTemp -= wallXTemp.floor(); if (wallXTemp < currentOffset) { - ignoredDoors.add(doorKey); - continue; + ignoredDoors.add(mapKey); + continue; // Ray passed through the open door gap } } - doorOffset = currentOffset; + hit = true; + hitWallId = map[mapY][mapX]; + textureOffset = currentOffset; + } + // --- PUSHWALL LOGIC --- + else if (activePushwall != null && + mapX == activePushwall!.x && + mapY == activePushwall!.y) { + hit = true; + hitWallId = map[mapY][mapX]; + + double pOffset = activePushwall!.offset; + int pDirX = activePushwall!.dirX; + int pDirY = activePushwall!.dirY; + + perpWallDist = (side == 0) + ? (sideDistX - deltaDistX) + : (sideDistY - deltaDistY); + + // Did we hit the face that is being pushed deeper? + if (side == 0 && pDirX != 0) { + if (pDirX == stepX) { + double intersect = perpWallDist + pOffset * deltaDistX; + if (intersect < sideDistY) { + perpWallDist = intersect; // Hit the recessed front face + } else { + side = + 1; // Missed the front face, hit the newly exposed side! + perpWallDist = sideDistY - deltaDistY; + } + } else { + perpWallDist -= (1.0 - pOffset) * deltaDistX; + } + } else if (side == 1 && pDirY != 0) { + if (pDirY == stepY) { + double intersect = perpWallDist + pOffset * deltaDistY; + if (intersect < sideDistX) { + perpWallDist = intersect; + } else { + side = 0; + perpWallDist = sideDistX - deltaDistX; + } + } else { + perpWallDist -= (1.0 - pOffset) * deltaDistY; + } + } else { + // We hit the side of the sliding block. Did the ray slip behind it? + double wallFraction = (side == 0) + ? player.y + perpWallDist * rayDir.y + : player.x + perpWallDist * rayDir.x; + wallFraction -= wallFraction.floor(); + + if (side == 0) { + if (pDirY == 1 && wallFraction < pOffset) hit = false; + if (pDirY == -1 && wallFraction > (1.0 - pOffset)) hit = false; + if (hit) { + textureOffset = + pOffset * pDirY; // Stick the texture to the block + } + } else { + if (pDirX == 1 && wallFraction < pOffset) hit = false; + if (pDirX == -1 && wallFraction > (1.0 - pOffset)) hit = false; + if (hit) { + textureOffset = + pOffset * pDirX; // Stick the texture to the block + } + } + } + if (!hit) continue; // The ray slipped past! Keep looping. + customDistCalculated = true; // Lock in our custom distance math + } + // --- STANDARD WALL --- + else { + hit = true; + hitWallId = map[mapY][mapX]; } - hit = true; - hitWallId = map[mapY][mapX]; } } if (hitOutOfBounds) continue; - if (side == 0) { - perpWallDist = (sideDistX - deltaDistX); - } else { - perpWallDist = (sideDistY - deltaDistY); + // Apply standard math ONLY if we didn't calculate a sub-tile pushwall distance + if (!customDistCalculated) { + if (side == 0) { + perpWallDist = (sideDistX - deltaDistX); + } else { + perpWallDist = (sideDistY - deltaDistY); + } } zBuffer[x] = perpWallDist; @@ -164,15 +244,15 @@ class RaycasterPainter extends CustomPainter { size, hitWallId, textures, - doorOffset, + textureOffset, columnPaint, ); } // --- 2. DRAW SPRITES --- + // (Keep your existing sprite rendering logic exactly the same) List activeSprites = List.from(entities); - // Sort sprites from furthest to closest using Coordinate2D activeSprites.sort((a, b) { double distA = player.position.distanceTo(a.position); double distB = player.position.distanceTo(b.position); @@ -180,10 +260,8 @@ class RaycasterPainter extends CustomPainter { }); for (Entity entity in activeSprites) { - // Relative position to player Coordinate2D spritePos = entity.position - player.position; - // Transform sprite with the inverse camera matrix double invDet = 1.0 / (plane.x * dir.y - dir.x * plane.y); double transformX = invDet * (dir.y * spritePos.x - dir.x * spritePos.y); double transformY = @@ -245,7 +323,7 @@ class RaycasterPainter extends CustomPainter { Size size, int hitWallId, List> textures, - double doorOffset, + double textureOffset, Paint paint, ) { if (distance <= 0.01) distance = 0.01; @@ -253,15 +331,19 @@ class RaycasterPainter extends CustomPainter { double wallHeight = size.height / distance; int drawStart = ((size.height / 2) - (wallHeight / 2)).toInt(); - int texNum = ((hitWallId - 1) * 2).clamp(0, textures.length - 2); - int texX = (wallX * 64).toInt().clamp(0, 63); + int texNum; + int texX; if (hitWallId >= 90) { + // DOORS texNum = 98.clamp(0, textures.length - 1); - texX = ((wallX - doorOffset) * 64).toInt().clamp(0, 63); + texX = ((wallX - textureOffset) * 64).toInt().clamp(0, 63); } else { + // WALLS & PUSHWALLS texNum = ((hitWallId - 1) * 2).clamp(0, textures.length - 2); if (side == 1) texNum += 1; + // We apply the modulo % 1.0 to handle negative texture offsets smoothly! + texX = (((wallX - textureOffset) % 1.0) * 64).toInt().clamp(0, 63); } if (side == 0 && math.cos(player.angle) > 0) texX = 63 - texX; diff --git a/lib/features/renderer/renderer.dart b/lib/features/renderer/renderer.dart index f2a2af8..d12f336 100644 --- a/lib/features/renderer/renderer.dart +++ b/lib/features/renderer/renderer.dart @@ -10,6 +10,7 @@ import 'package:wolf_dart/features/entities/door_manager.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/entities/pushwall_manager.dart'; import 'package:wolf_dart/features/input/input_manager.dart'; import 'package:wolf_dart/features/map/wolf_map.dart'; import 'package:wolf_dart/features/player/player.dart'; @@ -38,6 +39,7 @@ 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(); @@ -68,6 +70,8 @@ class _WolfRendererState extends State final Matrix objectLevel = gameMap.levels[0].objectGrid; + pushwallManager.initPushwalls(currentLevel, objectLevel); + for (int y = 0; y < 64; y++) { for (int x = 0; x < 64; x++) { int objId = objectLevel[y][x]; @@ -180,6 +184,7 @@ class _WolfRendererState extends State final inputResult = _processInputs(elapsed); doorManager.update(elapsed); + pushwallManager.update(elapsed, currentLevel); // 2. Explicit State Updates player.updateWeaponSwitch(); @@ -214,11 +219,6 @@ class _WolfRendererState extends State setState(() {}); } - void _takeDamage(int damage) { - player.takeDamage(damage); - damageFlashOpacity = 0.5; - } - // Returns a Record containing both movement delta and rotation delta ({Coordinate2D movement, double dAngle}) _processInputs(Duration elapsed) { inputManager.update(); @@ -235,6 +235,8 @@ class _WolfRendererState extends State if (inputManager.isFiring) { player.fire(elapsed.inMilliseconds); + } else { + player.releaseTrigger(); } // Calculate intended rotation @@ -260,6 +262,12 @@ class _WolfRendererState extends State player.y, player.angle, ); + pushwallManager.handleInteraction( + player.x, + player.y, + player.angle, + currentLevel, + ); } return (movement: movement, dAngle: dAngle); @@ -301,37 +309,50 @@ class _WolfRendererState extends State return Coordinate2D(newX, newY); } - // renderer.dart 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 + // 1. Get Intent (Now passing tryOpenDoor!) final intent = entity.update( elapsedMs: elapsed.inMilliseconds, playerPosition: player.position, isWalkable: _isWalkable, - onDamagePlayer: _takeDamage, + tryOpenDoor: doorManager.tryOpenDoor, + onDamagePlayer: (int damage) { + player.takeDamage(damage); + damageFlashOpacity = 0.5; + }, ); // 2. Update Angle entity.angle = intent.newAngle; - // 3. Resolve Movement & Collision - // We reuse the same logic we used for the player! - Coordinate2D validatedPos = _calculateValidatedPosition( - entity.position, - intent.movement, - ); + // 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; - entity.position = validatedPos; + // 4. Handle Item Drops & Score (Matches KillActor in C code) + // Check if they just died this exact frame + if (entity.state == EntityState.dead && + entity.isDying && + !entity.hasDroppedItem) { + entity.hasDroppedItem = true; // Make sure we only drop once! - // 4. Handle Attacking (if the enemy logic decides to) - // You can move 'onDamagePlayer' calls into the enemy's - // internal state check here if preferred. + // 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) { - // Collectible pickup logic remains the same if (player.position.distanceTo(entity.position) < 0.5) { if (player.tryPickup(entity)) { itemsToRemove.add(entity); @@ -340,9 +361,13 @@ class _WolfRendererState extends State } } + // 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 @@ -392,6 +417,7 @@ class _WolfRendererState extends State doorOffsets: doorManager.getOffsetsForRenderer(), entities: entities, sprites: gameMap.sprites, + activePushwall: pushwallManager.activePushwall, ), ), Positioned( diff --git a/lib/features/weapon/weapon.dart b/lib/features/weapon/weapon.dart index 200990f..df179fb 100644 --- a/lib/features/weapon/weapon.dart +++ b/lib/features/weapon/weapon.dart @@ -14,10 +14,12 @@ abstract class Weapon { final List fireFrames; final int damage; final int msPerFrame; + final bool isAutomatic; WeaponState state = WeaponState.idle; int frameIndex = 0; int lastFrameTime = 0; + bool _triggerReleased = true; Weapon({ required this.type, @@ -25,16 +27,24 @@ abstract class Weapon { required this.fireFrames, required this.damage, this.msPerFrame = 100, + this.isAutomatic = true, }); int get currentSprite => state == WeaponState.idle ? idleSprite : fireFrames[frameIndex]; + void releaseTrigger() { + _triggerReleased = true; + } + bool fire(int currentTime, {required int currentAmmo}) { if (state == WeaponState.idle && currentAmmo > 0) { + if (!isAutomatic && !_triggerReleased) return false; + state = WeaponState.firing; frameIndex = 0; lastFrameTime = currentTime; + _triggerReleased = false; return true; } return false; @@ -61,7 +71,7 @@ abstract class Weapon { required List entities, required bool Function(int x, int y) isWalkable, required int currentTime, - required void Function(int scoreToAdd) onEnemyKilled, + required void Function(Enemy killedEnemy) onEnemyKilled, }) { Enemy? closestEnemy; double minDistance = 15.0; @@ -85,7 +95,6 @@ abstract class Weapon { if (angleDiff.abs() < threshold) { Coordinate2D source = Coordinate2D(playerX, playerY); - // Delegate to the enemy to check if it's visible if (entity.hasLineOfSightFrom( source, playerAngle, @@ -103,8 +112,10 @@ abstract class Weapon { if (closestEnemy != null) { closestEnemy.takeDamage(damage, currentTime); + // If the shot was fatal, pass the enemy back so the Player class + // can calculate the correct score based on enemy type! if (closestEnemy.state == EntityState.dead) { - onEnemyKilled(100); + onEnemyKilled(closestEnemy); } } } diff --git a/lib/features/weapon/weapons/knife.dart b/lib/features/weapon/weapons/knife.dart index 72db5e3..aeba056 100644 --- a/lib/features/weapon/weapons/knife.dart +++ b/lib/features/weapon/weapons/knife.dart @@ -8,6 +8,7 @@ class Knife extends Weapon { fireFrames: [417, 418, 419, 420], damage: 15, msPerFrame: 120, + isAutomatic: false, ); @override diff --git a/lib/features/weapon/weapons/pistol.dart b/lib/features/weapon/weapons/pistol.dart index 0beec2f..a6e99b0 100644 --- a/lib/features/weapon/weapons/pistol.dart +++ b/lib/features/weapon/weapons/pistol.dart @@ -7,5 +7,6 @@ class Pistol extends Weapon { idleSprite: 421, fireFrames: [422, 423, 424, 425], damage: 20, + isAutomatic: false, ); }