Fix enemy difficulty spawn logic
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ class HansGrosse extends Enemy {
|
||||
|
||||
switch (state) {
|
||||
case EntityState.idle:
|
||||
case EntityState.chasing:
|
||||
case EntityState.ambushing:
|
||||
spriteIndex = _baseSprite;
|
||||
break;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user