353 lines
11 KiB
Dart
353 lines
11 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:wolf_dart/classes/coordinate_2d.dart';
|
|
import 'package:wolf_dart/features/difficulty/difficulty.dart';
|
|
import 'package:wolf_dart/features/entities/enemies/dog.dart';
|
|
import 'package:wolf_dart/features/entities/enemies/guard.dart';
|
|
import 'package:wolf_dart/features/entities/enemies/mutant.dart';
|
|
import 'package:wolf_dart/features/entities/enemies/officer.dart';
|
|
import 'package:wolf_dart/features/entities/enemies/ss.dart';
|
|
import 'package:wolf_dart/features/entities/entity.dart';
|
|
import 'package:wolf_dart/features/entities/map_objects.dart';
|
|
|
|
enum EnemyAnimation {
|
|
idle,
|
|
walking,
|
|
attacking,
|
|
pain,
|
|
dying,
|
|
dead,
|
|
}
|
|
|
|
enum EnemyType {
|
|
guard(mapBaseId: 108, spriteBaseIdx: 50),
|
|
dog(mapBaseId: 216, spriteBaseIdx: 99),
|
|
ss(mapBaseId: 180, spriteBaseIdx: 138),
|
|
mutant(mapBaseId: 252, spriteBaseIdx: 187),
|
|
officer(mapBaseId: 144, spriteBaseIdx: 238),
|
|
;
|
|
|
|
final int mapBaseId;
|
|
final int spriteBaseIdx;
|
|
|
|
const EnemyType({
|
|
required this.mapBaseId,
|
|
required this.spriteBaseIdx,
|
|
});
|
|
|
|
/// Helper to check if a specific TED5 Map ID belongs to this enemy
|
|
bool claimsMapId(int id) => id >= mapBaseId && id <= mapBaseId + 35;
|
|
|
|
/// Helper to find which EnemyType a given Map ID belongs to
|
|
static EnemyType? fromMapId(int id) {
|
|
for (final type in EnemyType.values) {
|
|
if (type.claimsMapId(id)) return type;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
bool claimsSpriteIndex(int index) {
|
|
return switch (this) {
|
|
// Walk, Action, & Death: 50-98
|
|
EnemyType.guard => index >= 50 && index <= 98,
|
|
// Walk, Action, & Death: 99-137
|
|
EnemyType.dog => index >= 99 && index <= 137,
|
|
// Walk, Action, & Death: 138-186
|
|
EnemyType.ss => index >= 138 && index <= 186,
|
|
// Walk, Action, & Death: 187-237
|
|
EnemyType.mutant => index >= 187 && index <= 237,
|
|
// Walk, Action, & Death: 238-287
|
|
EnemyType.officer => index >= 238 && index <= 287,
|
|
};
|
|
}
|
|
|
|
/// Returns the current animation state for a given sprite index.
|
|
/// Returns null if the sprite index does not belong to this enemy.
|
|
EnemyAnimation? getAnimationFromSprite(int spriteIndex) {
|
|
if (!claimsSpriteIndex(spriteIndex)) return null;
|
|
|
|
// By working with offsets, we don't have to hardcode the 100+ sprite indices!
|
|
int offset = spriteIndex - spriteBaseIdx;
|
|
|
|
// All standard enemies use offsets 0-7 for their 8 directional Idle frames
|
|
if (offset >= 0 && offset <= 7) return EnemyAnimation.idle;
|
|
|
|
// The action frames vary slightly depending on the enemy type
|
|
return switch (this) {
|
|
EnemyType.guard || EnemyType.ss => switch (offset) {
|
|
>= 8 && <= 39 => EnemyAnimation.walking, // 4 frames * 8 directions
|
|
>= 40 && <= 42 => EnemyAnimation.attacking, // Aim, Fire, Recoil
|
|
43 => EnemyAnimation.pain,
|
|
>= 44 && <= 46 => EnemyAnimation.dying,
|
|
_ => EnemyAnimation.dead, // Catch-all for final frames
|
|
},
|
|
|
|
EnemyType.officer || EnemyType.mutant => switch (offset) {
|
|
>= 8 && <= 39 => EnemyAnimation.walking,
|
|
>= 40 && <= 41 => EnemyAnimation.attacking, // Only 2 attack frames!
|
|
42 => EnemyAnimation.pain,
|
|
>= 43 && <= 45 => EnemyAnimation.dying,
|
|
_ => EnemyAnimation.dead,
|
|
},
|
|
|
|
EnemyType.dog => switch (offset) {
|
|
// Dogs are special: 3 walk frames (24 total) and NO pain frame!
|
|
>= 8 && <= 31 => EnemyAnimation.walking,
|
|
>= 32 && <= 34 => EnemyAnimation.attacking, // Leap and bite
|
|
>= 35 && <= 37 => EnemyAnimation.dying,
|
|
_ => EnemyAnimation.dead,
|
|
},
|
|
};
|
|
}
|
|
|
|
int getSpriteFromAnimation({
|
|
required EnemyAnimation animation,
|
|
required int elapsedMs,
|
|
required int lastActionTime,
|
|
double angleDiff = 0,
|
|
int? walkFrameOverride, // Optional for custom timing
|
|
}) {
|
|
// 1. Calculate Octant for directional sprites (Idle/Walk)
|
|
int octant = ((angleDiff + (math.pi / 8)) / (math.pi / 4)).floor() % 8;
|
|
if (octant < 0) octant += 8;
|
|
|
|
return switch (animation) {
|
|
EnemyAnimation.idle => spriteBaseIdx + octant,
|
|
|
|
EnemyAnimation.walking => () {
|
|
int frameCount = this == EnemyType.dog ? 3 : 4;
|
|
int frame = walkFrameOverride ?? (elapsedMs ~/ 150) % frameCount;
|
|
return (spriteBaseIdx + 8) + (frame * 8) + octant;
|
|
}(),
|
|
|
|
EnemyAnimation.attacking => () {
|
|
int time = elapsedMs - lastActionTime;
|
|
return switch (this) {
|
|
EnemyType.guard || EnemyType.ss || EnemyType.dog =>
|
|
spriteBaseIdx +
|
|
(time < 150
|
|
? 40
|
|
: time < 300
|
|
? 41
|
|
: 40),
|
|
EnemyType.officer ||
|
|
EnemyType.mutant => spriteBaseIdx + (time < 200 ? 40 : 41),
|
|
};
|
|
}(),
|
|
|
|
EnemyAnimation.pain => spriteBaseIdx + (this == EnemyType.dog ? 32 : 42),
|
|
|
|
EnemyAnimation.dying => () {
|
|
int frame = (elapsedMs - lastActionTime) ~/ 150;
|
|
int maxFrames = this == EnemyType.dog ? 2 : 3;
|
|
int offset = this == EnemyType.dog ? 35 : 43;
|
|
return spriteBaseIdx + offset + (frame.clamp(0, maxFrames));
|
|
}(),
|
|
|
|
EnemyAnimation.dead => spriteBaseIdx + (this == EnemyType.dog ? 37 : 45),
|
|
};
|
|
}
|
|
}
|
|
|
|
abstract class Enemy extends Entity {
|
|
Enemy({
|
|
required super.x,
|
|
required super.y,
|
|
required super.spriteIndex,
|
|
super.angle,
|
|
super.state,
|
|
super.mapId,
|
|
super.lastActionTime,
|
|
});
|
|
|
|
int health = 25;
|
|
int damage = 10;
|
|
bool isDying = false;
|
|
bool hasDroppedItem = false;
|
|
|
|
// Replaces ob->temp2 for reaction delays
|
|
int reactionTimeMs = 0;
|
|
|
|
void takeDamage(int amount, int currentTime) {
|
|
if (state == EntityState.dead) return;
|
|
|
|
health -= amount;
|
|
lastActionTime = currentTime;
|
|
|
|
if (health <= 0) {
|
|
state = EntityState.dead;
|
|
isDying = true;
|
|
} else if (math.Random().nextDouble() < 0.5) {
|
|
state = EntityState.pain;
|
|
} else {
|
|
state = EntityState.patrolling;
|
|
}
|
|
}
|
|
|
|
void checkWakeUp({
|
|
required int elapsedMs,
|
|
required Coordinate2D playerPosition,
|
|
required bool Function(int x, int y) isWalkable,
|
|
int baseReactionMs = 200,
|
|
int reactionVarianceMs = 600,
|
|
}) {
|
|
if (state == EntityState.idle &&
|
|
hasLineOfSight(playerPosition, isWalkable)) {
|
|
if (reactionTimeMs == 0) {
|
|
reactionTimeMs =
|
|
elapsedMs +
|
|
baseReactionMs +
|
|
math.Random().nextInt(reactionVarianceMs);
|
|
} else if (elapsedMs >= reactionTimeMs) {
|
|
state = EntityState.patrolling;
|
|
lastActionTime = elapsedMs;
|
|
reactionTimeMs = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Matches WL_STATE.C's 'CheckLine' using canonical Integer DDA traversal
|
|
bool hasLineOfSight(
|
|
Coordinate2D playerPosition,
|
|
bool Function(int x, int y) isWalkable,
|
|
) {
|
|
// 1. Proximity Check (Matches WL_STATE.C 'MINSIGHT')
|
|
// If the player is very close, sight is automatic regardless of facing angle.
|
|
// This compensates for our lack of a noise/gunshot alert system!
|
|
if (position.distanceTo(playerPosition) < 1.2) {
|
|
return true;
|
|
}
|
|
|
|
// 2. FOV Check (Matches original sight angles)
|
|
double angleToPlayer = position.angleTo(playerPosition);
|
|
double diff = angle - angleToPlayer;
|
|
|
|
while (diff <= -math.pi) {
|
|
diff += 2 * math.pi;
|
|
}
|
|
while (diff > math.pi) {
|
|
diff -= 2 * math.pi;
|
|
}
|
|
|
|
if (diff.abs() > math.pi / 2) return false;
|
|
|
|
// 3. Map Check (Corrected Integer Bresenham)
|
|
int currentX = position.x.toInt();
|
|
int currentY = position.y.toInt();
|
|
int targetX = playerPosition.x.toInt();
|
|
int targetY = playerPosition.y.toInt();
|
|
|
|
int dx = (targetX - currentX).abs();
|
|
int dy = -(targetY - currentY).abs();
|
|
int sx = currentX < targetX ? 1 : -1;
|
|
int sy = currentY < targetY ? 1 : -1;
|
|
int err = dx + dy;
|
|
|
|
while (true) {
|
|
if (!isWalkable(currentX, currentY)) return false;
|
|
if (currentX == targetX && currentY == targetY) break;
|
|
|
|
int e2 = 2 * err;
|
|
if (e2 >= dy) {
|
|
err += dy;
|
|
currentX += sx;
|
|
}
|
|
if (e2 <= dx) {
|
|
err += dx;
|
|
currentY += sy;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
Coordinate2D getValidMovement(
|
|
Coordinate2D intendedMovement,
|
|
bool Function(int x, int y) isWalkable,
|
|
void Function(int x, int y) tryOpenDoor,
|
|
) {
|
|
double newX = position.x + intendedMovement.x;
|
|
double newY = position.y + intendedMovement.y;
|
|
|
|
int currentTileX = position.x.toInt();
|
|
int currentTileY = position.y.toInt();
|
|
int targetTileX = newX.toInt();
|
|
int targetTileY = newY.toInt();
|
|
|
|
bool movedX = currentTileX != targetTileX;
|
|
bool movedY = currentTileY != targetTileY;
|
|
|
|
// 1. Check Diagonal Movement
|
|
if (movedX && movedY) {
|
|
bool canMoveX = isWalkable(targetTileX, currentTileY);
|
|
bool canMoveY = isWalkable(currentTileX, targetTileY);
|
|
bool canMoveDiag = isWalkable(targetTileX, targetTileY);
|
|
|
|
if (!canMoveX || !canMoveY || !canMoveDiag) {
|
|
// Trigger doors if they are blocking the path
|
|
if (!canMoveX) tryOpenDoor(targetTileX, currentTileY);
|
|
if (!canMoveY) tryOpenDoor(currentTileX, targetTileY);
|
|
if (!canMoveDiag) tryOpenDoor(targetTileX, targetTileY);
|
|
|
|
if (canMoveX) return Coordinate2D(intendedMovement.x, 0);
|
|
if (canMoveY) return Coordinate2D(0, intendedMovement.y);
|
|
return const Coordinate2D(0, 0);
|
|
}
|
|
}
|
|
|
|
// 2. Check Cardinal Movement
|
|
if (movedX && !movedY) {
|
|
if (!isWalkable(targetTileX, currentTileY)) {
|
|
tryOpenDoor(targetTileX, currentTileY); // Try to open!
|
|
return Coordinate2D(0, intendedMovement.y);
|
|
}
|
|
}
|
|
if (movedY && !movedX) {
|
|
if (!isWalkable(currentTileX, targetTileY)) {
|
|
tryOpenDoor(currentTileX, targetTileY); // Try to open!
|
|
return Coordinate2D(intendedMovement.x, 0);
|
|
}
|
|
}
|
|
|
|
return intendedMovement;
|
|
}
|
|
|
|
// Updated Signature
|
|
({Coordinate2D movement, double newAngle}) update({
|
|
required int elapsedMs,
|
|
required Coordinate2D playerPosition,
|
|
required bool Function(int x, int y) isWalkable,
|
|
required void Function(int x, int y) tryOpenDoor,
|
|
required void Function(int damage) onDamagePlayer,
|
|
});
|
|
|
|
/// Centralized factory to handle all enemy spawning logic
|
|
static Enemy? spawn(
|
|
int objId,
|
|
double x,
|
|
double y,
|
|
Difficulty difficulty, {
|
|
bool isSharewareMode = false,
|
|
}) {
|
|
// 1. Check Difficulty & Compatibility
|
|
if (!MapObject.shouldSpawn(objId, difficulty)) return null;
|
|
|
|
// If the checkbox is checked, block non-Shareware enemies
|
|
if (isSharewareMode && !MapObject.isSharewareCompatible(objId)) return null;
|
|
|
|
final type = EnemyType.fromMapId(objId);
|
|
if (type == null) return null;
|
|
|
|
bool isPatrolling = objId >= type.mapBaseId + 18;
|
|
double spawnAngle = MapObject.getAngle(objId);
|
|
|
|
// 2. Return the specific instance
|
|
return switch (type) {
|
|
EnemyType.guard => Guard(x: x, y: y, angle: spawnAngle, mapId: objId),
|
|
EnemyType.dog => Dog(x: x, y: y, angle: spawnAngle, mapId: objId),
|
|
EnemyType.ss => SS(x: x, y: y, angle: spawnAngle, mapId: objId),
|
|
EnemyType.mutant => Mutant(x: x, y: y, angle: spawnAngle, mapId: objId),
|
|
EnemyType.officer => Officer(x: x, y: y, angle: spawnAngle, mapId: objId),
|
|
}..state = isPatrolling ? EntityState.patrolling : EntityState.idle;
|
|
}
|
|
}
|