Fix guard logic

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-17 16:59:02 +01:00
parent 68dfd1a444
commit a2f01da515
8 changed files with 406 additions and 236 deletions

View File

@@ -90,25 +90,25 @@ class WolfEngine {
/// ///
/// Updates all world subsystems based on the [elapsed] time. /// Updates all world subsystems based on the [elapsed] time.
/// This should be called once per frame by the host application. /// This should be called once per frame by the host application.
void tick(Duration elapsed) { void tick(Duration delta) {
if (!isInitialized) return; if (!isInitialized) return;
_timeAliveMs += elapsed.inMilliseconds; // Trust the incoming delta time natively
_timeAliveMs += delta.inMilliseconds;
// 1. Process User Input // 1. Process User Input
input.update(); input.update();
final currentInput = input.currentInput; final currentInput = input.currentInput;
final inputResult = _processInputs(elapsed, currentInput); final inputResult = _processInputs(delta, currentInput);
// 2. Update Environment (Doors & Pushwalls) // 2. Update Environment
doorManager.update(elapsed); doorManager.update(delta);
pushwallManager.update(elapsed, currentLevel); pushwallManager.update(delta, currentLevel);
// 3. Update Physics & Movement // 3. Update Physics & Movement
player.tick(elapsed); player.tick(delta);
player.angle += inputResult.dAngle; player.angle += inputResult.dAngle;
// Normalize angle to [0, 2π]
if (player.angle < 0) player.angle += 2 * math.pi; if (player.angle < 0) player.angle += 2 * math.pi;
if (player.angle >= 2 * math.pi) player.angle -= 2 * math.pi; if (player.angle >= 2 * math.pi) player.angle -= 2 * math.pi;
@@ -120,8 +120,8 @@ class WolfEngine {
player.x = validatedPos.x; player.x = validatedPos.x;
player.y = validatedPos.y; player.y = validatedPos.y;
// 4. Update Dynamic World (Enemies & Combat) // 4. Update Dynamic World
_updateEntities(elapsed); _updateEntities(delta);
player.updateWeapon( player.updateWeapon(
currentTime: _timeAliveMs, currentTime: _timeAliveMs,
@@ -213,11 +213,15 @@ class WolfEngine {
/// Translates [EngineInput] into movement vectors and rotation. /// Translates [EngineInput] into movement vectors and rotation.
({Coordinate2D movement, double dAngle}) _processInputs( ({Coordinate2D movement, double dAngle}) _processInputs(
Duration elapsed, Duration delta,
EngineInput input, EngineInput input,
) { ) {
const double moveSpeed = 0.14; // Standardize movement to 60 FPS (16.66ms per frame)
const double turnSpeed = 0.10; final double timeScale = delta.inMilliseconds / 16.666;
// Apply the timeScale multiplier to ensure consistent speed at any framerate
double moveSpeed = 0.14 * timeScale;
double turnSpeed = 0.10 * timeScale;
Coordinate2D movement = const Coordinate2D(0, 0); Coordinate2D movement = const Coordinate2D(0, 0);
double dAngle = 0.0; double dAngle = 0.0;
@@ -229,7 +233,6 @@ class WolfEngine {
if (input.isFiring) { if (input.isFiring) {
player.fire(_timeAliveMs); player.fire(_timeAliveMs);
// Throttle the acoustic flood-fill to emit a "wave" every 400ms while firing
if (_timeAliveMs - _lastAcousticAlertTime > 400) { if (_timeAliveMs - _lastAcousticAlertTime > 400) {
_propagateGunfire(); _propagateGunfire();
_lastAcousticAlertTime = _timeAliveMs; _lastAcousticAlertTime = _timeAliveMs;
@@ -248,14 +251,12 @@ class WolfEngine {
if (input.isMovingForward) movement += forwardVec * moveSpeed; if (input.isMovingForward) movement += forwardVec * moveSpeed;
if (input.isMovingBackward) movement -= forwardVec * moveSpeed; if (input.isMovingBackward) movement -= forwardVec * moveSpeed;
// Handle Wall Interactions (Switches, Doors, Secret Walls)
if (input.isInteracting) { if (input.isInteracting) {
int targetX = (player.x + math.cos(player.angle)).toInt(); int targetX = (player.x + math.cos(player.angle)).toInt();
int targetY = (player.y + math.sin(player.angle)).toInt(); int targetY = (player.y + math.sin(player.angle)).toInt();
if (targetX >= 0 && targetX < 64 && targetY >= 0 && targetY < 64) { if (targetX >= 0 && targetX < 64 && targetY >= 0 && targetY < 64) {
int wallId = currentLevel[targetY][targetX]; int wallId = currentLevel[targetY][targetX];
// Handle Elevator Switches
if (wallId == MapObject.normalElevatorSwitch) { if (wallId == MapObject.normalElevatorSwitch) {
_onLevelCompleted(isSecretExit: false); _onLevelCompleted(isSecretExit: false);
return (movement: const Coordinate2D(0, 0), dAngle: 0.0); return (movement: const Coordinate2D(0, 0), dAngle: 0.0);
@@ -334,15 +335,29 @@ class WolfEngine {
// Standard AI Update cycle // Standard AI Update cycle
final intent = entity.update( final intent = entity.update(
elapsedMs: _timeAliveMs, elapsedMs: _timeAliveMs,
elapsedDeltaMs: elapsed.inMilliseconds,
playerPosition: player.position, playerPosition: player.position,
isWalkable: isWalkable, isWalkable: isWalkable,
tryOpenDoor: doorManager.tryOpenDoor, tryOpenDoor: doorManager.tryOpenDoor,
onDamagePlayer: (int damage) => player.takeDamage(damage), onDamagePlayer: (int damage) => player.takeDamage(damage),
); );
// Scale the enemy's movement intent to prevent super-speed on 90Hz/120Hz displays
double timeScale = elapsed.inMilliseconds / 16.666;
Coordinate2D scaledMovement = intent.movement * timeScale;
Coordinate2D safeMovement = _clampMovement(scaledMovement);
entity.angle = intent.newAngle; entity.angle = intent.newAngle;
entity.x += intent.movement.x;
entity.y += intent.movement.y; // Final sanity check: only move if the destination is on-map
double nextX = entity.x + safeMovement.x;
double nextY = entity.y + safeMovement.y;
if (nextX >= 0 && nextX < 64 && nextY >= 0 && nextY < 64) {
entity.x = nextX;
entity.y = nextY;
}
// Handle Item Drops // Handle Item Drops
if (entity.state == EntityState.dead && if (entity.state == EntityState.dead &&
@@ -439,11 +454,29 @@ class WolfEngine {
/// Returns true if a tile is empty or contains a door that is sufficiently open. /// Returns true if a tile is empty or contains a door that is sufficiently open.
bool isWalkable(int x, int y) { bool isWalkable(int x, int y) {
// 1. Boundary Guard: Prevent range errors by checking if coordinates are on the map
if (x < 0 || x >= 64 || y < 0 || y >= 64) {
return false; // Out of bounds is never walkable
}
if (currentLevel[y][x] == 0) return true; if (currentLevel[y][x] == 0) return true;
if (currentLevel[y][x] >= 90) return doorManager.isDoorOpenEnough(x, y); if (currentLevel[y][x] >= 90) return doorManager.isDoorOpenEnough(x, y);
return false; return false;
} }
/// Clamps movement to a maximum of 0.2 tiles per step to prevent wall-clipping
/// and map-boundary jumps during low framerate/high delta spikes.
Coordinate2D _clampMovement(Coordinate2D intent) {
const double maxStep = 0.2;
double length = math.sqrt(intent.x * intent.x + intent.y * intent.y);
if (length > maxStep) {
double scale = maxStep / length;
return Coordinate2D(intent.x * scale, intent.y * scale);
}
return intent;
}
/// Teleports the player to the nearest empty tile if they spawn inside a wall. /// Teleports the player to the nearest empty tile if they spawn inside a wall.
void _bumpPlayerIfStuck() { void _bumpPlayerIfStuck() {
int pX = player.x.toInt(); int pX = player.x.toInt();

View File

@@ -3,18 +3,10 @@ import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_entities.dart'; import 'package:wolf_3d_dart/wolf_3d_entities.dart';
/// The Episode 1 Boss: Hans Grosse.
///
/// Unlike standard enemies, Hans uses manual sprite indexing because
/// he does not have 8-directional rotation and features a unique
/// dual-chaingun firing rhythm.
class HansGrosse extends Enemy { class HansGrosse extends Enemy {
static const double speed = 0.04; static const double speed = 0.04;
static const int _baseSprite = 291; static const int _baseSprite = 291;
bool _hasFiredThisCycle = false;
/// Hans is a unique boss and does not map to a standard [EnemyType] enum.
/// This returns an 'unknown' or specialized type if needed for the engine.
@override @override
EnemyType get type => EnemyType get type =>
throw UnimplementedError("Hans Grosse uses manual animation logic."); throw UnimplementedError("Hans Grosse uses manual animation logic.");
@@ -26,7 +18,6 @@ class HansGrosse extends Enemy {
required super.mapId, required super.mapId,
required Difficulty difficulty, required Difficulty difficulty,
}) : super(spriteIndex: _baseSprite, state: EntityState.idle) { }) : super(spriteIndex: _baseSprite, state: EntityState.idle) {
// Boss health scales heavily with difficulty
health = switch (difficulty.level) { health = switch (difficulty.level) {
0 => 850, 0 => 850,
1 => 950, 1 => 950,
@@ -65,20 +56,21 @@ class HansGrosse extends Enemy {
if (health <= 0) { if (health <= 0) {
state = EntityState.dead; state = EntityState.dead;
isDying = true; isDying = true;
currentFrame = 0;
setTics(5); // Start death sequence
} }
// Note: Bosses do NOT have a pain state! They never flinch.
} }
@override @override
({Coordinate2D movement, double newAngle}) update({ ({Coordinate2D movement, double newAngle}) update({
required int elapsedMs, required int elapsedMs,
required int elapsedDeltaMs,
required Coordinate2D playerPosition, required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable, required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer, required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor, required void Function(int x, int y) tryOpenDoor,
}) { }) {
Coordinate2D movement = const Coordinate2D(0, 0); Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle; double newAngle = angle;
if (isAlerted && state != EntityState.dead) { if (isAlerted && state != EntityState.dead) {
@@ -96,59 +88,79 @@ class HansGrosse extends Enemy {
switch (state) { switch (state) {
case EntityState.idle: case EntityState.idle:
case EntityState.ambush:
spriteIndex = _baseSprite; spriteIndex = _baseSprite;
break; break;
case EntityState.patrolling: case EntityState.patrolling:
if (!isAlerted || distance > 1.5) { if (!isAlerted || distance > 1.5) {
double currentMoveAngle = isAlerted ? newAngle : angle; double currentMoveAngle = isAlerted ? newAngle : angle;
double moveX = math.cos(currentMoveAngle) * speed;
double moveY = math.sin(currentMoveAngle) * speed;
movement = getValidMovement( movement = getValidMovement(
Coordinate2D(moveX, moveY), Coordinate2D(
math.cos(currentMoveAngle) * speed,
math.sin(currentMoveAngle) * speed,
),
isWalkable, isWalkable,
tryOpenDoor, tryOpenDoor,
); );
} }
int walkFrame = (elapsedMs ~/ 150) % 4; spriteIndex = (_baseSprite + 1) + currentFrame;
spriteIndex = (_baseSprite + 1) + walkFrame;
if (isAlerted && distance < 8.0 && elapsedMs - lastActionTime > 1000) { if (processTics(elapsedDeltaMs, moveSpeed: speed)) {
if (hasLineOfSight(playerPosition, isWalkable)) { currentFrame = (currentFrame + 1) % 4;
setTics(15);
if (isAlerted &&
distance < 12.0 &&
hasLineOfSight(playerPosition, isWalkable)) {
state = EntityState.attacking; state = EntityState.attacking;
lastActionTime = elapsedMs; currentFrame = 0;
_hasFiredThisCycle = false; setTics(10); // Aiming
} }
} }
break; break;
case EntityState.attacking: case EntityState.attacking:
int timeShooting = elapsedMs - lastActionTime; if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
if (timeShooting < 150) { currentFrame++;
spriteIndex = _baseSprite + 5; // Aiming if (currentFrame == 1) {
} else if (timeShooting < 300) { spriteIndex = _baseSprite + 5; // Aim
spriteIndex = _baseSprite + 6; // Firing setTics(10);
if (!_hasFiredThisCycle) { } else if (currentFrame == 2) {
spriteIndex = _baseSprite + 6; // Fire
onDamagePlayer(damage); onDamagePlayer(damage);
_hasFiredThisCycle = true; setTics(4);
} else if (currentFrame == 3) {
spriteIndex = _baseSprite + 7; // Recoil
setTics(4);
} else {
// High chance to keep firing
if (distance < 12.0 && hasLineOfSight(playerPosition, isWalkable)) {
if (math.Random().nextDouble() > 0.2) {
currentFrame = 1;
setTics(10);
return (movement: movement, newAngle: newAngle);
}
}
state = EntityState.patrolling;
currentFrame = 0;
setTics(15);
} }
} else if (timeShooting < 450) {
spriteIndex = _baseSprite + 7; // Recoil
} else {
state = EntityState.patrolling;
lastActionTime = elapsedMs;
} }
break; break;
case EntityState.dead: case EntityState.dead:
if (isDying) { if (isDying) {
int deathFrame = (elapsedMs - lastActionTime) ~/ 150; if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
if (deathFrame < 4) { currentFrame++;
spriteIndex = (_baseSprite + 8) + deathFrame; if (currentFrame < 4) {
} else { spriteIndex = (_baseSprite + 8) + currentFrame;
spriteIndex = _baseSprite + 11; // Final dead frame setTics(5);
isDying = false; } else {
spriteIndex = _baseSprite + 11;
isDying = false;
}
} }
} else { } else {
spriteIndex = _baseSprite + 11; spriteIndex = _baseSprite + 11;
@@ -158,7 +170,6 @@ class HansGrosse extends Enemy {
default: default:
break; break;
} }
return (movement: movement, newAngle: newAngle); return (movement: movement, newAngle: newAngle);
} }
} }

View File

@@ -6,17 +6,9 @@ import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_type.dart';
import 'package:wolf_3d_dart/src/entities/entity.dart'; import 'package:wolf_3d_dart/src/entities/entity.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
/// The Attack Dog entity.
///
/// Dogs are fast, melee-only enemies with very low health. They do not
/// have a 'pain' state and attack by leaping at the player once in range.
class Dog extends Enemy { class Dog extends Enemy {
/// The movement speed of the Dog in tiles per frame.
static const double speed = 0.05; static const double speed = 0.05;
/// Ensures the dog only deals damage once per leap animation.
bool _hasBittenThisCycle = false;
@override @override
EnemyType get type => EnemyType.dog; EnemyType get type => EnemyType.dog;
@@ -26,7 +18,6 @@ class Dog extends Enemy {
required super.angle, required super.angle,
required super.mapId, required super.mapId,
}) : super( }) : super(
// Static metadata used during initialization.
spriteIndex: EnemyType.dog.animations.idle.start, spriteIndex: EnemyType.dog.animations.idle.start,
state: EntityState.idle, state: EntityState.idle,
) { ) {
@@ -37,6 +28,7 @@ class Dog extends Enemy {
@override @override
({Coordinate2D movement, double newAngle}) update({ ({Coordinate2D movement, double newAngle}) update({
required int elapsedMs, required int elapsedMs,
required int elapsedDeltaMs,
required Coordinate2D playerPosition, required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable, required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer, required void Function(int damage) onDamagePlayer,
@@ -54,9 +46,7 @@ class Dog extends Enemy {
double distance = position.distanceTo(playerPosition); double distance = position.distanceTo(playerPosition);
double angleToPlayer = position.angleTo(playerPosition); double angleToPlayer = position.angleTo(playerPosition);
if (isAlerted && state != EntityState.dead) { if (isAlerted && state != EntityState.dead) newAngle = angleToPlayer;
newAngle = angleToPlayer;
}
double diff = angleToPlayer - newAngle; double diff = angleToPlayer - newAngle;
while (diff <= -math.pi) { while (diff <= -math.pi) {
@@ -78,9 +68,9 @@ class Dog extends Enemy {
elapsedMs: elapsedMs, elapsedMs: elapsedMs,
lastActionTime: lastActionTime, lastActionTime: lastActionTime,
angleDiff: diff, angleDiff: diff,
walkFrameOverride: state == EntityState.patrolling ? currentFrame : null,
); );
// --- State: Patrolling ---
if (state == EntityState.patrolling) { if (state == EntityState.patrolling) {
if (!isAlerted || distance > 1.0) { if (!isAlerted || distance > 1.0) {
double currentMoveAngle = isAlerted ? angleToPlayer : angle; double currentMoveAngle = isAlerted ? angleToPlayer : angle;
@@ -94,24 +84,32 @@ class Dog extends Enemy {
); );
} }
// Dogs switch to attacking state based on melee proximity (1 tile). if (processTics(elapsedDeltaMs, moveSpeed: speed)) {
if (isAlerted && distance < 1.0) { currentFrame = (currentFrame + 1) % 4;
state = EntityState.attacking; setTics(5);
lastActionTime = elapsedMs;
_hasBittenThisCycle = false; if (isAlerted && distance < 1.0) {
state = EntityState.attacking;
currentFrame = 0;
lastActionTime = elapsedMs;
setTics(5); // Leap
}
} }
} }
// --- State: Attacking (The Leap) ---
if (state == EntityState.attacking) { if (state == EntityState.attacking) {
int time = elapsedMs - lastActionTime; if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
// Damage is applied mid-animation. currentFrame++;
if (time >= 200 && !_hasBittenThisCycle) { if (currentFrame == 1) {
onDamagePlayer(damage); onDamagePlayer(damage); // Bite
_hasBittenThisCycle = true; setTics(5);
} else if (time >= 400) { } else if (currentFrame == 2) {
state = EntityState.patrolling; setTics(5); // Land
lastActionTime = elapsedMs; } else {
state = EntityState.patrolling;
currentFrame = 0;
setTics(5);
}
} }
} }

View File

@@ -24,6 +24,15 @@ abstract class Enemy extends Entity {
super.lastActionTime, super.lastActionTime,
}); });
/// The current "Tic" count remaining for the active animation frame.
int _ticCount = 0;
/// Accumulates milliseconds to determine when a Tic has passed (14.28ms per Tic).
double _ticAccumulator = 0;
/// The current animation frame index (used to progress through states).
int currentFrame = 0;
/// The amount of damage the enemy can take before dying. /// The amount of damage the enemy can take before dying.
int health = 25; int health = 25;
@@ -49,6 +58,30 @@ abstract class Enemy extends Entity {
/// This replaces the `ob->temp2` variable found in the original `WL_ACT2.C`. /// This replaces the `ob->temp2` variable found in the original `WL_ACT2.C`.
int reactionTimeMs = 0; int reactionTimeMs = 0;
/// Processes elapsed time and returns true if the enemy's animation frame has completed.
///
/// Movement is applied continuously during the frame, but state changes (like deciding
/// to shoot) only happen when [processTics] returns true.
bool processTics(int elapsedDeltaMs, {required double moveSpeed}) {
// 1 Tic = 1/70th of a second (~14.28ms)
_ticAccumulator += elapsedDeltaMs;
// Calculate how many Tics passed this frame
int ticsPassed = (_ticAccumulator / 14.28).floor();
if (ticsPassed > 0) {
_ticAccumulator -= (ticsPassed * 14.28);
_ticCount -= ticsPassed;
}
return _ticCount <= 0;
}
/// Sets the duration for the next animation frame.
void setTics(int tics) {
_ticCount = tics;
}
/// Reduces health and handles state transitions for pain or death. /// Reduces health and handles state transitions for pain or death.
/// ///
/// Alerts the enemy automatically upon taking damage. There is a 50% chance /// Alerts the enemy automatically upon taking damage. There is a 50% chance
@@ -58,19 +91,55 @@ abstract class Enemy extends Entity {
health -= amount; health -= amount;
lastActionTime = currentTime; lastActionTime = currentTime;
// Any hit from the player instantly alerts the enemy
isAlerted = true; isAlerted = true;
if (health <= 0) { if (health <= 0) {
state = EntityState.dead; state = EntityState.dead;
isDying = true; isDying = true;
} else if (math.Random().nextDouble() < 0.5) { } else if (math.Random().nextDouble() < 0.5) {
// 50% chance to enter the 'Pain' state (flinching)
state = EntityState.pain; state = EntityState.pain;
setTics(5); // Add this so they actually pause to flinch
} else { } else {
// If no pain state, ensure they are actively patrolling/attacking
state = EntityState.patrolling; state = EntityState.patrolling;
setTics(10); // Add this to reset their movement rhythm
}
}
/// Handles the standard 3-phase attack progression (Aim -> Shoot -> Cooldown).
///
/// Calculates hit chance and damage based on the [distance] to the player.
void handleAttackState({
required int elapsedDeltaMs,
required void Function(int damage) onDamagePlayer,
required int shootTics,
required int cooldownTics,
required int postAttackTics,
required double distance,
}) {
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
currentFrame++;
if (currentFrame == 1) {
// Phase 1: Bang! Calculate hit chance based on distance.
// Drops by ~5% per tile distance, capped at a minimum 10% chance to hit.
double hitChance = (1.0 - (distance * 0.05)).clamp(0.1, 1.0);
if (math.Random().nextDouble() <= hitChance) {
// Hit! Roll for damage between 1 and the enemy's max damage stat.
int actualDamage = math.Random().nextInt(damage) + 1;
onDamagePlayer(actualDamage);
}
setTics(shootTics);
} else if (currentFrame == 2) {
// Phase 2: Cooldown. Gun lowered.
setTics(cooldownTics);
} else {
// Phase 3: Attack complete. Return to patrol logic.
state = EntityState.patrolling;
currentFrame = 0;
setTics(postAttackTics);
}
} }
} }
@@ -98,6 +167,7 @@ abstract class Enemy extends Entity {
if (state == EntityState.idle || state == EntityState.ambush) { if (state == EntityState.idle || state == EntityState.ambush) {
state = EntityState.patrolling; state = EntityState.patrolling;
setTics(10);
} }
lastActionTime = elapsedMs; lastActionTime = elapsedMs;
@@ -225,6 +295,7 @@ abstract class Enemy extends Entity {
/// The per-frame update logic to be implemented by specific enemy types. /// The per-frame update logic to be implemented by specific enemy types.
({Coordinate2D movement, double newAngle}) update({ ({Coordinate2D movement, double newAngle}) update({
required int elapsedMs, required int elapsedMs,
required int elapsedDeltaMs,
required Coordinate2D playerPosition, required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable, required bool Function(int x, int y) isWalkable,
required void Function(int x, int y) tryOpenDoor, required void Function(int x, int y) tryOpenDoor,

View File

@@ -6,18 +6,11 @@ import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_type.dart';
import 'package:wolf_3d_dart/src/entities/entity.dart'; import 'package:wolf_3d_dart/src/entities/entity.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
/// The standard Brown Guard entity.
///
/// The Guard is a basic projectile enemy that patrols until alerted by
/// line-of-sight or proximity to the player.
class Guard extends Enemy { class Guard extends Enemy {
/// The movement speed of the Guard in tiles per frame. // Original SPDPATROL is 512. 1 Tile is 65536 units.
static const double speed = 0.03; // 512 / 65536 = ~0.0078 tiles per tic.
static const double speedPerTic = 0.0078;
/// Internal flag to ensure the Guard only deals damage once per attack cycle.
bool _hasFiredThisCycle = false;
/// Returns the metadata specific to the Guard enemy type.
@override @override
EnemyType get type => EnemyType.guard; EnemyType get type => EnemyType.guard;
@@ -27,8 +20,6 @@ class Guard extends Enemy {
required super.angle, required super.angle,
required super.mapId, required super.mapId,
}) : super( }) : super(
// We access the static metadata via EnemyType.guard for the initializer
// since 'type' is not yet available in the super-constructor call.
spriteIndex: EnemyType.guard.animations.idle.start, spriteIndex: EnemyType.guard.animations.idle.start,
state: EntityState.idle, state: EntityState.idle,
) { ) {
@@ -36,11 +27,10 @@ class Guard extends Enemy {
damage = 10; damage = 10;
} }
/// Performs the per-frame logic for the Guard, including movement,
/// combat state transitions, and sprite animation.
@override @override
({Coordinate2D movement, double newAngle}) update({ ({Coordinate2D movement, double newAngle}) update({
required int elapsedMs, required int elapsedMs,
required int elapsedDeltaMs,
required Coordinate2D playerPosition, required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable, required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer, required void Function(int damage) onDamagePlayer,
@@ -48,24 +38,102 @@ class Guard extends Enemy {
}) { }) {
Coordinate2D movement = const Coordinate2D(0, 0); Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle; double newAngle = angle;
double distance = position.distanceTo(playerPosition);
// Standard AI 'Wake Up' check for line-of-sight detection. // 1. Perception (SightPlayer)
checkWakeUp( checkWakeUp(
elapsedMs: elapsedMs, elapsedMs: elapsedMs,
playerPosition: playerPosition, playerPosition: playerPosition,
isWalkable: isWalkable, isWalkable: isWalkable,
); );
double distance = position.distanceTo(playerPosition); // 2. Discrete AI Logic (Decisions happen every 10 tics)
double angleToPlayer = position.angleTo(playerPosition); bool ticReady = processTics(elapsedDeltaMs, moveSpeed: 0);
// If the enemy is alerted, they constantly turn to face the player. if (state == EntityState.attacking) {
if (isAlerted && state != EntityState.dead) { handleAttackState(
newAngle = angleToPlayer; elapsedDeltaMs: elapsedDeltaMs,
distance: distance,
onDamagePlayer: onDamagePlayer,
shootTics: 20,
cooldownTics: 20,
postAttackTics: 20,
);
} else if (state == EntityState.pain) {
if (ticReady) {
state = isAlerted ? EntityState.patrolling : EntityState.idle;
setTics(10);
}
} else if (state != EntityState.dead) {
// 3. Continuous Movement Logic (Happens every frame)
// Calculate movement based on current tics passed this frame for smoothness
double ticsThisFrame = elapsedDeltaMs / 14.28;
double currentMoveSpeed = speedPerTic * ticsThisFrame;
if (isAlerted) {
newAngle = position.angleTo(playerPosition);
// AI Decision: Should I stop to shoot? (Only check on tic boundaries)
if (ticReady && hasLineOfSight(playerPosition, isWalkable)) {
double chance = (distance < 2.0) ? 1.0 : (160.0 / distance) / 256.0;
if (math.Random().nextDouble() < chance) {
state = EntityState.attacking;
currentFrame = 0;
lastActionTime = elapsedMs;
setTics(20);
return (movement: const Coordinate2D(0, 0), newAngle: newAngle);
}
}
// Pursuit movement
movement = _calculateMovement(
newAngle,
currentMoveSpeed,
isWalkable,
tryOpenDoor,
);
} else if (state == EntityState.patrolling) {
// Normal patrol movement
movement = _calculateMovement(
angle,
currentMoveSpeed,
isWalkable,
tryOpenDoor,
);
}
if (ticReady) {
currentFrame = (currentFrame + 1) % 4;
setTics(10);
}
} }
// Calculate normalized angle difference for the octant billboarding logic. _updateAnimation(elapsedMs, newAngle, playerPosition);
double diff = angleToPlayer - newAngle; return (movement: movement, newAngle: newAngle);
}
Coordinate2D _calculateMovement(
double moveAngle,
double moveSpeed,
bool Function(int x, int y) isWalkable,
void Function(int x, int y) tryOpenDoor,
) {
return getValidMovement(
Coordinate2D(
math.cos(moveAngle) * moveSpeed,
math.sin(moveAngle) * moveSpeed,
),
isWalkable,
tryOpenDoor,
);
}
void _updateAnimation(
int elapsedMs,
double newAngle,
Coordinate2D playerPosition,
) {
double diff = position.angleTo(playerPosition) - newAngle;
while (diff <= -math.pi) { while (diff <= -math.pi) {
diff += 2 * math.pi; diff += 2 * math.pi;
} }
@@ -73,7 +141,6 @@ class Guard extends Enemy {
diff -= 2 * math.pi; diff -= 2 * math.pi;
} }
// Resolve the current animation state for the renderer.
EnemyAnimation currentAnim = switch (state) { EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling => EnemyAnimation.walking, EntityState.patrolling => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking, EntityState.attacking => EnemyAnimation.attacking,
@@ -82,61 +149,14 @@ class Guard extends Enemy {
_ => EnemyAnimation.idle, _ => EnemyAnimation.idle,
}; };
// Update the visual sprite based on state and viewing angle.
spriteIndex = type.getSpriteFromAnimation( spriteIndex = type.getSpriteFromAnimation(
animation: currentAnim, animation: currentAnim,
elapsedMs: elapsedMs, elapsedMs: elapsedMs,
lastActionTime: lastActionTime, lastActionTime: lastActionTime,
angleDiff: diff, angleDiff: diff,
walkFrameOverride: (state == EntityState.patrolling || isAlerted)
? currentFrame
: null,
); );
// --- State: Patrolling ---
if (state == EntityState.patrolling) {
// Move toward the player if alerted, otherwise maintain patrol heading.
if (!isAlerted || distance > 0.8) {
double currentMoveAngle = isAlerted ? angleToPlayer : angle;
double moveX = math.cos(currentMoveAngle) * speed;
double moveY = math.sin(currentMoveAngle) * speed;
movement = getValidMovement(
Coordinate2D(moveX, moveY),
isWalkable,
tryOpenDoor,
);
}
// Attack if the player is within 6 tiles and the brief cooldown has passed.
if (isAlerted && distance < 6.0 && elapsedMs - lastActionTime > 400) {
if (hasLineOfSight(playerPosition, isWalkable)) {
state = EntityState.attacking;
lastActionTime = elapsedMs;
_hasFiredThisCycle = false;
}
}
}
// --- State: Attacking ---
if (state == EntityState.attacking) {
int timeShooting = elapsedMs - lastActionTime;
// The Guard fires a single shot between 100ms and 200ms of the attack state.
if (timeShooting >= 100 && timeShooting < 200 && !_hasFiredThisCycle) {
onDamagePlayer(damage);
_hasFiredThisCycle = true;
} else if (timeShooting >= 300) {
// Return to pursuit after the attack animation completes.
state = EntityState.patrolling;
lastActionTime = elapsedMs;
}
}
// --- State: Pain ---
// Brief recovery period after being hit by a player bullet.
if (state == EntityState.pain && elapsedMs - lastActionTime > 250) {
state = EntityState.patrolling;
lastActionTime = elapsedMs;
}
return (movement: movement, newAngle: newAngle);
} }
} }

View File

@@ -8,7 +8,6 @@ import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
class Mutant extends Enemy { class Mutant extends Enemy {
static const double speed = 0.04; static const double speed = 0.04;
bool _hasFiredThisCycle = false;
@override @override
EnemyType get type => EnemyType.mutant; EnemyType get type => EnemyType.mutant;
@@ -29,6 +28,7 @@ class Mutant extends Enemy {
@override @override
({Coordinate2D movement, double newAngle}) update({ ({Coordinate2D movement, double newAngle}) update({
required int elapsedMs, required int elapsedMs,
required int elapsedDeltaMs,
required Coordinate2D playerPosition, required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable, required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer, required void Function(int damage) onDamagePlayer,
@@ -46,11 +46,8 @@ class Mutant extends Enemy {
double distance = position.distanceTo(playerPosition); double distance = position.distanceTo(playerPosition);
double angleToPlayer = position.angleTo(playerPosition); double angleToPlayer = position.angleTo(playerPosition);
if (isAlerted && state != EntityState.dead) { if (isAlerted && state != EntityState.dead) newAngle = angleToPlayer;
newAngle = angleToPlayer;
}
// Calculate angle diff for the octant logic
double diff = angleToPlayer - newAngle; double diff = angleToPlayer - newAngle;
while (diff <= -math.pi) { while (diff <= -math.pi) {
diff += 2 * math.pi; diff += 2 * math.pi;
@@ -59,7 +56,6 @@ class Mutant extends Enemy {
diff -= 2 * math.pi; diff -= 2 * math.pi;
} }
// Use the centralized animation logic to avoid manual offset errors
EnemyAnimation currentAnim = switch (state) { EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling => EnemyAnimation.walking, EntityState.patrolling => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking, EntityState.attacking => EnemyAnimation.attacking,
@@ -73,46 +69,59 @@ class Mutant extends Enemy {
elapsedMs: elapsedMs, elapsedMs: elapsedMs,
lastActionTime: lastActionTime, lastActionTime: lastActionTime,
angleDiff: diff, angleDiff: diff,
walkFrameOverride: state == EntityState.patrolling ? currentFrame : null,
); );
if (state == EntityState.patrolling) { if (state == EntityState.patrolling) {
// FIX 2: Move along patrol angle if unalerted, chase if alerted
if (!isAlerted || distance > 0.8) { if (!isAlerted || distance > 0.8) {
double currentMoveAngle = isAlerted ? angleToPlayer : angle; double currentMoveAngle = isAlerted ? angleToPlayer : angle;
double moveX = math.cos(currentMoveAngle) * speed;
double moveY = math.sin(currentMoveAngle) * speed;
movement = getValidMovement( movement = getValidMovement(
Coordinate2D(moveX, moveY), Coordinate2D(
math.cos(currentMoveAngle) * speed,
math.sin(currentMoveAngle) * speed,
),
isWalkable, isWalkable,
tryOpenDoor, tryOpenDoor,
); );
} }
// FIX 3: Only attack if alerted (Adjust the distance/timing per enemy class!) if (processTics(elapsedDeltaMs, moveSpeed: speed)) {
if (isAlerted && distance < 6.0 && elapsedMs - lastActionTime > 1500) { currentFrame = (currentFrame + 1) % 4;
if (hasLineOfSight(playerPosition, isWalkable)) { setTics(6); // Very fast walk
if (isAlerted &&
distance < 10.0 &&
hasLineOfSight(playerPosition, isWalkable)) {
state = EntityState.attacking; state = EntityState.attacking;
currentFrame = 0;
lastActionTime = elapsedMs; lastActionTime = elapsedMs;
_hasFiredThisCycle = false; setTics(10); // Aim
} }
} }
} }
if (state == EntityState.attacking) { if (state == EntityState.attacking) {
int timeShooting = elapsedMs - lastActionTime; if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
// SS-Specific firing logic currentFrame++;
if (timeShooting >= 100 && timeShooting < 200 && !_hasFiredThisCycle) { if (currentFrame == 1) {
onDamagePlayer(damage); onDamagePlayer(damage);
_hasFiredThisCycle = true; setTics(4);
} else if (timeShooting >= 300) { } else if (currentFrame == 2) {
state = EntityState.patrolling; setTics(4);
lastActionTime = elapsedMs; } else {
state = EntityState.patrolling;
currentFrame = 0;
setTics(6);
}
} }
} }
if (state == EntityState.pain && elapsedMs - lastActionTime > 250) { if (state == EntityState.pain) {
state = EntityState.patrolling; if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
lastActionTime = elapsedMs; state = EntityState.patrolling;
currentFrame = 0;
setTics(5);
}
} }
return (movement: movement, newAngle: newAngle); return (movement: movement, newAngle: newAngle);

View File

@@ -8,7 +8,6 @@ import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
class Officer extends Enemy { class Officer extends Enemy {
static const double speed = 0.055; static const double speed = 0.055;
bool _hasFiredThisCycle = false;
@override @override
EnemyType get type => EnemyType.officer; EnemyType get type => EnemyType.officer;
@@ -29,6 +28,7 @@ class Officer extends Enemy {
@override @override
({Coordinate2D movement, double newAngle}) update({ ({Coordinate2D movement, double newAngle}) update({
required int elapsedMs, required int elapsedMs,
required int elapsedDeltaMs,
required Coordinate2D playerPosition, required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable, required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer, required void Function(int damage) onDamagePlayer,
@@ -46,9 +46,7 @@ class Officer extends Enemy {
double distance = position.distanceTo(playerPosition); double distance = position.distanceTo(playerPosition);
double angleToPlayer = position.angleTo(playerPosition); double angleToPlayer = position.angleTo(playerPosition);
if (isAlerted && state != EntityState.dead) { if (isAlerted && state != EntityState.dead) newAngle = angleToPlayer;
newAngle = angleToPlayer;
}
double diff = angleToPlayer - newAngle; double diff = angleToPlayer - newAngle;
while (diff <= -math.pi) { while (diff <= -math.pi) {
@@ -58,7 +56,6 @@ class Officer extends Enemy {
diff -= 2 * math.pi; diff -= 2 * math.pi;
} }
// Use centralized animation logic
EnemyAnimation currentAnim = switch (state) { EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling => EnemyAnimation.walking, EntityState.patrolling => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking, EntityState.attacking => EnemyAnimation.attacking,
@@ -72,45 +69,59 @@ class Officer extends Enemy {
elapsedMs: elapsedMs, elapsedMs: elapsedMs,
lastActionTime: lastActionTime, lastActionTime: lastActionTime,
angleDiff: diff, angleDiff: diff,
walkFrameOverride: state == EntityState.patrolling ? currentFrame : null,
); );
if (state == EntityState.patrolling) { if (state == EntityState.patrolling) {
// FIX 2: Move along patrol angle if unalerted, chase if alerted
if (!isAlerted || distance > 0.8) { if (!isAlerted || distance > 0.8) {
double currentMoveAngle = isAlerted ? angleToPlayer : angle; double currentMoveAngle = isAlerted ? angleToPlayer : angle;
double moveX = math.cos(currentMoveAngle) * speed;
double moveY = math.sin(currentMoveAngle) * speed;
movement = getValidMovement( movement = getValidMovement(
Coordinate2D(moveX, moveY), Coordinate2D(
math.cos(currentMoveAngle) * speed,
math.sin(currentMoveAngle) * speed,
),
isWalkable, isWalkable,
tryOpenDoor, tryOpenDoor,
); );
} }
// FIX 3: Only attack if alerted (Adjust the distance/timing per enemy class!) if (processTics(elapsedDeltaMs, moveSpeed: speed)) {
if (isAlerted && distance < 6.0 && elapsedMs - lastActionTime > 1500) { currentFrame = (currentFrame + 1) % 4;
if (hasLineOfSight(playerPosition, isWalkable)) { setTics(8);
if (isAlerted &&
distance < 10.0 &&
hasLineOfSight(playerPosition, isWalkable)) {
state = EntityState.attacking; state = EntityState.attacking;
currentFrame = 0;
lastActionTime = elapsedMs; lastActionTime = elapsedMs;
_hasFiredThisCycle = false; setTics(8); // Fast draw!
} }
} }
} }
if (state == EntityState.attacking) { if (state == EntityState.attacking) {
int timeShooting = elapsedMs - lastActionTime; if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
if (timeShooting >= 150 && timeShooting < 300 && !_hasFiredThisCycle) { currentFrame++;
onDamagePlayer(damage); if (currentFrame == 1) {
_hasFiredThisCycle = true; onDamagePlayer(damage);
} else if (timeShooting >= 450) { setTics(4); // Bang!
state = EntityState.patrolling; } else if (currentFrame == 2) {
lastActionTime = elapsedMs; setTics(4); // Cooldown
} else {
state = EntityState.patrolling;
currentFrame = 0;
setTics(8);
}
} }
} }
if (state == EntityState.pain && elapsedMs - lastActionTime > 250) { if (state == EntityState.pain) {
state = EntityState.patrolling; if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
lastActionTime = elapsedMs; state = EntityState.patrolling;
currentFrame = 0;
setTics(5);
}
} }
return (movement: movement, newAngle: newAngle); return (movement: movement, newAngle: newAngle);

View File

@@ -8,9 +8,7 @@ import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
class SS extends Enemy { class SS extends Enemy {
static const double speed = 0.04; static const double speed = 0.04;
bool _hasFiredThisCycle = false;
/// Instance override required for engine-level animation lookup.
@override @override
EnemyType get type => EnemyType.ss; EnemyType get type => EnemyType.ss;
@@ -26,10 +24,10 @@ class SS extends Enemy {
health = 100; health = 100;
damage = 20; damage = 20;
} }
@override @override
({Coordinate2D movement, double newAngle}) update({ ({Coordinate2D movement, double newAngle}) update({
required int elapsedMs, required int elapsedMs,
required int elapsedDeltaMs,
required Coordinate2D playerPosition, required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable, required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer, required void Function(int damage) onDamagePlayer,
@@ -47,11 +45,8 @@ class SS extends Enemy {
double distance = position.distanceTo(playerPosition); double distance = position.distanceTo(playerPosition);
double angleToPlayer = position.angleTo(playerPosition); double angleToPlayer = position.angleTo(playerPosition);
if (isAlerted && state != EntityState.dead) { if (isAlerted && state != EntityState.dead) newAngle = angleToPlayer;
newAngle = angleToPlayer;
}
// Calculate angle diff for the octant logic
double diff = angleToPlayer - newAngle; double diff = angleToPlayer - newAngle;
while (diff <= -math.pi) { while (diff <= -math.pi) {
diff += 2 * math.pi; diff += 2 * math.pi;
@@ -60,7 +55,6 @@ class SS extends Enemy {
diff -= 2 * math.pi; diff -= 2 * math.pi;
} }
// Use the centralized animation logic to avoid manual offset errors
EnemyAnimation currentAnim = switch (state) { EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling => EnemyAnimation.walking, EntityState.patrolling => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking, EntityState.attacking => EnemyAnimation.attacking,
@@ -74,46 +68,69 @@ class SS extends Enemy {
elapsedMs: elapsedMs, elapsedMs: elapsedMs,
lastActionTime: lastActionTime, lastActionTime: lastActionTime,
angleDiff: diff, angleDiff: diff,
walkFrameOverride: state == EntityState.patrolling ? currentFrame : null,
); );
if (state == EntityState.patrolling) { if (state == EntityState.patrolling) {
// FIX 2: Move along patrol angle if unalerted, chase if alerted
if (!isAlerted || distance > 0.8) { if (!isAlerted || distance > 0.8) {
double currentMoveAngle = isAlerted ? angleToPlayer : angle; double currentMoveAngle = isAlerted ? angleToPlayer : angle;
double moveX = math.cos(currentMoveAngle) * speed;
double moveY = math.sin(currentMoveAngle) * speed;
movement = getValidMovement( movement = getValidMovement(
Coordinate2D(moveX, moveY), Coordinate2D(
math.cos(currentMoveAngle) * speed,
math.sin(currentMoveAngle) * speed,
),
isWalkable, isWalkable,
tryOpenDoor, tryOpenDoor,
); );
} }
// Attack if the player is within 6 tiles and the brief cooldown has passed. if (processTics(elapsedDeltaMs, moveSpeed: speed)) {
if (isAlerted && distance < 6.0 && elapsedMs - lastActionTime > 400) { currentFrame = (currentFrame + 1) % 4;
if (hasLineOfSight(playerPosition, isWalkable)) { setTics(8);
if (isAlerted &&
distance < 10.0 &&
hasLineOfSight(playerPosition, isWalkable)) {
state = EntityState.attacking; state = EntityState.attacking;
currentFrame = 0;
lastActionTime = elapsedMs; lastActionTime = elapsedMs;
_hasFiredThisCycle = false; setTics(10); // Aiming
} }
} }
} }
if (state == EntityState.attacking) { if (state == EntityState.attacking) {
int timeShooting = elapsedMs - lastActionTime; if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
// SS-Specific firing logic currentFrame++;
if (timeShooting >= 100 && timeShooting < 200 && !_hasFiredThisCycle) { if (currentFrame == 1) {
onDamagePlayer(damage); onDamagePlayer(damage);
_hasFiredThisCycle = true; setTics(5); // Bang!
} else if (timeShooting >= 300) { } else if (currentFrame == 2) {
state = EntityState.patrolling; setTics(5); // Cooldown
lastActionTime = elapsedMs; } else {
// Burst fire chance
if (distance < 10.0 && hasLineOfSight(playerPosition, isWalkable)) {
if (math.Random().nextDouble() > 0.5) {
// 50% chance to burst
currentFrame = 1;
onDamagePlayer(damage);
setTics(5);
return (movement: movement, newAngle: newAngle);
}
}
state = EntityState.patrolling;
currentFrame = 0;
setTics(8);
}
} }
} }
if (state == EntityState.pain && elapsedMs - lastActionTime > 250) { if (state == EntityState.pain) {
state = EntityState.patrolling; if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
lastActionTime = elapsedMs; state = EntityState.patrolling;
currentFrame = 0;
setTics(5);
}
} }
return (movement: movement, newAngle: newAngle); return (movement: movement, newAngle: newAngle);