diff --git a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart index 2be75f3..c464693 100644 --- a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart +++ b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart @@ -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(); diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/bosses/hans_grosse.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/bosses/hans_grosse.dart index b126de8..0539d95 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/bosses/hans_grosse.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/bosses/hans_grosse.dart @@ -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); } } diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/dog.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/dog.dart index 073bcf4..c02d8a1 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/dog.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/dog.dart @@ -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); + } } } diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy.dart index 0a68aa0..c51ea48 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy.dart @@ -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, diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/guard.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/guard.dart index 81de336..d0671ff 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/guard.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/guard.dart @@ -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); } } diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/mutant.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/mutant.dart index 6ea4914..4e1bd32 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/mutant.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/mutant.dart @@ -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); diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/officer.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/officer.dart index 5539eea..d673d97 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/officer.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/officer.dart @@ -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); diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/ss.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/ss.dart index 9ffc60c..09736aa 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/ss.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/ss.dart @@ -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);