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

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

View File

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

View File

@@ -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) {

View File

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

View File

@@ -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,

View File

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

View File

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

View File

@@ -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, {

View File

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

View File

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

View File

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

View File

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

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

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