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; } }