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:
2026-03-19 16:02:36 +01:00
parent 8bf0dbd57c
commit 225840f3ee
17 changed files with 644 additions and 63 deletions

View File

@@ -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);
});
});
}

View File

@@ -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);
});
});
}

View File

@@ -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));
});
});
}

View File

@@ -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);
});
});
}