Refactor enemy sound handling and improve enemy type definitions
- Updated enemy classes to include alert and attack sound IDs. - Refactored checkWakeUp and attack logic to play sounds appropriately. - Enhanced enemy type definitions with sound mappings. - Added unit tests for enemy spawn and validation. Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
@@ -89,13 +89,13 @@ abstract class MapObject {
|
|||||||
static const int bossFettgesicht = 223;
|
static const int bossFettgesicht = 223;
|
||||||
|
|
||||||
// --- Enemy Range Constants ---
|
// --- Enemy Range Constants ---
|
||||||
// Every enemy type occupies a block of 36 IDs.
|
// Enemies are encoded in 8-ID blocks: 4 standing directions and 4 patrol directions.
|
||||||
// Modulo math is used on these ranges to determine orientation and patrol status.
|
// Most enemy families shift by 36 IDs between difficulty tiers, while mutants shift by 18.
|
||||||
static const int guardStart = 108; // Claims 108-115, 144-151, 180-187
|
static const int guardStart = 108; // Claims 108-115, 144-151, 180-187
|
||||||
static const int dogStart = 116; // Claims 116-123, 152-159, 188-195
|
static const int officerStart = 116; // Claims 116-123, 152-159, 188-195
|
||||||
static const int ssStart = 126; // Claims 126-133, 162-169, 198-205
|
static const int ssStart = 126; // Claims 126-133, 162-169, 198-205
|
||||||
static const int mutantStart = 136; // Claims 136-143, 172-179, 208-215
|
static const int dogStart = 134; // Claims 134-141, 170-177, 206-213
|
||||||
static const int officerStart = 252; // Claims 252-259, 288-295, 324-331
|
static const int mutantStart = 216; // Claims 216-223, 234-241, 252-259
|
||||||
|
|
||||||
// --- Missing Decorative Bodies ---
|
// --- Missing Decorative Bodies ---
|
||||||
static const int deadGuard = 124; // Decorative only in WL1
|
static const int deadGuard = 124; // Decorative only in WL1
|
||||||
@@ -119,9 +119,14 @@ abstract class MapObject {
|
|||||||
final EnemyType? type = EnemyType.fromMapId(id);
|
final EnemyType? type = EnemyType.fromMapId(id);
|
||||||
if (type == null) return 0.0;
|
if (type == null) return 0.0;
|
||||||
|
|
||||||
// Because all enemies are grouped in blocks of 4 (one for each CardinalDirection),
|
final int? normalizedId = type.mapData.getNormalizedId(id, Difficulty.hard);
|
||||||
// modulo 4 extracts the specific orientation index.
|
if (normalizedId == null) return 0.0;
|
||||||
return CardinalDirection.fromEnemyIndex(id % 4).radians;
|
|
||||||
|
// Enemy IDs are stored as 8-ID blocks per family.
|
||||||
|
// Offsets 0-3 are standing directions and 4-7 are patrol directions.
|
||||||
|
return CardinalDirection.fromEnemyIndex(
|
||||||
|
normalizedId - type.mapData.baseId,
|
||||||
|
).radians;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates if an object should exist on the map given the current [difficulty].
|
/// Validates if an object should exist on the map given the current [difficulty].
|
||||||
|
|||||||
@@ -197,8 +197,8 @@ class Player {
|
|||||||
return pickedUp;
|
return pickedUp;
|
||||||
}
|
}
|
||||||
|
|
||||||
void fire(int currentTime) {
|
bool fire(int currentTime) {
|
||||||
if (switchState != WeaponSwitchState.idle) return;
|
if (switchState != WeaponSwitchState.idle) return false;
|
||||||
|
|
||||||
// We pass the isFiring state to handle automatic vs semi-auto behavior
|
// We pass the isFiring state to handle automatic vs semi-auto behavior
|
||||||
bool shotFired = currentWeapon.fire(currentTime, currentAmmo: ammo);
|
bool shotFired = currentWeapon.fire(currentTime, currentAmmo: ammo);
|
||||||
@@ -206,6 +206,8 @@ class Player {
|
|||||||
if (shotFired && currentWeapon.type != WeaponType.knife) {
|
if (shotFired && currentWeapon.type != WeaponType.knife) {
|
||||||
ammo--;
|
ammo--;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return shotFired;
|
||||||
}
|
}
|
||||||
|
|
||||||
void releaseTrigger() {
|
void releaseTrigger() {
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ class WolfEngine {
|
|||||||
|
|
||||||
/// Initializes the engine, sets the starting episode, and loads the first level.
|
/// Initializes the engine, sets the starting episode, and loads the first level.
|
||||||
void init() {
|
void init() {
|
||||||
|
audio.activeGame = data;
|
||||||
_currentEpisodeIndex = startingEpisode;
|
_currentEpisodeIndex = startingEpisode;
|
||||||
_currentLevelIndex = 0;
|
_currentLevelIndex = 0;
|
||||||
_loadLevel();
|
_loadLevel();
|
||||||
@@ -247,9 +248,13 @@ class WolfEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (input.isFiring) {
|
if (input.isFiring) {
|
||||||
player.fire(_timeAliveMs);
|
final bool shotFired = player.fire(_timeAliveMs);
|
||||||
|
|
||||||
if (_timeAliveMs - _lastAcousticAlertTime > 400) {
|
if (shotFired) {
|
||||||
|
_playPlayerWeaponSound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shotFired && _timeAliveMs - _lastAcousticAlertTime > 400) {
|
||||||
_propagateGunfire();
|
_propagateGunfire();
|
||||||
_lastAcousticAlertTime = _timeAliveMs;
|
_lastAcousticAlertTime = _timeAliveMs;
|
||||||
}
|
}
|
||||||
@@ -356,6 +361,7 @@ class WolfEngine {
|
|||||||
isWalkable: isWalkable,
|
isWalkable: isWalkable,
|
||||||
tryOpenDoor: doorManager.tryOpenDoor,
|
tryOpenDoor: doorManager.tryOpenDoor,
|
||||||
onDamagePlayer: (int damage) => player.takeDamage(damage),
|
onDamagePlayer: (int damage) => player.takeDamage(damage),
|
||||||
|
onPlaySound: audio.playSoundEffect,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Scale the enemy's movement intent to prevent super-speed on 90Hz/120Hz displays
|
// Scale the enemy's movement intent to prevent super-speed on 90Hz/120Hz displays
|
||||||
@@ -429,6 +435,7 @@ class WolfEngine {
|
|||||||
if (entity.position.x.toInt() == tile.x &&
|
if (entity.position.x.toInt() == tile.x &&
|
||||||
entity.position.y.toInt() == tile.y) {
|
entity.position.y.toInt() == tile.y) {
|
||||||
entity.isAlerted = true;
|
entity.isAlerted = true;
|
||||||
|
audio.playSoundEffect(entity.alertSoundId);
|
||||||
|
|
||||||
// Wake them up!
|
// Wake them up!
|
||||||
if (entity.state == EntityState.idle ||
|
if (entity.state == EntityState.idle ||
|
||||||
@@ -480,6 +487,17 @@ class WolfEngine {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _playPlayerWeaponSound() {
|
||||||
|
final sfxId = switch (player.currentWeapon.type) {
|
||||||
|
WeaponType.knife => WolfSound.knifeAttack,
|
||||||
|
WeaponType.pistol => WolfSound.pistolFire,
|
||||||
|
WeaponType.machineGun => WolfSound.machineGunFire,
|
||||||
|
WeaponType.chainGun => WolfSound.gatlingFire,
|
||||||
|
};
|
||||||
|
|
||||||
|
audio.playSoundEffect(sfxId);
|
||||||
|
}
|
||||||
|
|
||||||
/// Clamps movement to a maximum of 0.2 tiles per step to prevent wall-clipping
|
/// Clamps movement to a maximum of 0.2 tiles per step to prevent wall-clipping
|
||||||
/// and map-boundary jumps during low framerate/high delta spikes.
|
/// and map-boundary jumps during low framerate/high delta spikes.
|
||||||
Coordinate2D _clampMovement(Coordinate2D intent) {
|
Coordinate2D _clampMovement(Coordinate2D intent) {
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ class HansGrosse extends Enemy {
|
|||||||
EnemyType get type =>
|
EnemyType get type =>
|
||||||
throw UnimplementedError("Hans Grosse uses manual animation logic.");
|
throw UnimplementedError("Hans Grosse uses manual animation logic.");
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get alertSoundId => WolfSound.bossActive;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get attackSoundId => WolfSound.naziFire;
|
||||||
|
|
||||||
HansGrosse({
|
HansGrosse({
|
||||||
required super.x,
|
required super.x,
|
||||||
required super.y,
|
required super.y,
|
||||||
@@ -69,6 +75,7 @@ class HansGrosse extends Enemy {
|
|||||||
required bool Function(int x, int y) isWalkable,
|
required bool Function(int x, int y) isWalkable,
|
||||||
required void Function(int damage) onDamagePlayer,
|
required void Function(int damage) onDamagePlayer,
|
||||||
required void Function(int x, int y) tryOpenDoor,
|
required void Function(int x, int y) tryOpenDoor,
|
||||||
|
required void Function(int sfxId) onPlaySound,
|
||||||
}) {
|
}) {
|
||||||
Coordinate2D movement = const Coordinate2D(0, 0);
|
Coordinate2D movement = const Coordinate2D(0, 0);
|
||||||
double newAngle = angle;
|
double newAngle = angle;
|
||||||
@@ -77,12 +84,14 @@ class HansGrosse extends Enemy {
|
|||||||
newAngle = position.angleTo(playerPosition);
|
newAngle = position.angleTo(playerPosition);
|
||||||
}
|
}
|
||||||
|
|
||||||
checkWakeUp(
|
if (checkWakeUp(
|
||||||
elapsedMs: elapsedMs,
|
elapsedMs: elapsedMs,
|
||||||
playerPosition: playerPosition,
|
playerPosition: playerPosition,
|
||||||
isWalkable: isWalkable,
|
isWalkable: isWalkable,
|
||||||
baseReactionMs: 50,
|
baseReactionMs: 50,
|
||||||
);
|
)) {
|
||||||
|
onPlaySound(alertSoundId);
|
||||||
|
}
|
||||||
|
|
||||||
double distance = position.distanceTo(playerPosition);
|
double distance = position.distanceTo(playerPosition);
|
||||||
|
|
||||||
@@ -130,6 +139,7 @@ class HansGrosse extends Enemy {
|
|||||||
setTics(10);
|
setTics(10);
|
||||||
} else if (currentFrame == 2) {
|
} else if (currentFrame == 2) {
|
||||||
spriteIndex = _baseSprite + 6; // Fire
|
spriteIndex = _baseSprite + 6; // Fire
|
||||||
|
onPlaySound(attackSoundId);
|
||||||
onDamagePlayer(damage);
|
onDamagePlayer(damage);
|
||||||
setTics(4);
|
setTics(4);
|
||||||
} else if (currentFrame == 3) {
|
} else if (currentFrame == 3) {
|
||||||
|
|||||||
@@ -38,17 +38,20 @@ class Dog extends Enemy {
|
|||||||
required bool Function(int x, int y) isWalkable,
|
required bool Function(int x, int y) isWalkable,
|
||||||
required void Function(int damage) onDamagePlayer,
|
required void Function(int damage) onDamagePlayer,
|
||||||
required void Function(int x, int y) tryOpenDoor,
|
required void Function(int x, int y) tryOpenDoor,
|
||||||
|
required void Function(int sfxId) onPlaySound,
|
||||||
}) {
|
}) {
|
||||||
Coordinate2D movement = const Coordinate2D(0, 0);
|
Coordinate2D movement = const Coordinate2D(0, 0);
|
||||||
double newAngle = angle;
|
double newAngle = angle;
|
||||||
|
|
||||||
// 1. Perception
|
// 1. Perception
|
||||||
if (state != EntityState.dead && !isDying) {
|
if (state != EntityState.dead && !isDying) {
|
||||||
checkWakeUp(
|
if (checkWakeUp(
|
||||||
elapsedMs: elapsedMs,
|
elapsedMs: elapsedMs,
|
||||||
playerPosition: playerPosition,
|
playerPosition: playerPosition,
|
||||||
isWalkable: isWalkable,
|
isWalkable: isWalkable,
|
||||||
);
|
)) {
|
||||||
|
onPlaySound(alertSoundId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ticReady = processTics(elapsedDeltaMs, moveSpeed: 0);
|
bool ticReady = processTics(elapsedDeltaMs, moveSpeed: 0);
|
||||||
@@ -59,6 +62,7 @@ class Dog extends Enemy {
|
|||||||
currentFrame++;
|
currentFrame++;
|
||||||
// Phase 2: The actual bite
|
// Phase 2: The actual bite
|
||||||
if (currentFrame == 1) {
|
if (currentFrame == 1) {
|
||||||
|
onPlaySound(attackSoundId);
|
||||||
final bool attackSuccessful =
|
final bool attackSuccessful =
|
||||||
math.Random().nextDouble() < (180 / 256);
|
math.Random().nextDouble() < (180 / 256);
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ abstract class Enemy extends Entity {
|
|||||||
/// knowing the specific subclass.
|
/// knowing the specific subclass.
|
||||||
EnemyType get type;
|
EnemyType get type;
|
||||||
|
|
||||||
|
/// The sound played when this enemy notices the player or hears combat.
|
||||||
|
int get alertSoundId => type.alertSoundId;
|
||||||
|
|
||||||
|
/// The sound played when this enemy performs its attack animation.
|
||||||
|
int get attackSoundId => type.attackSoundId;
|
||||||
|
|
||||||
/// Ensures enemies drop only one item (like ammo or a key) upon death.
|
/// Ensures enemies drop only one item (like ammo or a key) upon death.
|
||||||
bool hasDroppedItem = false;
|
bool hasDroppedItem = false;
|
||||||
|
|
||||||
@@ -111,6 +117,7 @@ abstract class Enemy extends Entity {
|
|||||||
void handleAttackState({
|
void handleAttackState({
|
||||||
required int elapsedDeltaMs,
|
required int elapsedDeltaMs,
|
||||||
required void Function(int damage) onDamagePlayer,
|
required void Function(int damage) onDamagePlayer,
|
||||||
|
required void Function() onAttack,
|
||||||
required int shootTics,
|
required int shootTics,
|
||||||
required int cooldownTics,
|
required int cooldownTics,
|
||||||
required int postAttackTics,
|
required int postAttackTics,
|
||||||
@@ -122,6 +129,7 @@ abstract class Enemy extends Entity {
|
|||||||
if (currentFrame == 1) {
|
if (currentFrame == 1) {
|
||||||
// Phase 1: Bang! Calculate hit chance based on distance.
|
// Phase 1: Bang! Calculate hit chance based on distance.
|
||||||
// Drops by ~5% per tile distance, capped at a minimum 10% chance to hit.
|
// Drops by ~5% per tile distance, capped at a minimum 10% chance to hit.
|
||||||
|
onAttack();
|
||||||
double hitChance = (1.0 - (distance * 0.05)).clamp(0.1, 1.0);
|
double hitChance = (1.0 - (distance * 0.05)).clamp(0.1, 1.0);
|
||||||
|
|
||||||
if (math.Random().nextDouble() <= hitChance) {
|
if (math.Random().nextDouble() <= hitChance) {
|
||||||
@@ -147,13 +155,15 @@ abstract class Enemy extends Entity {
|
|||||||
///
|
///
|
||||||
/// Includes a randomized delay based on [baseReactionMs] and [reactionVarianceMs]
|
/// Includes a randomized delay based on [baseReactionMs] and [reactionVarianceMs]
|
||||||
/// to prevent all enemies in a room from reacting on the exact same frame.
|
/// to prevent all enemies in a room from reacting on the exact same frame.
|
||||||
void checkWakeUp({
|
bool checkWakeUp({
|
||||||
required int elapsedMs,
|
required int elapsedMs,
|
||||||
required Coordinate2D playerPosition,
|
required Coordinate2D playerPosition,
|
||||||
required bool Function(int x, int y) isWalkable,
|
required bool Function(int x, int y) isWalkable,
|
||||||
int baseReactionMs = 200,
|
int baseReactionMs = 200,
|
||||||
int reactionVarianceMs = 600,
|
int reactionVarianceMs = 600,
|
||||||
}) {
|
}) {
|
||||||
|
bool didAlert = false;
|
||||||
|
|
||||||
if (!isAlerted && hasLineOfSight(playerPosition, isWalkable)) {
|
if (!isAlerted && hasLineOfSight(playerPosition, isWalkable)) {
|
||||||
if (reactionTimeMs == 0) {
|
if (reactionTimeMs == 0) {
|
||||||
// First frame of spotting: calculate how long until they "wake up"
|
// First frame of spotting: calculate how long until they "wake up"
|
||||||
@@ -164,6 +174,7 @@ abstract class Enemy extends Entity {
|
|||||||
} else if (elapsedMs >= reactionTimeMs) {
|
} else if (elapsedMs >= reactionTimeMs) {
|
||||||
// Reaction delay has passed
|
// Reaction delay has passed
|
||||||
isAlerted = true;
|
isAlerted = true;
|
||||||
|
didAlert = true;
|
||||||
|
|
||||||
if (state == EntityState.idle || state == EntityState.ambushing) {
|
if (state == EntityState.idle || state == EntityState.ambushing) {
|
||||||
state = EntityState.patrolling;
|
state = EntityState.patrolling;
|
||||||
@@ -174,6 +185,8 @@ abstract class Enemy extends Entity {
|
|||||||
reactionTimeMs = 0;
|
reactionTimeMs = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return didAlert;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Determines if there is a clear, unobstructed path between the enemy and the player.
|
/// Determines if there is a clear, unobstructed path between the enemy and the player.
|
||||||
@@ -312,6 +325,7 @@ abstract class Enemy extends Entity {
|
|||||||
required bool Function(int x, int y) isWalkable,
|
required bool Function(int x, int y) isWalkable,
|
||||||
required void Function(int x, int y) tryOpenDoor,
|
required void Function(int x, int y) tryOpenDoor,
|
||||||
required void Function(int damage) onDamagePlayer,
|
required void Function(int damage) onDamagePlayer,
|
||||||
|
required void Function(int sfxId) onPlaySound,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Factory method to spawn the correct [Enemy] subclass based on a Map ID.
|
/// Factory method to spawn the correct [Enemy] subclass based on a Map ID.
|
||||||
@@ -358,7 +372,9 @@ abstract class Enemy extends Entity {
|
|||||||
|
|
||||||
// Resolve spawn orientation using the NORMALIZED ID (0-7 offset from base)
|
// 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.
|
// This prevents offset math bugs (like the Mutant's 18-ID shift) from breaking facing directions.
|
||||||
double spawnAngle = CardinalDirection.fromEnemyIndex(normalizedId).radians;
|
double spawnAngle = CardinalDirection.fromEnemyIndex(
|
||||||
|
normalizedId - matchedType.mapData.baseId,
|
||||||
|
).radians;
|
||||||
|
|
||||||
EntityState spawnState;
|
EntityState spawnState;
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ enum EnemyType {
|
|||||||
/// Standard Brown Guard (The most common enemy).
|
/// Standard Brown Guard (The most common enemy).
|
||||||
guard(
|
guard(
|
||||||
mapData: EnemyMapData(MapObject.guardStart),
|
mapData: EnemyMapData(MapObject.guardStart),
|
||||||
|
alertSoundId: WolfSound.guardHalt,
|
||||||
|
attackSoundId: WolfSound.naziFire,
|
||||||
animations: EnemyAnimationMap(
|
animations: EnemyAnimationMap(
|
||||||
idle: SpriteFrameRange(50, 57),
|
idle: SpriteFrameRange(50, 57),
|
||||||
walking: SpriteFrameRange(58, 89),
|
walking: SpriteFrameRange(58, 89),
|
||||||
@@ -24,6 +26,8 @@ enum EnemyType {
|
|||||||
/// Attack Dog (Fast melee enemy).
|
/// Attack Dog (Fast melee enemy).
|
||||||
dog(
|
dog(
|
||||||
mapData: EnemyMapData(MapObject.dogStart),
|
mapData: EnemyMapData(MapObject.dogStart),
|
||||||
|
alertSoundId: WolfSound.dogBark,
|
||||||
|
attackSoundId: WolfSound.dogAttack,
|
||||||
animations: EnemyAnimationMap(
|
animations: EnemyAnimationMap(
|
||||||
// Dogs don't have true idle sprites, so map idle to the first walk frame safely
|
// Dogs don't have true idle sprites, so map idle to the first walk frame safely
|
||||||
idle: SpriteFrameRange(99, 106),
|
idle: SpriteFrameRange(99, 106),
|
||||||
@@ -43,6 +47,8 @@ enum EnemyType {
|
|||||||
/// SS Officer (Blue uniform, machine gun).
|
/// SS Officer (Blue uniform, machine gun).
|
||||||
ss(
|
ss(
|
||||||
mapData: EnemyMapData(MapObject.ssStart),
|
mapData: EnemyMapData(MapObject.ssStart),
|
||||||
|
alertSoundId: WolfSound.ssSchutzstaffel,
|
||||||
|
attackSoundId: WolfSound.naziFire,
|
||||||
animations: EnemyAnimationMap(
|
animations: EnemyAnimationMap(
|
||||||
idle: SpriteFrameRange(138, 145),
|
idle: SpriteFrameRange(138, 145),
|
||||||
walking: SpriteFrameRange(146, 177),
|
walking: SpriteFrameRange(146, 177),
|
||||||
@@ -55,7 +61,9 @@ enum EnemyType {
|
|||||||
|
|
||||||
/// Undead Mutant (Exclusive to later episodes/retail).
|
/// Undead Mutant (Exclusive to later episodes/retail).
|
||||||
mutant(
|
mutant(
|
||||||
mapData: EnemyMapData(MapObject.mutantStart),
|
mapData: EnemyMapData(MapObject.mutantStart, tierOffset: 18),
|
||||||
|
alertSoundId: WolfSound.guardHalt,
|
||||||
|
attackSoundId: WolfSound.naziFire,
|
||||||
animations: EnemyAnimationMap(
|
animations: EnemyAnimationMap(
|
||||||
idle: SpriteFrameRange(187, 194),
|
idle: SpriteFrameRange(187, 194),
|
||||||
walking: SpriteFrameRange(195, 226),
|
walking: SpriteFrameRange(195, 226),
|
||||||
@@ -70,6 +78,8 @@ enum EnemyType {
|
|||||||
/// High-ranking Officer (White uniform, fast pistol).
|
/// High-ranking Officer (White uniform, fast pistol).
|
||||||
officer(
|
officer(
|
||||||
mapData: EnemyMapData(MapObject.officerStart),
|
mapData: EnemyMapData(MapObject.officerStart),
|
||||||
|
alertSoundId: WolfSound.guardHalt,
|
||||||
|
attackSoundId: WolfSound.naziFire,
|
||||||
animations: EnemyAnimationMap(
|
animations: EnemyAnimationMap(
|
||||||
idle: SpriteFrameRange(238, 245),
|
idle: SpriteFrameRange(238, 245),
|
||||||
walking: SpriteFrameRange(246, 277),
|
walking: SpriteFrameRange(246, 277),
|
||||||
@@ -88,12 +98,20 @@ enum EnemyType {
|
|||||||
/// The animation ranges for this enemy type.
|
/// The animation ranges for this enemy type.
|
||||||
final EnemyAnimationMap animations;
|
final EnemyAnimationMap animations;
|
||||||
|
|
||||||
|
/// The sound played when this enemy first becomes alerted.
|
||||||
|
final int alertSoundId;
|
||||||
|
|
||||||
|
/// The sound played when this enemy attacks.
|
||||||
|
final int attackSoundId;
|
||||||
|
|
||||||
/// If false, this enemy type will be ignored when loading shareware data.
|
/// If false, this enemy type will be ignored when loading shareware data.
|
||||||
final bool existsInShareware;
|
final bool existsInShareware;
|
||||||
|
|
||||||
const EnemyType({
|
const EnemyType({
|
||||||
required this.mapData,
|
required this.mapData,
|
||||||
required this.animations,
|
required this.animations,
|
||||||
|
required this.alertSoundId,
|
||||||
|
required this.attackSoundId,
|
||||||
this.existsInShareware = true,
|
this.existsInShareware = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -151,7 +169,6 @@ enum EnemyType {
|
|||||||
|
|
||||||
// --- Octant Calculation ---
|
// --- Octant Calculation ---
|
||||||
// We split the circle into 8 segments (octants).
|
// 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;
|
||||||
|
|
||||||
|
|||||||
@@ -35,17 +35,20 @@ class Guard extends Enemy {
|
|||||||
required bool Function(int x, int y) isWalkable,
|
required bool Function(int x, int y) isWalkable,
|
||||||
required void Function(int damage) onDamagePlayer,
|
required void Function(int damage) onDamagePlayer,
|
||||||
required void Function(int x, int y) tryOpenDoor,
|
required void Function(int x, int y) tryOpenDoor,
|
||||||
|
required void Function(int sfxId) onPlaySound,
|
||||||
}) {
|
}) {
|
||||||
Coordinate2D movement = const Coordinate2D(0, 0);
|
Coordinate2D movement = const Coordinate2D(0, 0);
|
||||||
double newAngle = angle;
|
double newAngle = angle;
|
||||||
double distance = position.distanceTo(playerPosition);
|
double distance = position.distanceTo(playerPosition);
|
||||||
|
|
||||||
// 1. Perception (SightPlayer)
|
// 1. Perception (SightPlayer)
|
||||||
checkWakeUp(
|
if (checkWakeUp(
|
||||||
elapsedMs: elapsedMs,
|
elapsedMs: elapsedMs,
|
||||||
playerPosition: playerPosition,
|
playerPosition: playerPosition,
|
||||||
isWalkable: isWalkable,
|
isWalkable: isWalkable,
|
||||||
);
|
)) {
|
||||||
|
onPlaySound(alertSoundId);
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Discrete AI Logic (Decisions happen every 10 tics)
|
// 2. Discrete AI Logic (Decisions happen every 10 tics)
|
||||||
bool ticReady = processTics(elapsedDeltaMs, moveSpeed: 0);
|
bool ticReady = processTics(elapsedDeltaMs, moveSpeed: 0);
|
||||||
@@ -55,6 +58,7 @@ class Guard extends Enemy {
|
|||||||
elapsedDeltaMs: elapsedDeltaMs,
|
elapsedDeltaMs: elapsedDeltaMs,
|
||||||
distance: distance,
|
distance: distance,
|
||||||
onDamagePlayer: onDamagePlayer,
|
onDamagePlayer: onDamagePlayer,
|
||||||
|
onAttack: () => onPlaySound(attackSoundId),
|
||||||
shootTics: 20,
|
shootTics: 20,
|
||||||
cooldownTics: 20,
|
cooldownTics: 20,
|
||||||
postAttackTics: 20,
|
postAttackTics: 20,
|
||||||
@@ -123,7 +127,7 @@ class Guard extends Enemy {
|
|||||||
double newAngle,
|
double newAngle,
|
||||||
Coordinate2D playerPosition,
|
Coordinate2D playerPosition,
|
||||||
) {
|
) {
|
||||||
double diff = position.angleTo(playerPosition) - newAngle;
|
double diff = newAngle - position.angleTo(playerPosition);
|
||||||
while (diff <= -math.pi) {
|
while (diff <= -math.pi) {
|
||||||
diff += 2 * math.pi;
|
diff += 2 * math.pi;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,22 +33,25 @@ class Mutant extends Enemy {
|
|||||||
required bool Function(int x, int y) isWalkable,
|
required bool Function(int x, int y) isWalkable,
|
||||||
required void Function(int damage) onDamagePlayer,
|
required void Function(int damage) onDamagePlayer,
|
||||||
required void Function(int x, int y) tryOpenDoor,
|
required void Function(int x, int y) tryOpenDoor,
|
||||||
|
required void Function(int sfxId) onPlaySound,
|
||||||
}) {
|
}) {
|
||||||
Coordinate2D movement = const Coordinate2D(0, 0);
|
Coordinate2D movement = const Coordinate2D(0, 0);
|
||||||
double newAngle = angle;
|
double newAngle = angle;
|
||||||
|
|
||||||
checkWakeUp(
|
if (checkWakeUp(
|
||||||
elapsedMs: elapsedMs,
|
elapsedMs: elapsedMs,
|
||||||
playerPosition: playerPosition,
|
playerPosition: playerPosition,
|
||||||
isWalkable: isWalkable,
|
isWalkable: isWalkable,
|
||||||
);
|
)) {
|
||||||
|
onPlaySound(alertSoundId);
|
||||||
|
}
|
||||||
|
|
||||||
double distance = position.distanceTo(playerPosition);
|
double distance = position.distanceTo(playerPosition);
|
||||||
double angleToPlayer = position.angleTo(playerPosition);
|
double angleToPlayer = position.angleTo(playerPosition);
|
||||||
|
|
||||||
if (isAlerted && state != EntityState.dead) newAngle = angleToPlayer;
|
if (isAlerted && state != EntityState.dead) newAngle = angleToPlayer;
|
||||||
|
|
||||||
double diff = angleToPlayer - newAngle;
|
double diff = newAngle - angleToPlayer;
|
||||||
while (diff <= -math.pi) {
|
while (diff <= -math.pi) {
|
||||||
diff += 2 * math.pi;
|
diff += 2 * math.pi;
|
||||||
}
|
}
|
||||||
@@ -105,6 +108,7 @@ class Mutant extends Enemy {
|
|||||||
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
|
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
|
||||||
currentFrame++;
|
currentFrame++;
|
||||||
if (currentFrame == 1) {
|
if (currentFrame == 1) {
|
||||||
|
onPlaySound(attackSoundId);
|
||||||
onDamagePlayer(damage);
|
onDamagePlayer(damage);
|
||||||
setTics(4);
|
setTics(4);
|
||||||
} else if (currentFrame == 2) {
|
} else if (currentFrame == 2) {
|
||||||
|
|||||||
@@ -33,22 +33,25 @@ class Officer extends Enemy {
|
|||||||
required bool Function(int x, int y) isWalkable,
|
required bool Function(int x, int y) isWalkable,
|
||||||
required void Function(int damage) onDamagePlayer,
|
required void Function(int damage) onDamagePlayer,
|
||||||
required void Function(int x, int y) tryOpenDoor,
|
required void Function(int x, int y) tryOpenDoor,
|
||||||
|
required void Function(int sfxId) onPlaySound,
|
||||||
}) {
|
}) {
|
||||||
Coordinate2D movement = const Coordinate2D(0, 0);
|
Coordinate2D movement = const Coordinate2D(0, 0);
|
||||||
double newAngle = angle;
|
double newAngle = angle;
|
||||||
|
|
||||||
checkWakeUp(
|
if (checkWakeUp(
|
||||||
elapsedMs: elapsedMs,
|
elapsedMs: elapsedMs,
|
||||||
playerPosition: playerPosition,
|
playerPosition: playerPosition,
|
||||||
isWalkable: isWalkable,
|
isWalkable: isWalkable,
|
||||||
);
|
)) {
|
||||||
|
onPlaySound(alertSoundId);
|
||||||
|
}
|
||||||
|
|
||||||
double distance = position.distanceTo(playerPosition);
|
double distance = position.distanceTo(playerPosition);
|
||||||
double angleToPlayer = position.angleTo(playerPosition);
|
double angleToPlayer = position.angleTo(playerPosition);
|
||||||
|
|
||||||
if (isAlerted && state != EntityState.dead) newAngle = angleToPlayer;
|
if (isAlerted && state != EntityState.dead) newAngle = angleToPlayer;
|
||||||
|
|
||||||
double diff = angleToPlayer - newAngle;
|
double diff = newAngle - angleToPlayer;
|
||||||
while (diff <= -math.pi) {
|
while (diff <= -math.pi) {
|
||||||
diff += 2 * math.pi;
|
diff += 2 * math.pi;
|
||||||
}
|
}
|
||||||
@@ -105,6 +108,7 @@ class Officer extends Enemy {
|
|||||||
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
|
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
|
||||||
currentFrame++;
|
currentFrame++;
|
||||||
if (currentFrame == 1) {
|
if (currentFrame == 1) {
|
||||||
|
onPlaySound(attackSoundId);
|
||||||
onDamagePlayer(damage);
|
onDamagePlayer(damage);
|
||||||
setTics(4); // Bang!
|
setTics(4); // Bang!
|
||||||
} else if (currentFrame == 2) {
|
} else if (currentFrame == 2) {
|
||||||
|
|||||||
@@ -32,22 +32,25 @@ class SS extends Enemy {
|
|||||||
required bool Function(int x, int y) isWalkable,
|
required bool Function(int x, int y) isWalkable,
|
||||||
required void Function(int damage) onDamagePlayer,
|
required void Function(int damage) onDamagePlayer,
|
||||||
required void Function(int x, int y) tryOpenDoor,
|
required void Function(int x, int y) tryOpenDoor,
|
||||||
|
required void Function(int sfxId) onPlaySound,
|
||||||
}) {
|
}) {
|
||||||
Coordinate2D movement = const Coordinate2D(0, 0);
|
Coordinate2D movement = const Coordinate2D(0, 0);
|
||||||
double newAngle = angle;
|
double newAngle = angle;
|
||||||
|
|
||||||
checkWakeUp(
|
if (checkWakeUp(
|
||||||
elapsedMs: elapsedMs,
|
elapsedMs: elapsedMs,
|
||||||
playerPosition: playerPosition,
|
playerPosition: playerPosition,
|
||||||
isWalkable: isWalkable,
|
isWalkable: isWalkable,
|
||||||
);
|
)) {
|
||||||
|
onPlaySound(alertSoundId);
|
||||||
|
}
|
||||||
|
|
||||||
double distance = position.distanceTo(playerPosition);
|
double distance = position.distanceTo(playerPosition);
|
||||||
double angleToPlayer = position.angleTo(playerPosition);
|
double angleToPlayer = position.angleTo(playerPosition);
|
||||||
|
|
||||||
if (isAlerted && state != EntityState.dead) newAngle = angleToPlayer;
|
if (isAlerted && state != EntityState.dead) newAngle = angleToPlayer;
|
||||||
|
|
||||||
double diff = angleToPlayer - newAngle;
|
double diff = newAngle - angleToPlayer;
|
||||||
while (diff <= -math.pi) {
|
while (diff <= -math.pi) {
|
||||||
diff += 2 * math.pi;
|
diff += 2 * math.pi;
|
||||||
}
|
}
|
||||||
@@ -104,6 +107,7 @@ class SS extends Enemy {
|
|||||||
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
|
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
|
||||||
currentFrame++;
|
currentFrame++;
|
||||||
if (currentFrame == 1) {
|
if (currentFrame == 1) {
|
||||||
|
onPlaySound(attackSoundId);
|
||||||
onDamagePlayer(damage);
|
onDamagePlayer(damage);
|
||||||
setTics(5); // Bang!
|
setTics(5); // Bang!
|
||||||
} else if (currentFrame == 2) {
|
} else if (currentFrame == 2) {
|
||||||
@@ -114,6 +118,7 @@ class SS extends Enemy {
|
|||||||
if (math.Random().nextDouble() > 0.5) {
|
if (math.Random().nextDouble() > 0.5) {
|
||||||
// 50% chance to burst
|
// 50% chance to burst
|
||||||
currentFrame = 1;
|
currentFrame = 1;
|
||||||
|
onPlaySound(attackSoundId);
|
||||||
onDamagePlayer(damage);
|
onDamagePlayer(damage);
|
||||||
setTics(5);
|
setTics(5);
|
||||||
return (movement: movement, newAngle: newAngle);
|
return (movement: movement, newAngle: newAngle);
|
||||||
|
|||||||
47
packages/wolf_3d_dart/test/entities/enemy_spawn_test.dart
Normal file
47
packages/wolf_3d_dart/test/entities/enemy_spawn_test.dart
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import 'package:test/test.dart';
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('Enemy map IDs', () {
|
||||||
|
test('maps officers, dogs, and mutants to the correct enemy types', () {
|
||||||
|
expect(EnemyType.fromMapId(116), EnemyType.officer);
|
||||||
|
expect(EnemyType.fromMapId(134), EnemyType.dog);
|
||||||
|
expect(EnemyType.fromMapId(216), EnemyType.mutant);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses the original mutant 18-ID difficulty offset', () {
|
||||||
|
expect(EnemyType.mutant.mapData.getNormalizedId(216, Difficulty.easy), 216);
|
||||||
|
expect(
|
||||||
|
EnemyType.mutant.mapData.getNormalizedId(234, Difficulty.medium),
|
||||||
|
216,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
EnemyType.mutant.mapData.getNormalizedId(252, Difficulty.hard),
|
||||||
|
216,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Enemy spawn facing', () {
|
||||||
|
test('spawns dog directions using the original dog map IDs', () {
|
||||||
|
expect(_spawnEnemy(134).angle, CardinalDirection.east.radians);
|
||||||
|
expect(_spawnEnemy(135).angle, CardinalDirection.north.radians);
|
||||||
|
expect(_spawnEnemy(136).angle, CardinalDirection.west.radians);
|
||||||
|
expect(_spawnEnemy(137).angle, CardinalDirection.south.radians);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maps patrol variants to the same facing directions', () {
|
||||||
|
expect(_spawnEnemy(138).angle, CardinalDirection.east.radians);
|
||||||
|
expect(_spawnEnemy(139).angle, CardinalDirection.north.radians);
|
||||||
|
expect(_spawnEnemy(140).angle, CardinalDirection.west.radians);
|
||||||
|
expect(_spawnEnemy(141).angle, CardinalDirection.south.radians);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Enemy _spawnEnemy(int mapId) {
|
||||||
|
final enemy = Enemy.spawn(mapId, 8.5, 8.5, Difficulty.hard);
|
||||||
|
expect(enemy, isNotNull);
|
||||||
|
return enemy!;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_type.dart';
|
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('Enemy Sprite Range Validation', () {
|
group('Enemy Sprite Range Validation', () {
|
||||||
@@ -22,6 +22,16 @@ void main() {
|
|||||||
// Skip if we are comparing the exact same animation on the same enemy
|
// Skip if we are comparing the exact same animation on the same enemy
|
||||||
if (enemyA == enemyB && animA == animB) return;
|
if (enemyA == enemyB && animA == animB) return;
|
||||||
|
|
||||||
|
// Dogs intentionally reuse some standing frames as walking frames.
|
||||||
|
final isDogIdleWalkReuse =
|
||||||
|
enemyA == EnemyType.dog &&
|
||||||
|
enemyB == EnemyType.dog &&
|
||||||
|
((animA == EnemyAnimation.idle &&
|
||||||
|
animB == EnemyAnimation.walking) ||
|
||||||
|
(animA == EnemyAnimation.walking &&
|
||||||
|
animB == EnemyAnimation.idle));
|
||||||
|
if (isDogIdleWalkReuse) return;
|
||||||
|
|
||||||
if (rangeA.overlaps(rangeB)) {
|
if (rangeA.overlaps(rangeB)) {
|
||||||
// Determine the specific frames that are clashing
|
// Determine the specific frames that are clashing
|
||||||
final start = math.max(rangeA.start, rangeB.start);
|
final start = math.max(rangeA.start, rangeB.start);
|
||||||
|
|||||||
Reference in New Issue
Block a user