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.
/// This should be called once per frame by the host application.
void tick(Duration elapsed) {
void tick(Duration delta) {
if (!isInitialized) return;
_timeAliveMs += elapsed.inMilliseconds;
// Trust the incoming delta time natively
_timeAliveMs += delta.inMilliseconds;
// 1. Process User Input
input.update();
final currentInput = input.currentInput;
final inputResult = _processInputs(elapsed, currentInput);
final inputResult = _processInputs(delta, currentInput);
// 2. Update Environment (Doors & Pushwalls)
doorManager.update(elapsed);
pushwallManager.update(elapsed, currentLevel);
// 2. Update Environment
doorManager.update(delta);
pushwallManager.update(delta, currentLevel);
// 3. Update Physics & Movement
player.tick(elapsed);
player.tick(delta);
player.angle += inputResult.dAngle;
// Normalize angle to [0, 2π]
if (player.angle < 0) 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.y = validatedPos.y;
// 4. Update Dynamic World (Enemies & Combat)
_updateEntities(elapsed);
// 4. Update Dynamic World
_updateEntities(delta);
player.updateWeapon(
currentTime: _timeAliveMs,
@@ -213,11 +213,15 @@ class WolfEngine {
/// Translates [EngineInput] into movement vectors and rotation.
({Coordinate2D movement, double dAngle}) _processInputs(
Duration elapsed,
Duration delta,
EngineInput input,
) {
const double moveSpeed = 0.14;
const double turnSpeed = 0.10;
// Standardize movement to 60 FPS (16.66ms per frame)
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);
double dAngle = 0.0;
@@ -229,7 +233,6 @@ class WolfEngine {
if (input.isFiring) {
player.fire(_timeAliveMs);
// Throttle the acoustic flood-fill to emit a "wave" every 400ms while firing
if (_timeAliveMs - _lastAcousticAlertTime > 400) {
_propagateGunfire();
_lastAcousticAlertTime = _timeAliveMs;
@@ -248,14 +251,12 @@ class WolfEngine {
if (input.isMovingForward) movement += forwardVec * moveSpeed;
if (input.isMovingBackward) movement -= forwardVec * moveSpeed;
// Handle Wall Interactions (Switches, Doors, Secret Walls)
if (input.isInteracting) {
int targetX = (player.x + math.cos(player.angle)).toInt();
int targetY = (player.y + math.sin(player.angle)).toInt();
if (targetX >= 0 && targetX < 64 && targetY >= 0 && targetY < 64) {
int wallId = currentLevel[targetY][targetX];
// Handle Elevator Switches
if (wallId == MapObject.normalElevatorSwitch) {
_onLevelCompleted(isSecretExit: false);
return (movement: const Coordinate2D(0, 0), dAngle: 0.0);
@@ -334,15 +335,29 @@ class WolfEngine {
// Standard AI Update cycle
final intent = entity.update(
elapsedMs: _timeAliveMs,
elapsedDeltaMs: elapsed.inMilliseconds,
playerPosition: player.position,
isWalkable: isWalkable,
tryOpenDoor: doorManager.tryOpenDoor,
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.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
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.
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] >= 90) return doorManager.isDoorOpenEnough(x, y);
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.
void _bumpPlayerIfStuck() {
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_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 {
static const double speed = 0.04;
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
EnemyType get type =>
throw UnimplementedError("Hans Grosse uses manual animation logic.");
@@ -26,7 +18,6 @@ class HansGrosse extends Enemy {
required super.mapId,
required Difficulty difficulty,
}) : super(spriteIndex: _baseSprite, state: EntityState.idle) {
// Boss health scales heavily with difficulty
health = switch (difficulty.level) {
0 => 850,
1 => 950,
@@ -65,20 +56,21 @@ class HansGrosse extends Enemy {
if (health <= 0) {
state = EntityState.dead;
isDying = true;
currentFrame = 0;
setTics(5); // Start death sequence
}
// Note: Bosses do NOT have a pain state! They never flinch.
}
@override
({Coordinate2D movement, double newAngle}) update({
required int elapsedMs,
required int elapsedDeltaMs,
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;
if (isAlerted && state != EntityState.dead) {
@@ -96,60 +88,80 @@ class HansGrosse extends Enemy {
switch (state) {
case EntityState.idle:
case EntityState.ambush:
spriteIndex = _baseSprite;
break;
case EntityState.patrolling:
if (!isAlerted || distance > 1.5) {
double currentMoveAngle = isAlerted ? newAngle : angle;
double moveX = math.cos(currentMoveAngle) * speed;
double moveY = math.sin(currentMoveAngle) * speed;
movement = getValidMovement(
Coordinate2D(moveX, moveY),
Coordinate2D(
math.cos(currentMoveAngle) * speed,
math.sin(currentMoveAngle) * speed,
),
isWalkable,
tryOpenDoor,
);
}
int walkFrame = (elapsedMs ~/ 150) % 4;
spriteIndex = (_baseSprite + 1) + walkFrame;
spriteIndex = (_baseSprite + 1) + currentFrame;
if (isAlerted && distance < 8.0 && elapsedMs - lastActionTime > 1000) {
if (hasLineOfSight(playerPosition, isWalkable)) {
if (processTics(elapsedDeltaMs, moveSpeed: speed)) {
currentFrame = (currentFrame + 1) % 4;
setTics(15);
if (isAlerted &&
distance < 12.0 &&
hasLineOfSight(playerPosition, isWalkable)) {
state = EntityState.attacking;
lastActionTime = elapsedMs;
_hasFiredThisCycle = false;
currentFrame = 0;
setTics(10); // Aiming
}
}
break;
case EntityState.attacking:
int timeShooting = elapsedMs - lastActionTime;
if (timeShooting < 150) {
spriteIndex = _baseSprite + 5; // Aiming
} else if (timeShooting < 300) {
spriteIndex = _baseSprite + 6; // Firing
if (!_hasFiredThisCycle) {
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
currentFrame++;
if (currentFrame == 1) {
spriteIndex = _baseSprite + 5; // Aim
setTics(10);
} else if (currentFrame == 2) {
spriteIndex = _baseSprite + 6; // Fire
onDamagePlayer(damage);
_hasFiredThisCycle = true;
}
} else if (timeShooting < 450) {
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;
lastActionTime = elapsedMs;
currentFrame = 0;
setTics(15);
}
}
break;
case EntityState.dead:
if (isDying) {
int deathFrame = (elapsedMs - lastActionTime) ~/ 150;
if (deathFrame < 4) {
spriteIndex = (_baseSprite + 8) + deathFrame;
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
currentFrame++;
if (currentFrame < 4) {
spriteIndex = (_baseSprite + 8) + currentFrame;
setTics(5);
} else {
spriteIndex = _baseSprite + 11; // Final dead frame
spriteIndex = _baseSprite + 11;
isDying = false;
}
}
} else {
spriteIndex = _baseSprite + 11;
}
@@ -158,7 +170,6 @@ class HansGrosse extends Enemy {
default:
break;
}
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/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 {
/// The movement speed of the Dog in tiles per frame.
static const double speed = 0.05;
/// Ensures the dog only deals damage once per leap animation.
bool _hasBittenThisCycle = false;
@override
EnemyType get type => EnemyType.dog;
@@ -26,7 +18,6 @@ class Dog extends Enemy {
required super.angle,
required super.mapId,
}) : super(
// Static metadata used during initialization.
spriteIndex: EnemyType.dog.animations.idle.start,
state: EntityState.idle,
) {
@@ -37,6 +28,7 @@ class Dog extends Enemy {
@override
({Coordinate2D movement, double newAngle}) update({
required int elapsedMs,
required int elapsedDeltaMs,
required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer,
@@ -54,9 +46,7 @@ class Dog extends Enemy {
double distance = position.distanceTo(playerPosition);
double angleToPlayer = position.angleTo(playerPosition);
if (isAlerted && state != EntityState.dead) {
newAngle = angleToPlayer;
}
if (isAlerted && state != EntityState.dead) newAngle = angleToPlayer;
double diff = angleToPlayer - newAngle;
while (diff <= -math.pi) {
@@ -78,9 +68,9 @@ class Dog extends Enemy {
elapsedMs: elapsedMs,
lastActionTime: lastActionTime,
angleDiff: diff,
walkFrameOverride: state == EntityState.patrolling ? currentFrame : null,
);
// --- State: Patrolling ---
if (state == EntityState.patrolling) {
if (!isAlerted || distance > 1.0) {
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)) {
currentFrame = (currentFrame + 1) % 4;
setTics(5);
if (isAlerted && distance < 1.0) {
state = EntityState.attacking;
currentFrame = 0;
lastActionTime = elapsedMs;
_hasBittenThisCycle = false;
setTics(5); // Leap
}
}
}
// --- State: Attacking (The Leap) ---
if (state == EntityState.attacking) {
int time = elapsedMs - lastActionTime;
// Damage is applied mid-animation.
if (time >= 200 && !_hasBittenThisCycle) {
onDamagePlayer(damage);
_hasBittenThisCycle = true;
} else if (time >= 400) {
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
currentFrame++;
if (currentFrame == 1) {
onDamagePlayer(damage); // Bite
setTics(5);
} else if (currentFrame == 2) {
setTics(5); // Land
} else {
state = EntityState.patrolling;
lastActionTime = elapsedMs;
currentFrame = 0;
setTics(5);
}
}
}

View File

@@ -24,6 +24,15 @@ abstract class Enemy extends Entity {
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.
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`.
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.
///
/// Alerts the enemy automatically upon taking damage. There is a 50% chance
@@ -58,19 +91,55 @@ abstract class Enemy extends Entity {
health -= amount;
lastActionTime = currentTime;
// Any hit from the player instantly alerts the enemy
isAlerted = true;
if (health <= 0) {
state = EntityState.dead;
isDying = true;
} else if (math.Random().nextDouble() < 0.5) {
// 50% chance to enter the 'Pain' state (flinching)
state = EntityState.pain;
setTics(5); // Add this so they actually pause to flinch
} else {
// If no pain state, ensure they are actively patrolling/attacking
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) {
state = EntityState.patrolling;
setTics(10);
}
lastActionTime = elapsedMs;
@@ -225,6 +295,7 @@ abstract class Enemy extends Entity {
/// The per-frame update logic to be implemented by specific enemy types.
({Coordinate2D movement, double newAngle}) update({
required int elapsedMs,
required int elapsedDeltaMs,
required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable,
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/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 {
/// The movement speed of the Guard in tiles per frame.
static const double speed = 0.03;
// Original SPDPATROL is 512. 1 Tile is 65536 units.
// 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
EnemyType get type => EnemyType.guard;
@@ -27,8 +20,6 @@ class Guard extends Enemy {
required super.angle,
required super.mapId,
}) : 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,
state: EntityState.idle,
) {
@@ -36,11 +27,10 @@ class Guard extends Enemy {
damage = 10;
}
/// Performs the per-frame logic for the Guard, including movement,
/// combat state transitions, and sprite animation.
@override
({Coordinate2D movement, double newAngle}) update({
required int elapsedMs,
required int elapsedDeltaMs,
required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer,
@@ -48,24 +38,102 @@ class Guard extends Enemy {
}) {
Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle;
double distance = position.distanceTo(playerPosition);
// Standard AI 'Wake Up' check for line-of-sight detection.
// 1. Perception (SightPlayer)
checkWakeUp(
elapsedMs: elapsedMs,
playerPosition: playerPosition,
isWalkable: isWalkable,
);
double distance = position.distanceTo(playerPosition);
double angleToPlayer = position.angleTo(playerPosition);
// 2. Discrete AI Logic (Decisions happen every 10 tics)
bool ticReady = processTics(elapsedDeltaMs, moveSpeed: 0);
// If the enemy is alerted, they constantly turn to face the player.
if (isAlerted && state != EntityState.dead) {
newAngle = angleToPlayer;
if (state == EntityState.attacking) {
handleAttackState(
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);
}
}
// Calculate normalized angle difference for the octant billboarding logic.
double diff = angleToPlayer - 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);
}
}
_updateAnimation(elapsedMs, newAngle, playerPosition);
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) {
diff += 2 * math.pi;
}
@@ -73,7 +141,6 @@ class Guard extends Enemy {
diff -= 2 * math.pi;
}
// Resolve the current animation state for the renderer.
EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking,
@@ -82,61 +149,14 @@ class Guard extends Enemy {
_ => EnemyAnimation.idle,
};
// Update the visual sprite based on state and viewing angle.
spriteIndex = type.getSpriteFromAnimation(
animation: currentAnim,
elapsedMs: elapsedMs,
lastActionTime: lastActionTime,
angleDiff: diff,
);
// --- 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,
walkFrameOverride: (state == EntityState.patrolling || isAlerted)
? currentFrame
: null,
);
}
// 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 {
static const double speed = 0.04;
bool _hasFiredThisCycle = false;
@override
EnemyType get type => EnemyType.mutant;
@@ -29,6 +28,7 @@ class Mutant extends Enemy {
@override
({Coordinate2D movement, double newAngle}) update({
required int elapsedMs,
required int elapsedDeltaMs,
required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer,
@@ -46,11 +46,8 @@ class Mutant extends Enemy {
double distance = position.distanceTo(playerPosition);
double angleToPlayer = position.angleTo(playerPosition);
if (isAlerted && state != EntityState.dead) {
newAngle = angleToPlayer;
}
if (isAlerted && state != EntityState.dead) newAngle = angleToPlayer;
// Calculate angle diff for the octant logic
double diff = angleToPlayer - newAngle;
while (diff <= -math.pi) {
diff += 2 * math.pi;
@@ -59,7 +56,6 @@ class Mutant extends Enemy {
diff -= 2 * math.pi;
}
// Use the centralized animation logic to avoid manual offset errors
EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking,
@@ -73,46 +69,59 @@ class Mutant extends Enemy {
elapsedMs: elapsedMs,
lastActionTime: lastActionTime,
angleDiff: diff,
walkFrameOverride: state == EntityState.patrolling ? currentFrame : null,
);
if (state == EntityState.patrolling) {
// FIX 2: Move along patrol angle if unalerted, chase if alerted
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),
Coordinate2D(
math.cos(currentMoveAngle) * speed,
math.sin(currentMoveAngle) * speed,
),
isWalkable,
tryOpenDoor,
);
}
// FIX 3: Only attack if alerted (Adjust the distance/timing per enemy class!)
if (isAlerted && distance < 6.0 && elapsedMs - lastActionTime > 1500) {
if (hasLineOfSight(playerPosition, isWalkable)) {
if (processTics(elapsedDeltaMs, moveSpeed: speed)) {
currentFrame = (currentFrame + 1) % 4;
setTics(6); // Very fast walk
if (isAlerted &&
distance < 10.0 &&
hasLineOfSight(playerPosition, isWalkable)) {
state = EntityState.attacking;
currentFrame = 0;
lastActionTime = elapsedMs;
_hasFiredThisCycle = false;
setTics(10); // Aim
}
}
}
if (state == EntityState.attacking) {
int timeShooting = elapsedMs - lastActionTime;
// SS-Specific firing logic
if (timeShooting >= 100 && timeShooting < 200 && !_hasFiredThisCycle) {
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
currentFrame++;
if (currentFrame == 1) {
onDamagePlayer(damage);
_hasFiredThisCycle = true;
} else if (timeShooting >= 300) {
setTics(4);
} else if (currentFrame == 2) {
setTics(4);
} else {
state = EntityState.patrolling;
lastActionTime = elapsedMs;
currentFrame = 0;
setTics(6);
}
}
}
if (state == EntityState.pain && elapsedMs - lastActionTime > 250) {
if (state == EntityState.pain) {
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
state = EntityState.patrolling;
lastActionTime = elapsedMs;
currentFrame = 0;
setTics(5);
}
}
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 {
static const double speed = 0.055;
bool _hasFiredThisCycle = false;
@override
EnemyType get type => EnemyType.officer;
@@ -29,6 +28,7 @@ class Officer extends Enemy {
@override
({Coordinate2D movement, double newAngle}) update({
required int elapsedMs,
required int elapsedDeltaMs,
required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer,
@@ -46,9 +46,7 @@ class Officer extends Enemy {
double distance = position.distanceTo(playerPosition);
double angleToPlayer = position.angleTo(playerPosition);
if (isAlerted && state != EntityState.dead) {
newAngle = angleToPlayer;
}
if (isAlerted && state != EntityState.dead) newAngle = angleToPlayer;
double diff = angleToPlayer - newAngle;
while (diff <= -math.pi) {
@@ -58,7 +56,6 @@ class Officer extends Enemy {
diff -= 2 * math.pi;
}
// Use centralized animation logic
EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking,
@@ -72,45 +69,59 @@ class Officer extends Enemy {
elapsedMs: elapsedMs,
lastActionTime: lastActionTime,
angleDiff: diff,
walkFrameOverride: state == EntityState.patrolling ? currentFrame : null,
);
if (state == EntityState.patrolling) {
// FIX 2: Move along patrol angle if unalerted, chase if alerted
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),
Coordinate2D(
math.cos(currentMoveAngle) * speed,
math.sin(currentMoveAngle) * speed,
),
isWalkable,
tryOpenDoor,
);
}
// FIX 3: Only attack if alerted (Adjust the distance/timing per enemy class!)
if (isAlerted && distance < 6.0 && elapsedMs - lastActionTime > 1500) {
if (hasLineOfSight(playerPosition, isWalkable)) {
if (processTics(elapsedDeltaMs, moveSpeed: speed)) {
currentFrame = (currentFrame + 1) % 4;
setTics(8);
if (isAlerted &&
distance < 10.0 &&
hasLineOfSight(playerPosition, isWalkable)) {
state = EntityState.attacking;
currentFrame = 0;
lastActionTime = elapsedMs;
_hasFiredThisCycle = false;
setTics(8); // Fast draw!
}
}
}
if (state == EntityState.attacking) {
int timeShooting = elapsedMs - lastActionTime;
if (timeShooting >= 150 && timeShooting < 300 && !_hasFiredThisCycle) {
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
currentFrame++;
if (currentFrame == 1) {
onDamagePlayer(damage);
_hasFiredThisCycle = true;
} else if (timeShooting >= 450) {
setTics(4); // Bang!
} else if (currentFrame == 2) {
setTics(4); // Cooldown
} else {
state = EntityState.patrolling;
lastActionTime = elapsedMs;
currentFrame = 0;
setTics(8);
}
}
}
if (state == EntityState.pain && elapsedMs - lastActionTime > 250) {
if (state == EntityState.pain) {
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
state = EntityState.patrolling;
lastActionTime = elapsedMs;
currentFrame = 0;
setTics(5);
}
}
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 {
static const double speed = 0.04;
bool _hasFiredThisCycle = false;
/// Instance override required for engine-level animation lookup.
@override
EnemyType get type => EnemyType.ss;
@@ -26,10 +24,10 @@ class SS extends Enemy {
health = 100;
damage = 20;
}
@override
({Coordinate2D movement, double newAngle}) update({
required int elapsedMs,
required int elapsedDeltaMs,
required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer,
@@ -47,11 +45,8 @@ class SS extends Enemy {
double distance = position.distanceTo(playerPosition);
double angleToPlayer = position.angleTo(playerPosition);
if (isAlerted && state != EntityState.dead) {
newAngle = angleToPlayer;
}
if (isAlerted && state != EntityState.dead) newAngle = angleToPlayer;
// Calculate angle diff for the octant logic
double diff = angleToPlayer - newAngle;
while (diff <= -math.pi) {
diff += 2 * math.pi;
@@ -60,7 +55,6 @@ class SS extends Enemy {
diff -= 2 * math.pi;
}
// Use the centralized animation logic to avoid manual offset errors
EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking,
@@ -74,46 +68,69 @@ class SS extends Enemy {
elapsedMs: elapsedMs,
lastActionTime: lastActionTime,
angleDiff: diff,
walkFrameOverride: state == EntityState.patrolling ? currentFrame : null,
);
if (state == EntityState.patrolling) {
// FIX 2: Move along patrol angle if unalerted, chase if alerted
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),
Coordinate2D(
math.cos(currentMoveAngle) * speed,
math.sin(currentMoveAngle) * speed,
),
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)) {
if (processTics(elapsedDeltaMs, moveSpeed: speed)) {
currentFrame = (currentFrame + 1) % 4;
setTics(8);
if (isAlerted &&
distance < 10.0 &&
hasLineOfSight(playerPosition, isWalkable)) {
state = EntityState.attacking;
currentFrame = 0;
lastActionTime = elapsedMs;
_hasFiredThisCycle = false;
setTics(10); // Aiming
}
}
}
if (state == EntityState.attacking) {
int timeShooting = elapsedMs - lastActionTime;
// SS-Specific firing logic
if (timeShooting >= 100 && timeShooting < 200 && !_hasFiredThisCycle) {
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
currentFrame++;
if (currentFrame == 1) {
onDamagePlayer(damage);
_hasFiredThisCycle = true;
} else if (timeShooting >= 300) {
setTics(5); // Bang!
} else if (currentFrame == 2) {
setTics(5); // Cooldown
} 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;
lastActionTime = elapsedMs;
currentFrame = 0;
setTics(8);
}
}
}
if (state == EntityState.pain && elapsedMs - lastActionTime > 250) {
if (state == EntityState.pain) {
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
state = EntityState.patrolling;
lastActionTime = elapsedMs;
currentFrame = 0;
setTics(5);
}
}
return (movement: movement, newAngle: newAngle);