@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user