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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!',
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user