@@ -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();
|
||||
|
||||
@@ -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,59 +88,79 @@ 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;
|
||||
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;
|
||||
|
||||
case EntityState.dead:
|
||||
if (isDying) {
|
||||
int deathFrame = (elapsedMs - lastActionTime) ~/ 150;
|
||||
if (deathFrame < 4) {
|
||||
spriteIndex = (_baseSprite + 8) + deathFrame;
|
||||
} else {
|
||||
spriteIndex = _baseSprite + 11; // Final dead frame
|
||||
isDying = false;
|
||||
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
|
||||
currentFrame++;
|
||||
if (currentFrame < 4) {
|
||||
spriteIndex = (_baseSprite + 8) + currentFrame;
|
||||
setTics(5);
|
||||
} else {
|
||||
spriteIndex = _baseSprite + 11;
|
||||
isDying = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
spriteIndex = _baseSprite + 11;
|
||||
@@ -158,7 +170,6 @@ class HansGrosse extends Enemy {
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return (movement: movement, newAngle: newAngle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (isAlerted && distance < 1.0) {
|
||||
state = EntityState.attacking;
|
||||
lastActionTime = elapsedMs;
|
||||
_hasBittenThisCycle = false;
|
||||
if (processTics(elapsedDeltaMs, moveSpeed: speed)) {
|
||||
currentFrame = (currentFrame + 1) % 4;
|
||||
setTics(5);
|
||||
|
||||
if (isAlerted && distance < 1.0) {
|
||||
state = EntityState.attacking;
|
||||
currentFrame = 0;
|
||||
lastActionTime = elapsedMs;
|
||||
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) {
|
||||
state = EntityState.patrolling;
|
||||
lastActionTime = elapsedMs;
|
||||
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;
|
||||
currentFrame = 0;
|
||||
setTics(5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
double diff = angleToPlayer - newAngle;
|
||||
_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,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
onDamagePlayer(damage);
|
||||
_hasFiredThisCycle = true;
|
||||
} else if (timeShooting >= 300) {
|
||||
state = EntityState.patrolling;
|
||||
lastActionTime = elapsedMs;
|
||||
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
|
||||
currentFrame++;
|
||||
if (currentFrame == 1) {
|
||||
onDamagePlayer(damage);
|
||||
setTics(4);
|
||||
} else if (currentFrame == 2) {
|
||||
setTics(4);
|
||||
} else {
|
||||
state = EntityState.patrolling;
|
||||
currentFrame = 0;
|
||||
setTics(6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state == EntityState.pain && elapsedMs - lastActionTime > 250) {
|
||||
state = EntityState.patrolling;
|
||||
lastActionTime = elapsedMs;
|
||||
if (state == EntityState.pain) {
|
||||
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
|
||||
state = EntityState.patrolling;
|
||||
currentFrame = 0;
|
||||
setTics(5);
|
||||
}
|
||||
}
|
||||
|
||||
return (movement: movement, newAngle: newAngle);
|
||||
|
||||
@@ -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) {
|
||||
onDamagePlayer(damage);
|
||||
_hasFiredThisCycle = true;
|
||||
} else if (timeShooting >= 450) {
|
||||
state = EntityState.patrolling;
|
||||
lastActionTime = elapsedMs;
|
||||
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
|
||||
currentFrame++;
|
||||
if (currentFrame == 1) {
|
||||
onDamagePlayer(damage);
|
||||
setTics(4); // Bang!
|
||||
} else if (currentFrame == 2) {
|
||||
setTics(4); // Cooldown
|
||||
} else {
|
||||
state = EntityState.patrolling;
|
||||
currentFrame = 0;
|
||||
setTics(8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state == EntityState.pain && elapsedMs - lastActionTime > 250) {
|
||||
state = EntityState.patrolling;
|
||||
lastActionTime = elapsedMs;
|
||||
if (state == EntityState.pain) {
|
||||
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
|
||||
state = EntityState.patrolling;
|
||||
currentFrame = 0;
|
||||
setTics(5);
|
||||
}
|
||||
}
|
||||
|
||||
return (movement: movement, newAngle: newAngle);
|
||||
|
||||
@@ -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) {
|
||||
onDamagePlayer(damage);
|
||||
_hasFiredThisCycle = true;
|
||||
} else if (timeShooting >= 300) {
|
||||
state = EntityState.patrolling;
|
||||
lastActionTime = elapsedMs;
|
||||
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
|
||||
currentFrame++;
|
||||
if (currentFrame == 1) {
|
||||
onDamagePlayer(damage);
|
||||
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;
|
||||
currentFrame = 0;
|
||||
setTics(8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state == EntityState.pain && elapsedMs - lastActionTime > 250) {
|
||||
state = EntityState.patrolling;
|
||||
lastActionTime = elapsedMs;
|
||||
if (state == EntityState.pain) {
|
||||
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
|
||||
state = EntityState.patrolling;
|
||||
currentFrame = 0;
|
||||
setTics(5);
|
||||
}
|
||||
}
|
||||
|
||||
return (movement: movement, newAngle: newAngle);
|
||||
|
||||
Reference in New Issue
Block a user