Fix enemy difficulty spawn logic

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-17 18:46:44 +01:00
parent 62fce48527
commit 673f82108d
10 changed files with 78 additions and 49 deletions

View File

@@ -2,36 +2,57 @@ import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
/// Handles identity and difficulty-based logic for enemies in map data. /// Handles identity and difficulty-based logic for enemies in map data.
/// ///
/// In Wolf3D, each enemy type (Guard, SS, etc.) occupies a block of 48 IDs /// Enemies in Wolf3D use a base 8-ID block (4 static, 4 patrol) and jump by
/// in the map object layer to account for behavior, direction, and difficulty. /// a tier offset (36 for most, 18 for Mutants) for higher difficulties.
class EnemyMapData { class EnemyMapData {
/// The starting ID for this specific enemy type (e.g., 108 for Guards). /// The starting ID for the "Easy" tier of this enemy (e.g., 108 for Guards).
final int baseId; final int baseId;
const EnemyMapData(this.baseId); /// The ID jump between difficulty tiers (36 by default, 18 for Mutants).
final int tierOffset;
/// Returns true if the provided [id] belongs to this enemy type's 48-ID block. const EnemyMapData(this.baseId, {this.tierOffset = 36});
bool claimsId(int id) => id >= baseId && id < baseId + 48;
/// Returns true if the ID represents a patrolling version for the [difficulty]. /// Evaluates an [id] to see if it belongs to this enemy type and is valid
bool isPatrolForDifficulty(int id, Difficulty difficulty) { /// for the current [difficulty].
// Patrolling enemies occupy the first 16 IDs (4 per difficulty level) ///
int start = baseId + (difficulty.level * 4); /// Returns the normalized "Easy" base ID (e.g., 108-115 for a Guard) if
return id >= start && id < start + 4; /// the enemy should spawn, or `null` if it should be skipped.
int? getNormalizedId(int id, Difficulty difficulty) {
// 1. Check Easy Tier (Base IDs)
// The original C logic always spawns these regardless of difficulty.
if (id >= baseId && id < baseId + 8) {
return id;
} }
/// Returns true if the ID represents a standing (static) version for the [difficulty]. // 2. Check Medium Tier (+1 tierOffset)
bool isStaticForDifficulty(int id, Difficulty difficulty) { int mediumBase = baseId + tierOffset;
// Standing enemies occupy the next 16 IDs if (id >= mediumBase && id < mediumBase + 8) {
int start = baseId + 16 + (difficulty.level * 4); // In Dart enum, Medium (Bring 'em on!) is level 1.
return id >= start && id < start + 4; if (difficulty.level < 1) return null;
return id - tierOffset; // Shift down to base Easy IDs
} }
/// Returns true if the ID represents an "Ambush" version for the [difficulty]. // 3. Check Hard Tier (+2 tierOffsets)
/// Ambush enemies wait for the player to enter their line of sight before moving. int hardBase = baseId + (tierOffset * 2);
bool isAmbushForDifficulty(int id, Difficulty difficulty) { if (id >= hardBase && id < hardBase + 8) {
// Ambush enemies occupy the final 16 IDs of the block // In Dart enum, Hard (Death incarnate!) is level 2.
int start = baseId + 32 + (difficulty.level * 4); if (difficulty.level < 2) return null;
return id >= start && id < start + 4; return id - (tierOffset * 2); // Shift down to base Easy IDs
}
return null;
}
/// Returns true if the normalized ID represents a standing (static) version.
bool isStatic(int normalizedId) {
// The first 4 IDs are always Stand (matching the C switch statements)
return normalizedId >= baseId && normalizedId < baseId + 4;
}
/// Returns true if the normalized ID represents a patrolling version.
bool isPatrol(int normalizedId) {
// The next 4 IDs are always Patrol
return normalizedId >= baseId + 4 && normalizedId < baseId + 8;
} }
} }

View File

