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:
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