feat: Implement difficulty scaling for enemy damage and enhance enemy behaviors
- Added `scaleIncomingEnemyDamage` method to `Difficulty` enum for handling damage scaling based on difficulty level. - Introduced `lives` attribute to `Player` class with methods to manage lives. - Updated enemy classes (`Dog`, `Guard`, `Mutant`, `Officer`, `SS`, `HansGrosse`) to utilize difficulty settings for health and damage calculations. - Modified enemy drop logic to reflect new collectible types and behaviors. - Created tests for verifying enemy drop parity, collectible values, and difficulty damage scaling. - Adjusted enemy spawn logic to ensure standing enemies remain idle until alerted. - Enhanced scoring system to reflect updated score values for different enemy types. Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
@@ -16,4 +16,15 @@ enum Difficulty {
|
||||
final int level;
|
||||
|
||||
const Difficulty(this.level, this.title);
|
||||
|
||||
/// Applies canonical incoming enemy-damage scaling for this difficulty.
|
||||
///
|
||||
/// Wolf3D reduces damage heavily on baby mode (`>> 2`).
|
||||
int scaleIncomingEnemyDamage(int rawDamage) {
|
||||
if (rawDamage <= 0) return 0;
|
||||
if (this != Difficulty.baby) return rawDamage;
|
||||
|
||||
final scaled = rawDamage >> 2;
|
||||
return scaled > 0 ? scaled : 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ class Player {
|
||||
int health = 100;
|
||||
int ammo = 8;
|
||||
int score = 0;
|
||||
int lives = 3;
|
||||
|
||||
// Damage flash
|
||||
double damageFlash = 0.0; // 0.0 is none, 1.0 is maximum red
|
||||
@@ -135,6 +136,10 @@ class Player {
|
||||
ammo = newAmmo;
|
||||
}
|
||||
|
||||
void addLives(int amount) {
|
||||
lives = math.min(9, lives + amount);
|
||||
}
|
||||
|
||||
/// Attempts to collect [item] and returns the SFX to play.
|
||||
///
|
||||
/// Returns `null` when the item was not collected (for example: full health).
|
||||
@@ -164,6 +169,10 @@ class Player {
|
||||
score += effect.scoreToAdd;
|
||||
}
|
||||
|
||||
if (effect.extraLivesToAdd > 0) {
|
||||
addLives(effect.extraLivesToAdd);
|
||||
}
|
||||
|
||||
if (effect.grantGoldKey) {
|
||||
hasGoldKey = true;
|
||||
}
|
||||
|
||||
@@ -125,6 +125,8 @@ class WolfEngine {
|
||||
List<Entity> entities = [];
|
||||
|
||||
int _currentEpisodeIndex = 0;
|
||||
|
||||
bool _isPlayerMovingFast = false;
|
||||
int _currentLevelIndex = 0;
|
||||
|
||||
/// Stores the previous level index when entering a secret floor,
|
||||
@@ -213,6 +215,10 @@ class WolfEngine {
|
||||
inputResult.movement,
|
||||
);
|
||||
|
||||
// RUN key does not exist yet in current input model. Keep canonical hit
|
||||
// chance in "walking" mode until true run-state input is implemented.
|
||||
_isPlayerMovingFast = false;
|
||||
|
||||
player.x = validatedPos.x;
|
||||
player.y = validatedPos.y;
|
||||
|
||||
@@ -533,9 +539,14 @@ class WolfEngine {
|
||||
elapsedMs: _timeAliveMs,
|
||||
elapsedDeltaMs: elapsed.inMilliseconds,
|
||||
playerPosition: player.position,
|
||||
playerAngle: player.angle,
|
||||
isPlayerRunning: _isPlayerMovingFast,
|
||||
isWalkable: isWalkable,
|
||||
tryOpenDoor: doorManager.tryOpenDoor,
|
||||
onDamagePlayer: (int damage) => player.takeDamage(damage),
|
||||
onDamagePlayer: (int damage) {
|
||||
final difficultyMode = difficulty ?? Difficulty.medium;
|
||||
player.takeDamage(difficultyMode.scaleIncomingEnemyDamage(damage));
|
||||
},
|
||||
onPlaySound: audio.playSoundEffect,
|
||||
);
|
||||
|
||||
@@ -561,14 +572,25 @@ class WolfEngine {
|
||||
entity.isDying &&
|
||||
!entity.hasDroppedItem) {
|
||||
entity.hasDroppedItem = true;
|
||||
Entity? droppedAmmo = EntityRegistry.spawn(
|
||||
MapObject.ammoClip,
|
||||
entity.x,
|
||||
entity.y,
|
||||
difficulty!,
|
||||
data.sprites.length,
|
||||
);
|
||||
if (droppedAmmo != null) itemsToAdd.add(droppedAmmo);
|
||||
|
||||
Entity? droppedItem;
|
||||
if (entity is Dog) {
|
||||
droppedItem = null;
|
||||
} else if (entity is SS) {
|
||||
droppedItem = !player.hasMachineGun
|
||||
? WeaponCollectible(
|
||||
x: entity.x,
|
||||
y: entity.y,
|
||||
mapId: MapObject.machineGun,
|
||||
)
|
||||
: SmallAmmoCollectible(x: entity.x, y: entity.y);
|
||||
} else if (entity is Guard || entity is Officer || entity is Mutant) {
|
||||
droppedItem = SmallAmmoCollectible(x: entity.x, y: entity.y);
|
||||
}
|
||||
|
||||
if (droppedItem != null) {
|
||||
itemsToAdd.add(droppedItem);
|
||||
}
|
||||
}
|
||||
} else if (entity is Collectible) {
|
||||
if (player.position.distanceTo(entity.position) < 0.5) {
|
||||
|
||||
@@ -28,6 +28,7 @@ class CollectiblePickupEffect {
|
||||
final int healthToRestore;
|
||||
final int ammoToAdd;
|
||||
final int scoreToAdd;
|
||||
final int extraLivesToAdd;
|
||||
final int pickupSfxId;
|
||||
final bool grantGoldKey;
|
||||
final bool grantSilverKey;
|
||||
@@ -38,6 +39,7 @@ class CollectiblePickupEffect {
|
||||
this.healthToRestore = 0,
|
||||
this.ammoToAdd = 0,
|
||||
this.scoreToAdd = 0,
|
||||
this.extraLivesToAdd = 0,
|
||||
required this.pickupSfxId,
|
||||
this.grantGoldKey = false,
|
||||
this.grantSilverKey = false,
|
||||
@@ -113,22 +115,25 @@ class HealthCollectible extends Collectible {
|
||||
CollectiblePickupEffect? tryCollect(CollectiblePickupContext context) {
|
||||
if (context.health >= 100) return null;
|
||||
|
||||
final bool isSmallHealth = mapId == MapObject.food;
|
||||
final bool isFood = mapId == MapObject.food;
|
||||
return CollectiblePickupEffect(
|
||||
healthToRestore: isSmallHealth ? 4 : 25,
|
||||
pickupSfxId: isSmallHealth
|
||||
? WolfSound.healthSmall
|
||||
: WolfSound.healthLarge,
|
||||
healthToRestore: isFood ? 10 : 25,
|
||||
pickupSfxId: isFood ? WolfSound.healthSmall : WolfSound.healthLarge,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AmmoCollectible extends Collectible {
|
||||
AmmoCollectible({required super.x, required super.y})
|
||||
: super._(
|
||||
mapId: MapObject.ammoClip,
|
||||
spriteIndex: Collectible._spriteIndexFor(MapObject.ammoClip),
|
||||
);
|
||||
final int ammoAmount;
|
||||
|
||||
AmmoCollectible({
|
||||
required super.x,
|
||||
required super.y,
|
||||
this.ammoAmount = 8,
|
||||
}) : super._(
|
||||
mapId: MapObject.ammoClip,
|
||||
spriteIndex: Collectible._spriteIndexFor(MapObject.ammoClip),
|
||||
);
|
||||
|
||||
@override
|
||||
CollectiblePickupEffect? tryCollect(CollectiblePickupContext context) {
|
||||
@@ -138,13 +143,18 @@ class AmmoCollectible extends Collectible {
|
||||
context.currentWeaponType == WeaponType.knife && context.ammo <= 0;
|
||||
|
||||
return CollectiblePickupEffect(
|
||||
ammoToAdd: 8,
|
||||
ammoToAdd: ammoAmount,
|
||||
pickupSfxId: WolfSound.getAmmo,
|
||||
requestWeaponSwitch: shouldAutoswitchToPistol ? WeaponType.pistol : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SmallAmmoCollectible extends AmmoCollectible {
|
||||
SmallAmmoCollectible({required super.x, required super.y})
|
||||
: super(ammoAmount: 4);
|
||||
}
|
||||
|
||||
class WeaponCollectible extends Collectible {
|
||||
WeaponCollectible({
|
||||
required super.x,
|
||||
@@ -156,7 +166,7 @@ class WeaponCollectible extends Collectible {
|
||||
CollectiblePickupEffect? tryCollect(CollectiblePickupContext context) {
|
||||
if (mapId == MapObject.machineGun) {
|
||||
return const CollectiblePickupEffect(
|
||||
ammoToAdd: 8,
|
||||
ammoToAdd: 6,
|
||||
pickupSfxId: WolfSound.getMachineGun,
|
||||
grantWeapon: WeaponType.machineGun,
|
||||
requestWeaponSwitch: WeaponType.machineGun,
|
||||
@@ -165,7 +175,7 @@ class WeaponCollectible extends Collectible {
|
||||
|
||||
if (mapId == MapObject.chainGun) {
|
||||
return const CollectiblePickupEffect(
|
||||
ammoToAdd: 8,
|
||||
ammoToAdd: 6,
|
||||
pickupSfxId: WolfSound.getGatling,
|
||||
grantWeapon: WeaponType.chainGun,
|
||||
requestWeaponSwitch: WeaponType.chainGun,
|
||||
@@ -225,8 +235,9 @@ class TreasureCollectible extends Collectible {
|
||||
CollectiblePickupEffect? tryCollect(CollectiblePickupContext context) {
|
||||
if (mapId == MapObject.extraLife) {
|
||||
return const CollectiblePickupEffect(
|
||||
healthToRestore: 100,
|
||||
healthToRestore: 99,
|
||||
ammoToAdd: 25,
|
||||
extraLivesToAdd: 1,
|
||||
pickupSfxId: WolfSound.extraLife,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ class HansGrosse extends Enemy {
|
||||
@override
|
||||
int get deathSoundId => WolfSound.mutti;
|
||||
|
||||
@override
|
||||
int get scoreValue => 5000;
|
||||
|
||||
HansGrosse({
|
||||
required super.x,
|
||||
required super.y,
|
||||
@@ -75,6 +78,8 @@ class HansGrosse extends Enemy {
|
||||
required int elapsedMs,
|
||||
required int elapsedDeltaMs,
|
||||
required Coordinate2D playerPosition,
|
||||
required double playerAngle,
|
||||
required bool isPlayerRunning,
|
||||
required bool Function(int x, int y) isWalkable,
|
||||
required void Function(int damage) onDamagePlayer,
|
||||
required void Function(int x, int y) tryOpenDoor,
|
||||
|
||||
@@ -22,11 +22,12 @@ class Dog extends Enemy {
|
||||
required super.y,
|
||||
required super.angle,
|
||||
required super.mapId,
|
||||
Difficulty difficulty = Difficulty.medium,
|
||||
}) : super(
|
||||
spriteIndex: EnemyType.dog.animations.walking.start,
|
||||
state: EntityState.patrolling,
|
||||
) {
|
||||
health = 1;
|
||||
health = type.hitPointsFor(difficulty);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -34,6 +35,8 @@ class Dog extends Enemy {
|
||||
required int elapsedMs,
|
||||
required int elapsedDeltaMs,
|
||||
required Coordinate2D playerPosition,
|
||||
required double playerAngle,
|
||||
required bool isPlayerRunning,
|
||||
required bool Function(int x, int y) isWalkable,
|
||||
required void Function(int damage) onDamagePlayer,
|
||||
required void Function(int x, int y) tryOpenDoor,
|
||||
@@ -70,7 +73,7 @@ class Dog extends Enemy {
|
||||
bool inBiteRange = dx <= 1.0 && dy <= 1.0;
|
||||
|
||||
if (inBiteRange && attackSuccessful) {
|
||||
onDamagePlayer(math.Random().nextInt(16));
|
||||
onDamagePlayer(math.Random().nextInt(16)); // Canonical 0..15
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +90,7 @@ class Dog extends Enemy {
|
||||
double ticsThisFrame = elapsedDeltaMs / 14.28;
|
||||
double currentMoveSpeed = speedPerTic * ticsThisFrame;
|
||||
|
||||
if (isAlerted || state == EntityState.ambushing) {
|
||||
if (isAlerted) {
|
||||
state = EntityState.ambushing;
|
||||
|
||||
double dx = (position.x - playerPosition.x).abs() - currentMoveSpeed;
|
||||
|
||||
@@ -97,6 +97,65 @@ abstract class Enemy extends Entity {
|
||||
_ticCount = tics;
|
||||
}
|
||||
|
||||
/// Canonical Wolf3D ranged damage buckets by tile distance.
|
||||
int rollRangedDamage(double distance, {bool isSharpShooter = false}) {
|
||||
final adjustedDistance = isSharpShooter ? (distance * (2.0 / 3.0)) : distance;
|
||||
|
||||
if (adjustedDistance < 2.0) {
|
||||
return math.Random().nextInt(64); // 0..63
|
||||
}
|
||||
if (adjustedDistance < 4.0) {
|
||||
return math.Random().nextInt(32); // 0..31
|
||||
}
|
||||
return math.Random().nextInt(16); // 0..15
|
||||
}
|
||||
|
||||
/// Approximate whether this enemy is on-screen from the player's perspective.
|
||||
bool isVisibleFromPlayer({
|
||||
required Coordinate2D playerPosition,
|
||||
required double playerAngle,
|
||||
}) {
|
||||
final double angleToEnemy = playerPosition.angleTo(position);
|
||||
double diff = playerAngle - angleToEnemy;
|
||||
|
||||
while (diff <= -math.pi) {
|
||||
diff += 2 * math.pi;
|
||||
}
|
||||
while (diff > math.pi) {
|
||||
diff -= 2 * math.pi;
|
||||
}
|
||||
|
||||
// Wolf3D visibility in logic is tied to rendered visibility; use an 80-degree
|
||||
// front arc as an approximation for dodge-capable shots.
|
||||
return diff.abs() <= (math.pi * (80.0 / 180.0));
|
||||
}
|
||||
|
||||
/// Canonical enemy gun hit chance from `T_Shoot`.
|
||||
bool tryRollRangedHit({
|
||||
required double distance,
|
||||
required Coordinate2D playerPosition,
|
||||
required double playerAngle,
|
||||
required bool isPlayerRunning,
|
||||
bool isSharpShooter = false,
|
||||
}) {
|
||||
final adjustedDistance = isSharpShooter ? (distance * (2.0 / 3.0)) : distance;
|
||||
final dist = adjustedDistance.floor();
|
||||
final isVisible = isVisibleFromPlayer(
|
||||
playerPosition: playerPosition,
|
||||
playerAngle: playerAngle,
|
||||
);
|
||||
|
||||
int hitchance;
|
||||
if (isPlayerRunning) {
|
||||
hitchance = isVisible ? 160 - dist * 16 : 160 - dist * 8;
|
||||
} else {
|
||||
hitchance = isVisible ? 256 - dist * 16 : 256 - dist * 8;
|
||||
}
|
||||
|
||||
hitchance = hitchance.clamp(0, 255);
|
||||
return math.Random().nextInt(256) < hitchance;
|
||||
}
|
||||
|
||||
/// Reduces health and handles state transitions for pain or death.
|
||||
///
|
||||
/// Alerts the enemy automatically upon taking damage. There is a 50% chance
|
||||
@@ -331,6 +390,8 @@ abstract class Enemy extends Entity {
|
||||
required int elapsedMs,
|
||||
required int elapsedDeltaMs,
|
||||
required Coordinate2D playerPosition,
|
||||
required double playerAngle,
|
||||
required bool isPlayerRunning,
|
||||
required bool Function(int x, int y) isWalkable,
|
||||
required void Function(int x, int y) tryOpenDoor,
|
||||
required void Function(int damage) onDamagePlayer,
|
||||
@@ -390,20 +451,50 @@ abstract class Enemy extends Entity {
|
||||
if (matchedType.mapData.isPatrol(normalizedId)) {
|
||||
spawnState = EntityState.patrolling;
|
||||
} else if (matchedType.mapData.isStatic(normalizedId)) {
|
||||
// Standing map placements face a specific direction but stand still
|
||||
// until the player enters their line of sight (handled by checkWakeUp).
|
||||
spawnState = EntityState.idle;
|
||||
// Standing map placements should hold position in an ambush state until
|
||||
// their wake-up logic transitions them to active behavior.
|
||||
spawnState = EntityState.ambushing;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return the specific instance
|
||||
return switch (matchedType) {
|
||||
EnemyType.guard => Guard(x: x, y: y, angle: spawnAngle, mapId: objId),
|
||||
EnemyType.dog => Dog(x: x, y: y, angle: spawnAngle, mapId: objId),
|
||||
EnemyType.ss => SS(x: x, y: y, angle: spawnAngle, mapId: objId),
|
||||
EnemyType.mutant => Mutant(x: x, y: y, angle: spawnAngle, mapId: objId),
|
||||
EnemyType.officer => Officer(x: x, y: y, angle: spawnAngle, mapId: objId),
|
||||
EnemyType.guard => Guard(
|
||||
x: x,
|
||||
y: y,
|
||||
angle: spawnAngle,
|
||||
mapId: objId,
|
||||
difficulty: difficulty,
|
||||
),
|
||||
EnemyType.dog => Dog(
|
||||
x: x,
|
||||
y: y,
|
||||
angle: spawnAngle,
|
||||
mapId: objId,
|
||||
difficulty: difficulty,
|
||||
),
|
||||
EnemyType.ss => SS(
|
||||
x: x,
|
||||
y: y,
|
||||
angle: spawnAngle,
|
||||
mapId: objId,
|
||||
difficulty: difficulty,
|
||||
),
|
||||
EnemyType.mutant => Mutant(
|
||||
x: x,
|
||||
y: y,
|
||||
angle: spawnAngle,
|
||||
mapId: objId,
|
||||
difficulty: difficulty,
|
||||
),
|
||||
EnemyType.officer => Officer(
|
||||
x: x,
|
||||
y: y,
|
||||
angle: spawnAngle,
|
||||
mapId: objId,
|
||||
difficulty: difficulty,
|
||||
),
|
||||
}..state = spawnState;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ enum EnemyType {
|
||||
/// SS Officer (Blue uniform, machine gun).
|
||||
ss(
|
||||
mapData: EnemyMapData(MapObject.ssStart),
|
||||
scoreValue: 100,
|
||||
scoreValue: 500,
|
||||
alertSoundId: WolfSound.ssSchutzstaffel,
|
||||
attackSoundId: WolfSound.naziFire,
|
||||
deathSoundId: WolfSound.ssMeinGott,
|
||||
@@ -67,7 +67,7 @@ enum EnemyType {
|
||||
/// Undead Mutant (Exclusive to later episodes/retail).
|
||||
mutant(
|
||||
mapData: EnemyMapData(MapObject.mutantStart, tierOffset: 18),
|
||||
scoreValue: 100,
|
||||
scoreValue: 700,
|
||||
alertSoundId: WolfSound.guardHalt,
|
||||
attackSoundId: WolfSound.naziFire,
|
||||
deathSoundId: WolfSound.deathScream2,
|
||||
@@ -85,7 +85,7 @@ enum EnemyType {
|
||||
/// High-ranking Officer (White uniform, fast pistol).
|
||||
officer(
|
||||
mapData: EnemyMapData(MapObject.officerStart),
|
||||
scoreValue: 100,
|
||||
scoreValue: 400,
|
||||
alertSoundId: WolfSound.guardHalt,
|
||||
attackSoundId: WolfSound.naziFire,
|
||||
deathSoundId: WolfSound.deathScream3,
|
||||
@@ -163,6 +163,22 @@ enum EnemyType {
|
||||
return getAnimations(isShareware)?.getAnimation(index) != null;
|
||||
}
|
||||
|
||||
/// Returns canonical hit points from the original source per difficulty.
|
||||
int hitPointsFor(Difficulty difficulty) {
|
||||
return switch (this) {
|
||||
EnemyType.guard => 25,
|
||||
EnemyType.officer => 50,
|
||||
EnemyType.ss => 100,
|
||||
EnemyType.dog => 1,
|
||||
EnemyType.mutant => switch (difficulty) {
|
||||
Difficulty.baby => 45,
|
||||
Difficulty.easy => 55,
|
||||
Difficulty.medium => 55,
|
||||
Difficulty.hard => 65,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// Resolves the [EnemyAnimation] state for a specific [spriteIndex].
|
||||
EnemyAnimation? getAnimationFromSprite(
|
||||
int spriteIndex, {
|
||||
|
||||
@@ -18,12 +18,12 @@ class Guard extends Enemy {
|
||||
required super.y,
|
||||
required super.angle,
|
||||
required super.mapId,
|
||||
Difficulty difficulty = Difficulty.medium,
|
||||
}) : super(
|
||||
spriteIndex: EnemyType.guard.animations.idle.start,
|
||||
state: EntityState.idle,
|
||||
) {
|
||||
health = 25;
|
||||
damage = 10;
|
||||
health = type.hitPointsFor(difficulty);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -31,6 +31,8 @@ class Guard extends Enemy {
|
||||
required int elapsedMs,
|
||||
required int elapsedDeltaMs,
|
||||
required Coordinate2D playerPosition,
|
||||
required double playerAngle,
|
||||
required bool isPlayerRunning,
|
||||
required bool Function(int x, int y) isWalkable,
|
||||
required void Function(int damage) onDamagePlayer,
|
||||
required void Function(int x, int y) tryOpenDoor,
|
||||
@@ -53,15 +55,27 @@ class Guard extends Enemy {
|
||||
bool ticReady = processTics(elapsedDeltaMs, moveSpeed: 0);
|
||||
|
||||
if (state == EntityState.attacking) {
|
||||
handleAttackState(
|
||||
elapsedDeltaMs: elapsedDeltaMs,
|
||||
distance: distance,
|
||||
onDamagePlayer: onDamagePlayer,
|
||||
onAttack: () => onPlaySound(attackSoundId),
|
||||
shootTics: 20,
|
||||
cooldownTics: 20,
|
||||
postAttackTics: 20,
|
||||
);
|
||||
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
|
||||
currentFrame++;
|
||||
if (currentFrame == 1) {
|
||||
onPlaySound(attackSoundId);
|
||||
if (tryRollRangedHit(
|
||||
distance: distance,
|
||||
playerPosition: playerPosition,
|
||||
playerAngle: playerAngle,
|
||||
isPlayerRunning: isPlayerRunning,
|
||||
)) {
|
||||
onDamagePlayer(rollRangedDamage(distance));
|
||||
}
|
||||
setTics(20);
|
||||
} else if (currentFrame == 2) {
|
||||
setTics(20);
|
||||
} else {
|
||||
state = EntityState.patrolling;
|
||||
currentFrame = 0;
|
||||
setTics(20);
|
||||
}
|
||||
}
|
||||
} else if (state == EntityState.pain) {
|
||||
if (ticReady) {
|
||||
state = isAlerted ? EntityState.patrolling : EntityState.idle;
|
||||
|
||||
@@ -16,12 +16,12 @@ class Mutant extends Enemy {
|
||||
required super.y,
|
||||
required super.angle,
|
||||
required super.mapId,
|
||||
Difficulty difficulty = Difficulty.medium,
|
||||
}) : super(
|
||||
spriteIndex: EnemyType.mutant.animations.idle.start,
|
||||
state: EntityState.idle,
|
||||
) {
|
||||
health = 45;
|
||||
damage = 10;
|
||||
health = type.hitPointsFor(difficulty);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -29,6 +29,8 @@ class Mutant extends Enemy {
|
||||
required int elapsedMs,
|
||||
required int elapsedDeltaMs,
|
||||
required Coordinate2D playerPosition,
|
||||
required double playerAngle,
|
||||
required bool isPlayerRunning,
|
||||
required bool Function(int x, int y) isWalkable,
|
||||
required void Function(int damage) onDamagePlayer,
|
||||
required void Function(int x, int y) tryOpenDoor,
|
||||
@@ -108,7 +110,14 @@ class Mutant extends Enemy {
|
||||
currentFrame++;
|
||||
if (currentFrame == 1) {
|
||||
onPlaySound(attackSoundId);
|
||||
onDamagePlayer(damage);
|
||||
if (tryRollRangedHit(
|
||||
distance: distance,
|
||||
playerPosition: playerPosition,
|
||||
playerAngle: playerAngle,
|
||||
isPlayerRunning: isPlayerRunning,
|
||||
)) {
|
||||
onDamagePlayer(rollRangedDamage(distance));
|
||||
}
|
||||
setTics(4);
|
||||
} else if (currentFrame == 2) {
|
||||
setTics(4);
|
||||
|
||||
@@ -16,12 +16,12 @@ class Officer extends Enemy {
|
||||
required super.y,
|
||||
required super.angle,
|
||||
required super.mapId,
|
||||
Difficulty difficulty = Difficulty.medium,
|
||||
}) : super(
|
||||
spriteIndex: EnemyType.officer.animations.idle.start,
|
||||
state: EntityState.idle,
|
||||
) {
|
||||
health = 50;
|
||||
damage = 15;
|
||||
health = type.hitPointsFor(difficulty);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -29,6 +29,8 @@ class Officer extends Enemy {
|
||||
required int elapsedMs,
|
||||
required int elapsedDeltaMs,
|
||||
required Coordinate2D playerPosition,
|
||||
required double playerAngle,
|
||||
required bool isPlayerRunning,
|
||||
required bool Function(int x, int y) isWalkable,
|
||||
required void Function(int damage) onDamagePlayer,
|
||||
required void Function(int x, int y) tryOpenDoor,
|
||||
@@ -108,7 +110,14 @@ class Officer extends Enemy {
|
||||
currentFrame++;
|
||||
if (currentFrame == 1) {
|
||||
onPlaySound(attackSoundId);
|
||||
onDamagePlayer(damage);
|
||||
if (tryRollRangedHit(
|
||||
distance: distance,
|
||||
playerPosition: playerPosition,
|
||||
playerAngle: playerAngle,
|
||||
isPlayerRunning: isPlayerRunning,
|
||||
)) {
|
||||
onDamagePlayer(rollRangedDamage(distance));
|
||||
}
|
||||
setTics(4); // Bang!
|
||||
} else if (currentFrame == 2) {
|
||||
setTics(4); // Cooldown
|
||||
|
||||
@@ -16,18 +16,20 @@ class SS extends Enemy {
|
||||
required super.y,
|
||||
required super.angle,
|
||||
required super.mapId,
|
||||
Difficulty difficulty = Difficulty.medium,
|
||||
}) : super(
|
||||
spriteIndex: EnemyType.ss.animations.idle.start,
|
||||
state: EntityState.idle,
|
||||
) {
|
||||
health = 100;
|
||||
damage = 20;
|
||||
health = type.hitPointsFor(difficulty);
|
||||
}
|
||||
@override
|
||||
({Coordinate2D movement, double newAngle}) update({
|
||||
required int elapsedMs,
|
||||
required int elapsedDeltaMs,
|
||||
required Coordinate2D playerPosition,
|
||||
required double playerAngle,
|
||||
required bool isPlayerRunning,
|
||||
required bool Function(int x, int y) isWalkable,
|
||||
required void Function(int damage) onDamagePlayer,
|
||||
required void Function(int x, int y) tryOpenDoor,
|
||||
@@ -107,7 +109,15 @@ class SS extends Enemy {
|
||||
currentFrame++;
|
||||
if (currentFrame == 1) {
|
||||
onPlaySound(attackSoundId);
|
||||
onDamagePlayer(damage);
|
||||
if (tryRollRangedHit(
|
||||
distance: distance,
|
||||
playerPosition: playerPosition,
|
||||
playerAngle: playerAngle,
|
||||
isPlayerRunning: isPlayerRunning,
|
||||
isSharpShooter: true,
|
||||
)) {
|
||||
onDamagePlayer(rollRangedDamage(distance, isSharpShooter: true));
|
||||
}
|
||||
setTics(5); // Bang!
|
||||
} else if (currentFrame == 2) {
|
||||
setTics(5); // Cooldown
|
||||
@@ -118,7 +128,17 @@ class SS extends Enemy {
|
||||
// 50% chance to burst
|
||||
currentFrame = 1;
|
||||
onPlaySound(attackSoundId);
|
||||
onDamagePlayer(damage);
|
||||
if (tryRollRangedHit(
|
||||
distance: distance,
|
||||
playerPosition: playerPosition,
|
||||
playerAngle: playerAngle,
|
||||
isPlayerRunning: isPlayerRunning,
|
||||
isSharpShooter: true,
|
||||
)) {
|
||||
onDamagePlayer(
|
||||
rollRangedDamage(distance, isSharpShooter: true),
|
||||
);
|
||||
}
|
||||
setTics(5);
|
||||
return (movement: movement, newAngle: newAngle);
|
||||
}
|
||||
|
||||
148
packages/wolf_3d_dart/test/engine/enemy_drop_parity_test.dart
Normal file
148
packages/wolf_3d_dart/test/engine/enemy_drop_parity_test.dart
Normal file
@@ -0,0 +1,148 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:test/test.dart';
|
||||
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
||||
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
|
||||
import 'package:wolf_3d_dart/wolf_3d_input.dart';
|
||||
|
||||
void main() {
|
||||
group('Canonical enemy drop parity', () {
|
||||
test('SS drops machine gun if player does not have one', () {
|
||||
final engine = _buildEngine();
|
||||
engine.init();
|
||||
|
||||
final ss = SS(x: 3.5, y: 2.5, angle: 0, mapId: MapObject.ssStart);
|
||||
engine.entities.add(ss);
|
||||
|
||||
ss.takeDamage(999, engine.timeAliveMs);
|
||||
engine.tick(const Duration(milliseconds: 16));
|
||||
|
||||
final droppedMachineGun =
|
||||
engine.entities.whereType<WeaponCollectible>().any(
|
||||
(item) => item.mapId == MapObject.machineGun,
|
||||
);
|
||||
expect(droppedMachineGun, isTrue);
|
||||
});
|
||||
|
||||
test('SS drops small clip if player already has machine gun', () {
|
||||
final engine = _buildEngine();
|
||||
engine.init();
|
||||
engine.player.hasMachineGun = true;
|
||||
|
||||
final ss = SS(x: 3.5, y: 2.5, angle: 0, mapId: MapObject.ssStart);
|
||||
engine.entities.add(ss);
|
||||
|
||||
ss.takeDamage(999, engine.timeAliveMs);
|
||||
engine.tick(const Duration(milliseconds: 16));
|
||||
|
||||
expect(engine.entities.whereType<SmallAmmoCollectible>(), hasLength(1));
|
||||
expect(engine.entities.whereType<WeaponCollectible>(), isEmpty);
|
||||
});
|
||||
|
||||
test('dog does not drop items on death', () {
|
||||
final engine = _buildEngine();
|
||||
engine.init();
|
||||
|
||||
final dog = Dog(x: 3.5, y: 2.5, angle: 0, mapId: MapObject.dogStart);
|
||||
engine.entities.add(dog);
|
||||
|
||||
dog.takeDamage(999, engine.timeAliveMs);
|
||||
engine.tick(const Duration(milliseconds: 16));
|
||||
|
||||
expect(engine.entities.whereType<Collectible>(), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
WolfEngine _buildEngine() {
|
||||
final wallGrid = _buildGrid();
|
||||
final objectGrid = _buildGrid();
|
||||
|
||||
_fillBoundaries(wallGrid, 2);
|
||||
objectGrid[2][2] = MapObject.playerEast;
|
||||
|
||||
return WolfEngine(
|
||||
data: WolfensteinData(
|
||||
version: GameVersion.retail,
|
||||
dataVersion: DataVersion.unknown,
|
||||
registry: RetailAssetRegistry(),
|
||||
walls: [
|
||||
_solidSprite(1),
|
||||
_solidSprite(1),
|
||||
_solidSprite(2),
|
||||
_solidSprite(2),
|
||||
],
|
||||
sprites: List.generate(436, (_) => _solidSprite(255)),
|
||||
sounds: List.generate(200, (_) => PcmSound(Uint8List(1))),
|
||||
adLibSounds: const [],
|
||||
music: const [],
|
||||
vgaImages: const [],
|
||||
episodes: [
|
||||
Episode(
|
||||
name: 'Episode 1',
|
||||
levels: [
|
||||
WolfLevel(
|
||||
name: 'Test Level',
|
||||
wallGrid: wallGrid,
|
||||
objectGrid: objectGrid,
|
||||
musicIndex: 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
difficulty: Difficulty.hard,
|
||||
startingEpisode: 0,
|
||||
frameBuffer: FrameBuffer(64, 64),
|
||||
input: _TestInput(),
|
||||
engineAudio: _SilentAudio(),
|
||||
onGameWon: () {},
|
||||
);
|
||||
}
|
||||
|
||||
class _TestInput extends Wolf3dInput {
|
||||
@override
|
||||
void update() {}
|
||||
}
|
||||
|
||||
class _SilentAudio implements EngineAudio {
|
||||
@override
|
||||
WolfensteinData? activeGame;
|
||||
|
||||
@override
|
||||
Future<void> debugSoundTest() async {}
|
||||
|
||||
@override
|
||||
Future<void> init() async {}
|
||||
|
||||
@override
|
||||
void playLevelMusic(WolfLevel level) {}
|
||||
|
||||
@override
|
||||
void playMenuMusic() {}
|
||||
|
||||
@override
|
||||
void playSoundEffect(int sfxId) {}
|
||||
|
||||
@override
|
||||
void stopMusic() {}
|
||||
|
||||
@override
|
||||
void dispose() {}
|
||||
}
|
||||
|
||||
SpriteMap _buildGrid() => List.generate(64, (_) => List.filled(64, 0));
|
||||
|
||||
void _fillBoundaries(SpriteMap grid, int wallId) {
|
||||
for (int i = 0; i < 64; i++) {
|
||||
grid[0][i] = wallId;
|
||||
grid[63][i] = wallId;
|
||||
grid[i][0] = wallId;
|
||||
grid[i][63] = wallId;
|
||||
}
|
||||
}
|
||||
|
||||
Sprite _solidSprite(int colorIndex) {
|
||||
return Sprite(Uint8List.fromList(List.filled(64 * 64, colorIndex)));
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'package:test/test.dart';
|
||||
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
||||
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
|
||||
|
||||
void main() {
|
||||
group('Canonical numeric parity', () {
|
||||
test('enemy HP values match source tables for implemented classes', () {
|
||||
final guardBaby = Guard(
|
||||
x: 1,
|
||||
y: 1,
|
||||
angle: 0,
|
||||
mapId: MapObject.guardStart,
|
||||
difficulty: Difficulty.baby,
|
||||
);
|
||||
final guardHard = Guard(
|
||||
x: 1,
|
||||
y: 1,
|
||||
angle: 0,
|
||||
mapId: MapObject.guardStart,
|
||||
difficulty: Difficulty.hard,
|
||||
);
|
||||
final mutantBaby = Mutant(
|
||||
x: 1,
|
||||
y: 1,
|
||||
angle: 0,
|
||||
mapId: MapObject.mutantStart,
|
||||
difficulty: Difficulty.baby,
|
||||
);
|
||||
final mutantEasy = Mutant(
|
||||
x: 1,
|
||||
y: 1,
|
||||
angle: 0,
|
||||
mapId: MapObject.mutantStart,
|
||||
difficulty: Difficulty.easy,
|
||||
);
|
||||
final mutantHard = Mutant(
|
||||
x: 1,
|
||||
y: 1,
|
||||
angle: 0,
|
||||
mapId: MapObject.mutantStart,
|
||||
difficulty: Difficulty.hard,
|
||||
);
|
||||
|
||||
expect(guardBaby.health, 25);
|
||||
expect(guardHard.health, 25);
|
||||
expect(mutantBaby.health, 45);
|
||||
expect(mutantEasy.health, 55);
|
||||
expect(mutantHard.health, 65);
|
||||
});
|
||||
|
||||
test('collectible pickup values match canonical amounts', () {
|
||||
const context = CollectiblePickupContext(
|
||||
health: 50,
|
||||
ammo: 10,
|
||||
hasGoldKey: false,
|
||||
hasSilverKey: false,
|
||||
hasMachineGun: false,
|
||||
hasChainGun: false,
|
||||
currentWeaponType: WeaponType.pistol,
|
||||
);
|
||||
|
||||
final food = HealthCollectible(x: 1, y: 1, mapId: MapObject.food);
|
||||
final medkit = HealthCollectible(x: 1, y: 1, mapId: MapObject.medkit);
|
||||
final clip = AmmoCollectible(x: 1, y: 1);
|
||||
final clipDrop = SmallAmmoCollectible(x: 1, y: 1);
|
||||
final machineGun = WeaponCollectible(
|
||||
x: 1,
|
||||
y: 1,
|
||||
mapId: MapObject.machineGun,
|
||||
);
|
||||
final fullHeal = TreasureCollectible(
|
||||
x: 1,
|
||||
y: 1,
|
||||
mapId: MapObject.extraLife,
|
||||
);
|
||||
|
||||
expect(food.tryCollect(context)?.healthToRestore, 10);
|
||||
expect(medkit.tryCollect(context)?.healthToRestore, 25);
|
||||
expect(clip.tryCollect(context)?.ammoToAdd, 8);
|
||||
expect(clipDrop.tryCollect(context)?.ammoToAdd, 4);
|
||||
expect(machineGun.tryCollect(context)?.ammoToAdd, 6);
|
||||
expect(fullHeal.tryCollect(context)?.healthToRestore, 99);
|
||||
expect(fullHeal.tryCollect(context)?.ammoToAdd, 25);
|
||||
expect(fullHeal.tryCollect(context)?.extraLivesToAdd, 1);
|
||||
});
|
||||
|
||||
test('extra life collectible increases player lives and caps at 9', () {
|
||||
final player = Player(x: 1.5, y: 1.5, angle: 0);
|
||||
final extraLife = TreasureCollectible(
|
||||
x: 1,
|
||||
y: 1,
|
||||
mapId: MapObject.extraLife,
|
||||
);
|
||||
|
||||
expect(player.lives, 3);
|
||||
player.tryPickup(extraLife);
|
||||
expect(player.lives, 4);
|
||||
|
||||
for (int i = 0; i < 20; i++) {
|
||||
player.addLives(1);
|
||||
}
|
||||
expect(player.lives, 9);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:test/test.dart';
|
||||
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||
|
||||
void main() {
|
||||
group('Difficulty incoming damage scaling', () {
|
||||
test('baby mode applies canonical heavy reduction', () {
|
||||
expect(Difficulty.baby.scaleIncomingEnemyDamage(1), 1);
|
||||
expect(Difficulty.baby.scaleIncomingEnemyDamage(4), 1);
|
||||
expect(Difficulty.baby.scaleIncomingEnemyDamage(8), 2);
|
||||
expect(Difficulty.baby.scaleIncomingEnemyDamage(16), 4);
|
||||
expect(Difficulty.baby.scaleIncomingEnemyDamage(63), 15);
|
||||
});
|
||||
|
||||
test('easy and above preserve full incoming damage', () {
|
||||
expect(Difficulty.easy.scaleIncomingEnemyDamage(16), 16);
|
||||
expect(Difficulty.medium.scaleIncomingEnemyDamage(16), 16);
|
||||
expect(Difficulty.hard.scaleIncomingEnemyDamage(16), 16);
|
||||
});
|
||||
|
||||
test('non-positive damage stays non-positive', () {
|
||||
expect(Difficulty.baby.scaleIncomingEnemyDamage(0), 0);
|
||||
expect(Difficulty.hard.scaleIncomingEnemyDamage(0), 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -42,10 +42,10 @@ void main() {
|
||||
});
|
||||
|
||||
test('spawns standing variants as idle', () {
|
||||
expect(_spawnEnemy(134).state, EntityState.idle);
|
||||
expect(_spawnEnemy(135).state, EntityState.idle);
|
||||
expect(_spawnEnemy(136).state, EntityState.idle);
|
||||
expect(_spawnEnemy(137).state, EntityState.idle);
|
||||
expect(_spawnEnemy(134).state, EntityState.ambushing);
|
||||
expect(_spawnEnemy(135).state, EntityState.ambushing);
|
||||
expect(_spawnEnemy(136).state, EntityState.ambushing);
|
||||
expect(_spawnEnemy(137).state, EntityState.ambushing);
|
||||
});
|
||||
|
||||
test('spawns patrol variants as patrolling', () {
|
||||
@@ -54,6 +54,74 @@ void main() {
|
||||
expect(_spawnEnemy(140).state, EntityState.patrolling);
|
||||
expect(_spawnEnemy(141).state, EntityState.patrolling);
|
||||
});
|
||||
|
||||
test('standing guard and dog do not move before alert', () {
|
||||
final guard = _spawnEnemy(108); // guard standing east
|
||||
final dog = _spawnEnemy(134); // dog standing east
|
||||
|
||||
final guardIntent = guard.update(
|
||||
elapsedMs: 1000,
|
||||
elapsedDeltaMs: 16,
|
||||
playerPosition: const Coordinate2D(20, 20),
|
||||
playerAngle: 0,
|
||||
isPlayerRunning: false,
|
||||
isWalkable: (_, _) => false,
|
||||
onDamagePlayer: (_) {},
|
||||
tryOpenDoor: (_, _) {},
|
||||
onPlaySound: (_) {},
|
||||
);
|
||||
|
||||
final dogIntent = dog.update(
|
||||
elapsedMs: 1000,
|
||||
elapsedDeltaMs: 16,
|
||||
playerPosition: const Coordinate2D(20, 20),
|
||||
playerAngle: 0,
|
||||
isPlayerRunning: false,
|
||||
isWalkable: (_, _) => false,
|
||||
onDamagePlayer: (_) {},
|
||||
tryOpenDoor: (_, _) {},
|
||||
onPlaySound: (_) {},
|
||||
);
|
||||
|
||||
expect(guardIntent.movement.x, 0);
|
||||
expect(guardIntent.movement.y, 0);
|
||||
expect(dogIntent.movement.x, 0);
|
||||
expect(dogIntent.movement.y, 0);
|
||||
});
|
||||
|
||||
test('patrol guard and dog produce movement intent', () {
|
||||
final guard = _spawnEnemy(112); // guard patrol east
|
||||
final dog = _spawnEnemy(138); // dog patrol east
|
||||
|
||||
final guardIntent = guard.update(
|
||||
elapsedMs: 1000,
|
||||
elapsedDeltaMs: 16,
|
||||
playerPosition: const Coordinate2D(40, 40),
|
||||
playerAngle: 0,
|
||||
isPlayerRunning: false,
|
||||
isWalkable: (_, _) => true,
|
||||
onDamagePlayer: (_) {},
|
||||
tryOpenDoor: (_, _) {},
|
||||
onPlaySound: (_) {},
|
||||
);
|
||||
|
||||
final dogIntent = dog.update(
|
||||
elapsedMs: 1000,
|
||||
elapsedDeltaMs: 16,
|
||||
playerPosition: const Coordinate2D(40, 40),
|
||||
playerAngle: 0,
|
||||
isPlayerRunning: false,
|
||||
isWalkable: (_, _) => true,
|
||||
onDamagePlayer: (_) {},
|
||||
tryOpenDoor: (_, _) {},
|
||||
onPlaySound: (_) {},
|
||||
);
|
||||
|
||||
expect(guardIntent.movement.x.abs() + guardIntent.movement.y.abs(),
|
||||
greaterThan(0));
|
||||
expect(
|
||||
dogIntent.movement.x.abs() + dogIntent.movement.y.abs(), greaterThan(0));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -29,11 +29,25 @@ void main() {
|
||||
test('enemy instances expose score values from enemy type metadata', () {
|
||||
final guard = Guard(x: 1, y: 1, angle: 0, mapId: MapObject.guardStart);
|
||||
final dog = Dog(x: 1, y: 1, angle: 0, mapId: MapObject.dogStart);
|
||||
final officer = Officer(
|
||||
x: 1,
|
||||
y: 1,
|
||||
angle: 0,
|
||||
mapId: MapObject.officerStart,
|
||||
);
|
||||
final ss = SS(x: 1, y: 1, angle: 0, mapId: MapObject.ssStart);
|
||||
final mutant = Mutant(
|
||||
x: 1,
|
||||
y: 1,
|
||||
angle: 0,
|
||||
mapId: MapObject.mutantStart,
|
||||
);
|
||||
|
||||
expect(guard.scoreValue, 100);
|
||||
expect(dog.scoreValue, 200);
|
||||
expect(ss.scoreValue, 100);
|
||||
expect(officer.scoreValue, 400);
|
||||
expect(ss.scoreValue, 500);
|
||||
expect(mutant.scoreValue, 700);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user