@@ -416,7 +416,7 @@ class WolfEngine {
// Wake them up! // Wake them up!
if (entity.state == EntityState.idle || if (entity.state == EntityState.idle ||
entity.state == EntityState.chasing) { entity.state == EntityState.ambushing) {
entity.state = EntityState.patrolling; entity.state = EntityState.patrolling;
entity.lastActionTime = _timeAliveMs; entity.lastActionTime = _timeAliveMs;
} }

View File

@@ -88,7 +88,7 @@ class HansGrosse extends Enemy {
switch (state) { switch (state) {
case EntityState.idle: case EntityState.idle:
case EntityState.chasing: case EntityState.ambushing:
spriteIndex = _baseSprite; spriteIndex = _baseSprite;
break; break;

View File

@@ -72,7 +72,7 @@ class Dog extends Enemy {
} }
if (currentFrame >= 5) { if (currentFrame >= 5) {
state = EntityState.chasing; state = EntityState.ambushing;
currentFrame = 0; currentFrame = 0;
} }
setTics(10); setTics(10);
@@ -84,8 +84,8 @@ class Dog extends Enemy {
double ticsThisFrame = elapsedDeltaMs / 14.28; double ticsThisFrame = elapsedDeltaMs / 14.28;
double currentMoveSpeed = speedPerTic * ticsThisFrame; double currentMoveSpeed = speedPerTic * ticsThisFrame;
if (isAlerted || state == EntityState.chasing) { if (isAlerted || state == EntityState.ambushing) {
state = EntityState.chasing; state = EntityState.ambushing;
double dx = (position.x - playerPosition.x).abs() - currentMoveSpeed; double dx = (position.x - playerPosition.x).abs() - currentMoveSpeed;
double dy = (position.y - playerPosition.y).abs() - currentMoveSpeed; double dy = (position.y - playerPosition.y).abs() - currentMoveSpeed;
@@ -118,7 +118,7 @@ class Dog extends Enemy {
), ),
playerPosition: playerPosition, playerPosition: playerPosition,
isWalkable: isWalkable, isWalkable: isWalkable,
// FIX: Alerted dogs cannot open doors! Pass an empty closure to treat doors as walls. // Alerted dogs cannot open doors! Pass an empty closure to treat doors as walls.
tryOpenDoor: (x, y) {}, tryOpenDoor: (x, y) {},
); );
@@ -162,7 +162,7 @@ class Dog extends Enemy {
} }
EnemyAnimation currentAnim = switch (state) { EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling || EntityState.chasing => EnemyAnimation.walking, EntityState.patrolling || EntityState.ambushing => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking, EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain, EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead, EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
@@ -176,7 +176,7 @@ class Dog extends Enemy {
angleDiff: diff, angleDiff: diff,
walkFrameOverride: walkFrameOverride:
(state == EntityState.patrolling || (state == EntityState.patrolling ||
state == EntityState.chasing || state == EntityState.ambushing ||
isAlerted) isAlerted)
? currentFrame ? currentFrame
: null, : null,

View File

@@ -165,7 +165,7 @@ abstract class Enemy extends Entity {
// Reaction delay has passed // Reaction delay has passed
isAlerted = true; isAlerted = true;
if (state == EntityState.idle || state == EntityState.chasing) { if (state == EntityState.idle || state == EntityState.ambushing) {
state = EntityState.patrolling; state = EntityState.patrolling;
setTics(10); setTics(10);
} }
@@ -339,31 +339,39 @@ abstract class Enemy extends Entity {
return null; return null;
} }
final type = EnemyType.fromMapId(objId); EnemyType? matchedType;
if (type == null) return null; int? normalizedId;
// Check version compatibility // Find which enemy type claims this ID for the current difficulty
if (isSharewareMode && !type.existsInShareware) return null; for (final type in EnemyType.values) {
if (isSharewareMode && !type.existsInShareware) continue;
final mapData = type.mapData; normalizedId = type.mapData.getNormalizedId(objId, difficulty);
if (normalizedId != null) {
matchedType = type;
break;
}
}
// If no type claimed it, or the difficulty was too low, abort spawn
if (matchedType == null || normalizedId == null) return null;
// Resolve spawn orientation using the NORMALIZED ID (0-7 offset from base)
// This prevents offset math bugs (like the Mutant's 18-ID shift) from breaking facing directions.
double spawnAngle = CardinalDirection.fromEnemyIndex(normalizedId).radians;
// Resolve spawn orientation and initial AI state
double spawnAngle = CardinalDirection.fromEnemyIndex(objId).radians;
EntityState spawnState; EntityState spawnState;
if (mapData.isPatrolForDifficulty(objId, difficulty)) { if (matchedType.mapData.isPatrol(normalizedId)) {
spawnState = EntityState.patrolling; spawnState = EntityState.patrolling;
} else if (mapData.isStaticForDifficulty(objId, difficulty)) { } else if (matchedType.mapData.isStatic(normalizedId)) {
spawnState = EntityState.idle; spawnState = EntityState.idle;
} else if (mapData.isAmbushForDifficulty(objId, difficulty)) {
spawnState = EntityState.chasing;
} else { } else {
// The ID belongs to this enemy type, but not for this specific difficulty level
return null; return null;
} }
// Return the specific instance // Return the specific instance
return switch (type) { return switch (matchedType) {
EnemyType.guard => Guard(x: x, y: y, angle: spawnAngle, mapId: objId), EnemyType.guard => Guard(x: x, y: y, angle: spawnAngle, mapId: objId),
EnemyType.dog => Dog(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.ss => SS(x: x, y: y, angle: spawnAngle, mapId: objId),

View File

@@ -132,7 +132,7 @@ class Guard extends Enemy {
} }
EnemyAnimation currentAnim = switch (state) { EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling || EntityState.chasing => EnemyAnimation.walking, EntityState.patrolling || EntityState.ambushing => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking, EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain, EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead, EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,

View File

@@ -57,7 +57,7 @@ class Mutant extends Enemy {
} }
EnemyAnimation currentAnim = switch (state) { EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling || EntityState.chasing => EnemyAnimation.walking, EntityState.patrolling || EntityState.ambushing => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking, EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain, EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead, EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,

View File

@@ -57,7 +57,7 @@ class Officer extends Enemy {
} }
EnemyAnimation currentAnim = switch (state) { EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling || EntityState.chasing => EnemyAnimation.walking, EntityState.patrolling || EntityState.ambushing => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking, EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain, EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead, EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,

View File

@@ -56,7 +56,7 @@ class SS extends Enemy {
} }
EnemyAnimation currentAnim = switch (state) { EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling || EntityState.chasing => EnemyAnimation.walking, EntityState.patrolling || EntityState.ambushing => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking, EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain, EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead, EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,

View File

@@ -6,7 +6,7 @@ enum EntityState {
staticObj, staticObj,
/// Enemies waiting for the player to enter their field of view. /// Enemies waiting for the player to enter their field of view.
chasing, ambushing,
/// Enemies standing still but capable of hearing noise. /// Enemies standing still but capable of hearing noise.
idle, idle,