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.
///
/// In Wolf3D, each enemy type (Guard, SS, etc.) occupies a block of 48 IDs
/// in the map object layer to account for behavior, direction, and difficulty.
/// Enemies in Wolf3D use a base 8-ID block (4 static, 4 patrol) and jump by
/// a tier offset (36 for most, 18 for Mutants) for higher difficulties.
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;
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.
bool claimsId(int id) => id >= baseId && id < baseId + 48;
const EnemyMapData(this.baseId, {this.tierOffset = 36});
/// Returns true if the ID represents a patrolling version for the [difficulty].
bool isPatrolForDifficulty(int id, Difficulty difficulty) {
// Patrolling enemies occupy the first 16 IDs (4 per difficulty level)
int start = baseId + (difficulty.level * 4);
return id >= start && id < start + 4;
/// Evaluates an [id] to see if it belongs to this enemy type and is valid
/// for the current [difficulty].
///
/// Returns the normalized "Easy" base ID (e.g., 108-115 for a Guard) if
/// 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;
}
// 2. Check Medium Tier (+1 tierOffset)
int mediumBase = baseId + tierOffset;
if (id >= mediumBase && id < mediumBase + 8) {
// In Dart enum, Medium (Bring 'em on!) is level 1.
if (difficulty.level < 1) return null;
return id - tierOffset; // Shift down to base Easy IDs
}
// 3. Check Hard Tier (+2 tierOffsets)
int hardBase = baseId + (tierOffset * 2);
if (id >= hardBase && id < hardBase + 8) {
// In Dart enum, Hard (Death incarnate!) is level 2.
if (difficulty.level < 2) return null;
return id - (tierOffset * 2); // Shift down to base Easy IDs
}
return null;
}
/// Returns true if the ID represents a standing (static) version for the [difficulty].
bool isStaticForDifficulty(int id, Difficulty difficulty) {
// Standing enemies occupy the next 16 IDs
int start = baseId + 16 + (difficulty.level * 4);
return id >= start && id < start + 4;
/// 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 ID represents an "Ambush" version for the [difficulty].
/// Ambush enemies wait for the player to enter their line of sight before moving.
bool isAmbushForDifficulty(int id, Difficulty difficulty) {
// Ambush enemies occupy the final 16 IDs of the block
int start = baseId + 32 + (difficulty.level * 4);
return id >= start && id < start + 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!
if (entity.state == EntityState.idle ||
entity.state == EntityState.chasing) {
entity.state == EntityState.ambushing) {
entity.state = EntityState.patrolling;
entity.lastActionTime = _timeAliveMs;
}

View File

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

View File

@@ -72,7 +72,7 @@ class Dog extends Enemy {
}
if (currentFrame >= 5) {
state = EntityState.chasing;
state = EntityState.ambushing;
currentFrame = 0;
}
setTics(10);
@@ -84,8 +84,8 @@ class Dog extends Enemy {
double ticsThisFrame = elapsedDeltaMs / 14.28;
double currentMoveSpeed = speedPerTic * ticsThisFrame;
if (isAlerted || state == EntityState.chasing) {
state = EntityState.chasing;
if (isAlerted || state == EntityState.ambushing) {
state = EntityState.ambushing;
double dx = (position.x - playerPosition.x).abs() - currentMoveSpeed;
double dy = (position.y - playerPosition.y).abs() - currentMoveSpeed;
@@ -118,7 +118,7 @@ class Dog extends Enemy {
),
playerPosition: playerPosition,
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) {},
);
@@ -162,7 +162,7 @@ class Dog extends Enemy {
}
EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling || EntityState.chasing => EnemyAnimation.walking,
EntityState.patrolling || EntityState.ambushing => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
@@ -176,7 +176,7 @@ class Dog extends Enemy {
angleDiff: diff,
walkFrameOverride:
(state == EntityState.patrolling ||
state == EntityState.chasing ||
state == EntityState.ambushing ||
isAlerted)
? currentFrame
: null,

View File

@@ -165,7 +165,7 @@ abstract class Enemy extends Entity {
// Reaction delay has passed
isAlerted = true;
if (state == EntityState.idle || state == EntityState.chasing) {
if (state == EntityState.idle || state == EntityState.ambushing) {
state = EntityState.patrolling;
setTics(10);
}
@@ -339,31 +339,39 @@ abstract class Enemy extends Entity {
return null;
}
final type = EnemyType.fromMapId(objId);
if (type == null) return null;
EnemyType? matchedType;
int? normalizedId;
// Check version compatibility
if (isSharewareMode && !type.existsInShareware) return null;
// Find which enemy type claims this ID for the current difficulty
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;
if (mapData.isPatrolForDifficulty(objId, difficulty)) {
if (matchedType.mapData.isPatrol(normalizedId)) {
spawnState = EntityState.patrolling;
} else if (mapData.isStaticForDifficulty(objId, difficulty)) {
} else if (matchedType.mapData.isStatic(normalizedId)) {
spawnState = EntityState.idle;
} else if (mapData.isAmbushForDifficulty(objId, difficulty)) {
spawnState = EntityState.chasing;
} else {
// The ID belongs to this enemy type, but not for this specific difficulty level
return null;
}
// Return the specific instance
return switch (type) {
return switch (matchedType) {
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),

View File

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

View File

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

View File

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

View File

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

View File

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