From 2ff7e04ba4b1471af9c90f421c29b1d872fa2488 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Tue, 17 Mar 2026 12:46:27 +0100 Subject: [PATCH] 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 --- .../src/data_types/sprite_frame_range.dart | 21 +++ .../entities/entities/enemies/enemy_type.dart | 143 +++++++++++++++++- 2 files changed, 159 insertions(+), 5 deletions(-) diff --git a/packages/wolf_3d_dart/lib/src/data_types/sprite_frame_range.dart b/packages/wolf_3d_dart/lib/src/data_types/sprite_frame_range.dart index bd55592..1654b09 100644 --- a/packages/wolf_3d_dart/lib/src/data_types/sprite_frame_range.dart +++ b/packages/wolf_3d_dart/lib/src/data_types/sprite_frame_range.dart @@ -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; + } } diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy_type.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy_type.dart index c37dafa..8f93b5b 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy_type.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy_type.dart @@ -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!', + // ); + // } + // } + // } + // } + } }