Can now open secret walls and pick up machine gun

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-14 15:34:27 +01:00
parent 9f5f29100b
commit 001c7c3131
13 changed files with 545 additions and 120 deletions

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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,
});
}

View File

@@ -27,4 +27,40 @@ abstract class Entity<T> {
}
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;
}
}

View File

@@ -13,12 +13,11 @@ typedef EntitySpawner =
);
abstract class EntityRegistry {
// Add future enemies (SSGuard, Dog, etc.) to this list!
static final List<EntitySpawner> _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(

View File

@@ -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<String, Pushwall> pushwalls = {};
Pushwall? activePushwall;
void initPushwalls(Matrix<int> wallGrid, Matrix<int> 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<int> 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<int> 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;
}
}
}
}

View File

@@ -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)",
);
},
);
}

View File

@@ -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<String, double> doorOffsets;
final Pushwall? activePushwall; // NEW
final List<Matrix<int>> sprites;
final List<Entity> 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<double> 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<String> 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<Entity> 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<Matrix<int>> 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;

View File

@@ -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<WolfRenderer>
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<WolfRenderer>
final Matrix<int> 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<WolfRenderer>
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<WolfRenderer>
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<WolfRenderer>
if (inputManager.isFiring) {
player.fire(elapsed.inMilliseconds);
} else {
player.releaseTrigger();
}
// Calculate intended rotation
@@ -260,6 +262,12 @@ class _WolfRendererState extends State<WolfRenderer>
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<WolfRenderer>
return Coordinate2D(newX, newY);
}
// renderer.dart
void _updateEntities(Duration elapsed) {
List<Entity> itemsToRemove = [];
List<Entity> 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<WolfRenderer>
}
}
// 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<WolfRenderer>
doorOffsets: doorManager.getOffsetsForRenderer(),
entities: entities,
sprites: gameMap.sprites,
activePushwall: pushwallManager.activePushwall,
),
),
Positioned(

View File

@@ -14,10 +14,12 @@ abstract class Weapon {
final List<int> 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<Entity> 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);
}
}
}

View File

@@ -8,6 +8,7 @@ class Knife extends Weapon {
fireFrames: [417, 418, 419, 420],
damage: 15,
msPerFrame: 120,
isAutomatic: false,
);
@override

View File

@@ -7,5 +7,6 @@ class Pistol extends Weapon {
idleSprite: 421,
fireFrames: [422, 423, 424, 425],
damage: 20,
isAutomatic: false,
);
}