diff --git a/packages/wolf_3d_dart/lib/src/data_types/difficulty.dart b/packages/wolf_3d_dart/lib/src/data_types/difficulty.dart index 6b69033..c621284 100644 --- a/packages/wolf_3d_dart/lib/src/data_types/difficulty.dart +++ b/packages/wolf_3d_dart/lib/src/data_types/difficulty.dart @@ -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; + } } diff --git a/packages/wolf_3d_dart/lib/src/engine/player/player.dart b/packages/wolf_3d_dart/lib/src/engine/player/player.dart index e543915..09bddd0 100644 --- a/packages/wolf_3d_dart/lib/src/engine/player/player.dart +++ b/packages/wolf_3d_dart/lib/src/engine/player/player.dart @@ -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; } diff --git a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart index 1de0f42..398b185 100644 --- a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart +++ b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart @@ -125,6 +125,8 @@ class WolfEngine { List 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) { diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/collectible.dart b/packages/wolf_3d_dart/lib/src/entities/entities/collectible.dart index 1cbfbed..5432565 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/collectible.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/collectible.dart @@ -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, ); } diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/bosses/hans_grosse.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/bosses/hans_grosse.dart index c1d8dda..22f6ad2 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/bosses/hans_grosse.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/bosses/hans_grosse.dart @@ -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, diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/dog.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/dog.dart index f427c0d..cde4b34 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/dog.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/dog.dart @@ -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; diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy.dart index 7e4546d..0a97056 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy.dart @@ -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; } } diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy_type.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy_type.dart index 4cec714..d37d694 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy_type.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy_type.dart @@ -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, { diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/guard.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/guard.dart index f8de7ff..6096f1d 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/guard.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/guard.dart @@ -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; diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/mutant.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/mutant.dart index 86fdbd1..0ae9ab7 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/mutant.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/mutant.dart @@ -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); diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/officer.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/officer.dart index f8307d8..0fb6b65 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/officer.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/officer.dart @@ -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 diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/ss.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/ss.dart index 6df74a7..2d6c2b1 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/ss.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/ss.dart @@ -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); } diff --git a/packages/wolf_3d_dart/test/engine/enemy_drop_parity_test.dart b/packages/wolf_3d_dart/test/engine/enemy_drop_parity_test.dart new file mode 100644 index 0000000..5228bdf --- /dev/null +++ b/packages/wolf_3d_dart/test/engine/enemy_drop_parity_test.dart @@ -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().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(), hasLength(1)); + expect(engine.entities.whereType(), 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(), 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 debugSoundTest() async {} + + @override + Future 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))); +} diff --git a/packages/wolf_3d_dart/test/entities/canonical_numeric_parity_test.dart b/packages/wolf_3d_dart/test/entities/canonical_numeric_parity_test.dart new file mode 100644 index 0000000..3bcc8b5 --- /dev/null +++ b/packages/wolf_3d_dart/test/entities/canonical_numeric_parity_test.dart @@ -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); + }); + }); +} diff --git a/packages/wolf_3d_dart/test/entities/difficulty_damage_scaling_test.dart b/packages/wolf_3d_dart/test/entities/difficulty_damage_scaling_test.dart new file mode 100644 index 0000000..4bda81d --- /dev/null +++ b/packages/wolf_3d_dart/test/entities/difficulty_damage_scaling_test.dart @@ -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); + }); + }); +} diff --git a/packages/wolf_3d_dart/test/entities/enemy_spawn_test.dart b/packages/wolf_3d_dart/test/entities/enemy_spawn_test.dart index 74481f0..58bb245 100644 --- a/packages/wolf_3d_dart/test/entities/enemy_spawn_test.dart +++ b/packages/wolf_3d_dart/test/entities/enemy_spawn_test.dart @@ -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)); + }); }); } diff --git a/packages/wolf_3d_dart/test/entities/scoring_ownership_test.dart b/packages/wolf_3d_dart/test/entities/scoring_ownership_test.dart index f26ac15..0bae545 100644 --- a/packages/wolf_3d_dart/test/entities/scoring_ownership_test.dart +++ b/packages/wolf_3d_dart/test/entities/scoring_ownership_test.dart @@ -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); }); }); }