Added dartdoc comments for enemy types and sprite frame ranges. Added helper methods to enemy types to check for overlapping frames.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-17 12:46:27 +01:00
parent 556f89e076
commit 2ff7e04ba4
2 changed files with 159 additions and 5 deletions

View File

@@ -1,3 +1,5 @@
import 'dart:math' as math;
/// Represents a slice of indices within a sprite list for animation states, /// Represents a slice of indices within a sprite list for animation states,
/// with the ability to skip specific frames. /// with the ability to skip specific frames.
class SpriteFrameRange { class SpriteFrameRange {
@@ -25,4 +27,23 @@ class SpriteFrameRange {
bool contains(int index) { bool contains(int index) {
return index >= start && index <= end && !excluded.contains(index); return index >= start && index <= end && !excluded.contains(index);
} }
/// Checks if this range shares any valid (non-excluded) frames with [other].
bool overlaps(SpriteFrameRange other) {
// Find the overlapping boundary
final overlapStart = math.max(start, other.start);
final overlapEnd = math.min(end, other.end);
// If bounds don't intersect at all, they don't overlap
if (overlapStart > overlapEnd) return false;
// If they intersect, verify the overlapping frames aren't excluded
for (int i = overlapStart; i <= overlapEnd; i++) {
if (!excluded.contains(i) && !other.excluded.contains(i)) {
return true; // Found a frame that belongs to both!
}
}
return false;
}
} }

View File

@@ -1,8 +1,12 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_animation.dart'; import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_animation.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
/// A mapping container that defines the sprite index ranges for an enemy's life cycle.
///
/// Wolfenstein 3D enemies use specific ranges within the global sprite list
/// to handle state transitions like walking, attacking, or dying.
class EnemyAnimationMap { class EnemyAnimationMap {
final SpriteFrameRange idle; final SpriteFrameRange idle;
final SpriteFrameRange walking; final SpriteFrameRange walking;
@@ -20,6 +24,9 @@ class EnemyAnimationMap {
required this.dead, required this.dead,
}); });
/// Identifies which [EnemyAnimation] state a specific [spriteIndex] belongs to.
///
/// Returns `null` if the index is outside this enemy's defined animation ranges.
EnemyAnimation? getAnimation(int spriteIndex) { EnemyAnimation? getAnimation(int spriteIndex) {
if (idle.contains(spriteIndex)) return EnemyAnimation.idle; if (idle.contains(spriteIndex)) return EnemyAnimation.idle;
if (walking.contains(spriteIndex)) return EnemyAnimation.walking; if (walking.contains(spriteIndex)) return EnemyAnimation.walking;
@@ -30,6 +37,7 @@ class EnemyAnimationMap {
return null; return null;
} }
/// Returns the [SpriteFrameRange] associated with a specific [animation] state.
SpriteFrameRange getRange(EnemyAnimation animation) { SpriteFrameRange getRange(EnemyAnimation animation) {
return switch (animation) { return switch (animation) {
EnemyAnimation.idle => idle, EnemyAnimation.idle => idle,
@@ -40,9 +48,85 @@ class EnemyAnimationMap {
EnemyAnimation.dead => dead, EnemyAnimation.dead => dead,
}; };
} }
/// Checks if any animation range in this map overlaps with any range in [other].
bool overlapsWith(EnemyAnimationMap other) {
final myRanges = [idle, walking, attacking, pain, dying, dead];
final otherRanges = [
other.idle,
other.walking,
other.attacking,
other.pain,
other.dying,
other.dead,
];
for (final myRange in myRanges) {
for (final otherRange in otherRanges) {
if (myRange.overlaps(otherRange)) {
return true;
}
}
}
return false;
}
/// Checks if any animation ranges within this specific enemy overlap with each other.
///
/// Useful for validating that an enemy isn't trying to use the same sprite
/// for two different states (e.g., walking and attacking).
bool hasInternalOverlaps() {
final ranges = [idle, walking, attacking, pain, dying, dead];
// Compare every unique pair of ranges
for (int i = 0; i < ranges.length; i++) {
for (int j = i + 1; j < ranges.length; j++) {
if (ranges[i].overlaps(ranges[j])) {
return true;
}
}
}
return false;
}
void validateEnemyAnimations() {
bool hasErrors = false;
for (final enemy in EnemyType.values) {
// 1. Check for internal overlaps (e.g., Guard walking overlaps Guard attacking)
if (enemy.animations.hasInternalOverlaps()) {
print(
'❌ ERROR: ${enemy.name} has overlapping internal animation states!',
);
hasErrors = true;
}
// 2. Check for external overlaps (e.g., Guard sprites overlap SS sprites)
for (final otherEnemy in EnemyType.values) {
if (enemy != otherEnemy && enemy.sharesFramesWith(otherEnemy)) {
print(
'❌ ERROR: ${enemy.name} shares sprite frames with ${otherEnemy.name}!',
);
hasErrors = true;
}
}
}
if (!hasErrors) {
print(
'✅ All enemy animations validated successfully! No overlaps found.',
);
}
}
} }
/// Defines the primary enemy types and their behavioral metadata.
///
/// This enum maps level object IDs to their corresponding sprite ranges and
/// handles version-specific availability (e.g., Mutants only appearing in Retail).
enum EnemyType { enum EnemyType {
/// Standard Brown Guard (The most common enemy).
guard( guard(
mapData: EnemyMapData(MapObject.guardStart), mapData: EnemyMapData(MapObject.guardStart),
animations: EnemyAnimationMap( animations: EnemyAnimationMap(
@@ -54,6 +138,8 @@ enum EnemyType {
attacking: SpriteFrameRange(96, 98), attacking: SpriteFrameRange(96, 98),
), ),
), ),
/// Attack Dog (Fast melee enemy).
dog( dog(
mapData: EnemyMapData(MapObject.dogStart), mapData: EnemyMapData(MapObject.dogStart),
animations: EnemyAnimationMap( animations: EnemyAnimationMap(
@@ -65,6 +151,8 @@ enum EnemyType {
dead: SpriteFrameRange(137, 137), dead: SpriteFrameRange(137, 137),
), ),
), ),
/// SS Officer (Blue uniform, machine gun).
ss( ss(
mapData: EnemyMapData(MapObject.ssStart), mapData: EnemyMapData(MapObject.ssStart),
animations: EnemyAnimationMap( animations: EnemyAnimationMap(
@@ -76,6 +164,8 @@ enum EnemyType {
dead: SpriteFrameRange(183, 183), dead: SpriteFrameRange(183, 183),
), ),
), ),
/// Undead Mutant (Exclusive to later episodes/retail).
mutant( mutant(
mapData: EnemyMapData(MapObject.mutantStart), mapData: EnemyMapData(MapObject.mutantStart),
animations: EnemyAnimationMap( animations: EnemyAnimationMap(
@@ -88,6 +178,8 @@ enum EnemyType {
), ),
existsInShareware: false, existsInShareware: false,
), ),
/// High-ranking Officer (White uniform, fast pistol).
officer( officer(
mapData: EnemyMapData(MapObject.officerStart), mapData: EnemyMapData(MapObject.officerStart),
animations: EnemyAnimationMap( animations: EnemyAnimationMap(
@@ -99,10 +191,16 @@ enum EnemyType {
dead: SpriteFrameRange(283, 283), dead: SpriteFrameRange(283, 283),
), ),
existsInShareware: false, existsInShareware: false,
); )
;
/// Logic for identifying the enemy block in map data.
final EnemyMapData mapData; final EnemyMapData mapData;
/// The animation ranges for this enemy type.
final EnemyAnimationMap animations; final EnemyAnimationMap animations;
/// If false, this enemy type will be ignored when loading shareware data.
final bool existsInShareware; final bool existsInShareware;
const EnemyType({ const EnemyType({
@@ -111,6 +209,7 @@ enum EnemyType {
this.existsInShareware = true, this.existsInShareware = true,
}); });
/// Identifies an [EnemyType] based on a tile ID from the map's object layer.
static EnemyType? fromMapId(int id) { static EnemyType? fromMapId(int id) {
for (final type in EnemyType.values) { for (final type in EnemyType.values) {
if (type.mapData.claimsId(id)) return type; if (type.mapData.claimsId(id)) return type;
@@ -118,16 +217,18 @@ enum EnemyType {
return null; return null;
} }
/// Returns the animations only if the enemy actually exists in the current version. /// Safely retrieves the animation map based on the active [GameVersion].
EnemyAnimationMap? getAnimations(bool isShareware) { EnemyAnimationMap? getAnimations(bool isShareware) {
if (isShareware && !existsInShareware) return null; if (isShareware && !existsInShareware) return null;
return animations; return animations;
} }
/// Checks if a [spriteIndex] belongs to this enemy type.
bool claimsSpriteIndex(int index, {bool isShareware = false}) { bool claimsSpriteIndex(int index, {bool isShareware = false}) {
return getAnimations(isShareware)?.getAnimation(index) != null; return getAnimations(isShareware)?.getAnimation(index) != null;
} }
/// Resolves the [EnemyAnimation] state for a specific [spriteIndex].
EnemyAnimation? getAnimationFromSprite( EnemyAnimation? getAnimationFromSprite(
int spriteIndex, { int spriteIndex, {
bool isShareware = false, bool isShareware = false,
@@ -135,6 +236,10 @@ enum EnemyType {
return getAnimations(isShareware)?.getAnimation(spriteIndex); return getAnimations(isShareware)?.getAnimation(spriteIndex);
} }
/// Calculates the correct sprite index based on animation state, time, and camera angle.
///
/// Wolfenstein 3D uses "Octant" billboarding, meaning idle and walking states
/// have 8 different rotations based on the angle between the player and the enemy.
int getSpriteFromAnimation({ int getSpriteFromAnimation({
required EnemyAnimation animation, required EnemyAnimation animation,
required int elapsedMs, required int elapsedMs,
@@ -142,15 +247,19 @@ enum EnemyType {
double angleDiff = 0, double angleDiff = 0,
int? walkFrameOverride, int? walkFrameOverride,
}) { }) {
// We don't need to check isShareware here, because if the entity exists
// in the game world, it implicitly passed the version check during spawn.
final range = animations.getRange(animation); final range = animations.getRange(animation);
// --- Octant Calculation ---
// We split the circle into 8 segments (octants).
// Adding pi/8 offsets the segments so "North" covers the +/- 22.5 degree range.
int octant = ((angleDiff + (math.pi / 8)) / (math.pi / 4)).floor() % 8; int octant = ((angleDiff + (math.pi / 8)) / (math.pi / 4)).floor() % 8;
if (octant < 0) octant += 8; if (octant < 0) octant += 8;
return switch (animation) { return switch (animation) {
// Idle sprites are stored sequentially for all 8 octants.
EnemyAnimation.idle => range.start + octant, EnemyAnimation.idle => range.start + octant,
// Walking frames alternate: [Frame 1 Octants 0-7], [Frame 2 Octants 0-7], etc.
EnemyAnimation.walking => () { EnemyAnimation.walking => () {
int framesPerAngle = range.length ~/ 8; int framesPerAngle = range.length ~/ 8;
if (framesPerAngle < 1) framesPerAngle = 1; if (framesPerAngle < 1) framesPerAngle = 1;
@@ -158,18 +267,42 @@ enum EnemyType {
int frame = walkFrameOverride ?? (elapsedMs ~/ 150) % framesPerAngle; int frame = walkFrameOverride ?? (elapsedMs ~/ 150) % framesPerAngle;
return range.start + (frame * 8) + octant; return range.start + (frame * 8) + octant;
}(), }(),
// Attack and Dying animations are "Global," meaning they look the same
// regardless of which way the enemy is facing.
EnemyAnimation.attacking => () { EnemyAnimation.attacking => () {
int time = elapsedMs - lastActionTime; int time = elapsedMs - lastActionTime;
int mappedFrame = (time ~/ 150).clamp(0, range.length - 1); int mappedFrame = (time ~/ 150).clamp(0, range.length - 1);
return range.start + mappedFrame; return range.start + mappedFrame;
}(), }(),
EnemyAnimation.pain => range.start, EnemyAnimation.pain => range.start,
EnemyAnimation.dying => () { EnemyAnimation.dying => () {
int time = elapsedMs - lastActionTime; int time = elapsedMs - lastActionTime;
int mappedFrame = (time ~/ 150).clamp(0, range.length - 1); int mappedFrame = (time ~/ 150).clamp(0, range.length - 1);
return range.start + mappedFrame; return range.start + mappedFrame;
}(), }(),
EnemyAnimation.dead => range.start, EnemyAnimation.dead => range.start,
}; };
} }
/// Checks if this enemy shares any sprite frames with [other].
bool sharesFramesWith(EnemyType other) {
return animations.overlapsWith(other.animations);
// * Usage example:
// void checkEnemyOverlaps() {
// for (final enemyA in EnemyType.values) {
// for (final enemyB in EnemyType.values) {
// if (enemyA != enemyB && enemyA.sharesFramesWith(enemyB)) {
// print(
// 'Warning: ${enemyA.name} and ${enemyB.name} have overlapping sprites!',
// );
// }
// }
// }
// }
}
} }