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,
/// with the ability to skip specific frames.
class SpriteFrameRange {
@@ -25,4 +27,23 @@ class SpriteFrameRange {
bool contains(int 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 '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/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 {
final SpriteFrameRange idle;
final SpriteFrameRange walking;
@@ -20,6 +24,9 @@ class EnemyAnimationMap {
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) {
if (idle.contains(spriteIndex)) return EnemyAnimation.idle;
if (walking.contains(spriteIndex)) return EnemyAnimation.walking;
@@ -30,6 +37,7 @@ class EnemyAnimationMap {
return null;
}
/// Returns the [SpriteFrameRange] associated with a specific [animation] state.
SpriteFrameRange getRange(EnemyAnimation animation) {
return switch (animation) {
EnemyAnimation.idle => idle,
@@ -40,9 +48,85 @@ class EnemyAnimationMap {
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 {
/// Standard Brown Guard (The most common enemy).
guard(
mapData: EnemyMapData(MapObject.guardStart),
animations: EnemyAnimationMap(
@@ -54,6 +138,8 @@ enum EnemyType {
attacking: SpriteFrameRange(96, 98),
),
),
/// Attack Dog (Fast melee enemy).
dog(
mapData: EnemyMapData(MapObject.dogStart),
animations: EnemyAnimationMap(
@@ -65,6 +151,8 @@ enum EnemyType {
dead: SpriteFrameRange(137, 137),
),
),
/// SS Officer (Blue uniform, machine gun).
ss(
mapData: EnemyMapData(MapObject.ssStart),
animations: EnemyAnimationMap(
@@ -76,6 +164,8 @@ enum EnemyType {
dead: SpriteFrameRange(183, 183),
),
),
/// Undead Mutant (Exclusive to later episodes/retail).
mutant(
mapData: EnemyMapData(MapObject.mutantStart),
animations: EnemyAnimationMap(
@@ -88,6 +178,8 @@ enum EnemyType {
),
existsInShareware: false,
),
/// High-ranking Officer (White uniform, fast pistol).
officer(
mapData: EnemyMapData(MapObject.officerStart),
animations: EnemyAnimationMap(
@@ -99,10 +191,16 @@ enum EnemyType {
dead: SpriteFrameRange(283, 283),
),
existsInShareware: false,
);
)
;
/// Logic for identifying the enemy block in map data.
final EnemyMapData mapData;
/// The animation ranges for this enemy type.
final EnemyAnimationMap animations;
/// If false, this enemy type will be ignored when loading shareware data.
final bool existsInShareware;
const EnemyType({
@@ -111,6 +209,7 @@ enum EnemyType {
this.existsInShareware = true,
});
/// Identifies an [EnemyType] based on a tile ID from the map's object layer.
static EnemyType? fromMapId(int id) {
for (final type in EnemyType.values) {
if (type.mapData.claimsId(id)) return type;
@@ -118,16 +217,18 @@ enum EnemyType {
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) {
if (isShareware && !existsInShareware) return null;
return animations;
}
/// Checks if a [spriteIndex] belongs to this enemy type.
bool claimsSpriteIndex(int index, {bool isShareware = false}) {
return getAnimations(isShareware)?.getAnimation(index) != null;
}
/// Resolves the [EnemyAnimation] state for a specific [spriteIndex].
EnemyAnimation? getAnimationFromSprite(
int spriteIndex, {
bool isShareware = false,
@@ -135,6 +236,10 @@ enum EnemyType {
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({
required EnemyAnimation animation,
required int elapsedMs,
@@ -142,15 +247,19 @@ enum EnemyType {
double angleDiff = 0,
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);
// --- 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;
if (octant < 0) octant += 8;
return switch (animation) {
// Idle sprites are stored sequentially for all 8 octants.
EnemyAnimation.idle => range.start + octant,
// Walking frames alternate: [Frame 1 Octants 0-7], [Frame 2 Octants 0-7], etc.
EnemyAnimation.walking => () {
int framesPerAngle = range.length ~/ 8;
if (framesPerAngle < 1) framesPerAngle = 1;
@@ -158,18 +267,42 @@ enum EnemyType {
int frame = walkFrameOverride ?? (elapsedMs ~/ 150) % framesPerAngle;
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 => () {
int time = elapsedMs - lastActionTime;
int mappedFrame = (time ~/ 150).clamp(0, range.length - 1);
return range.start + mappedFrame;
}(),
EnemyAnimation.pain => range.start,
EnemyAnimation.dying => () {
int time = elapsedMs - lastActionTime;
int mappedFrame = (time ~/ 150).clamp(0, range.length - 1);
return range.start + mappedFrame;
}(),
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!',
// );
// }
// }
// }
// }
}
}