From 57dde0f31cb248b365eab89a6bb91256b76d2481 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Thu, 19 Mar 2026 14:25:14 +0100 Subject: [PATCH] Refactor collectible handling and scoring system - Introduced `CollectiblePickupContext` and `CollectiblePickupEffect` to streamline the collectible pickup logic in the Player class. - Updated the `tryPickup` method in the Player class to utilize the new effect system for health, ammo, score, keys, and weapons. - Refactored collectible classes to implement the new `tryCollect` method, returning a `CollectiblePickupEffect`. - Enhanced enemy classes to expose score values based on their type. - Added unit tests for scoring ownership and shareware menu module functionality. - Updated rendering logic in various renderer classes to use the new mapped picture retrieval system. - Improved the `WolfClassicMenuArt` class to utilize a more structured approach for image retrieval based on registry keys. Signed-off-by: Hans Kokx --- .../lib/src/engine/player/player.dart | 149 ++++------- .../src/entities/entities/collectible.dart | 232 ++++++++++++++++-- .../src/entities/entities/enemies/enemy.dart | 3 + .../entities/entities/enemies/enemy_type.dart | 9 + .../built_in/shareware_asset_registry.dart | 6 +- .../built_in/shareware_menu_module.dart | 43 +++- .../lib/src/registry/registry_resolver.dart | 2 +- .../lib/src/rendering/ascii_renderer.dart | 6 +- .../lib/src/rendering/sixel_renderer.dart | 10 +- .../lib/src/rendering/software_renderer.dart | 10 +- packages/wolf_3d_dart/lib/wolf_3d_menu.dart | 159 ++++++------ .../test/engine/audio_events_test.dart | 8 +- .../test/entities/scoring_ownership_test.dart | 39 +++ .../registry/shareware_menu_module_test.dart | 88 +++++++ .../wolf_classic_menu_art_mapping_test.dart | 43 ++++ 15 files changed, 584 insertions(+), 223 deletions(-) create mode 100644 packages/wolf_3d_dart/test/entities/scoring_ownership_test.dart create mode 100644 packages/wolf_3d_dart/test/registry/shareware_menu_module_test.dart create mode 100644 packages/wolf_3d_dart/test/registry/wolf_classic_menu_art_mapping_test.dart 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 cea796d..e543915 100644 --- a/packages/wolf_3d_dart/lib/src/engine/player/player.dart +++ b/packages/wolf_3d_dart/lib/src/engine/player/player.dart @@ -139,86 +139,58 @@ class Player { /// /// Returns `null` when the item was not collected (for example: full health). int? tryPickup(Collectible item) { - bool pickedUp = false; - int? pickupSfxId; + final effect = item.tryCollect( + CollectiblePickupContext( + health: health, + ammo: ammo, + hasGoldKey: hasGoldKey, + hasSilverKey: hasSilverKey, + hasMachineGun: hasMachineGun, + hasChainGun: hasChainGun, + currentWeaponType: currentWeapon.type, + ), + ); + if (effect == null) return null; - switch (item.type) { - case CollectibleType.health: - if (health >= 100) return null; - heal(item.mapId == MapObject.food ? 4 : 25); - pickupSfxId = item.mapId == MapObject.food - ? WolfSound.healthSmall - : WolfSound.healthLarge; - pickedUp = true; - break; - - case CollectibleType.ammo: - if (ammo >= 99) return null; - int previousAmmo = ammo; - addAmmo(8); - if (currentWeapon is Knife && previousAmmo <= 0) { - requestWeaponSwitch(WeaponType.pistol); - } - pickupSfxId = WolfSound.getAmmo; - pickedUp = true; - break; - - case CollectibleType.treasure: - if (item.mapId == MapObject.cross) { - score += 100; - pickupSfxId = WolfSound.treasure1; - } - if (item.mapId == MapObject.chalice) { - score += 500; - pickupSfxId = WolfSound.treasure2; - } - if (item.mapId == MapObject.chest) { - score += 1000; - pickupSfxId = WolfSound.treasure3; - } - if (item.mapId == MapObject.crown) { - score += 5000; - pickupSfxId = WolfSound.treasure4; - } - if (item.mapId == MapObject.extraLife) { - heal(100); - addAmmo(25); - pickupSfxId = WolfSound.extraLife; - } - pickedUp = true; - break; - - case CollectibleType.weapon: - if (item.mapId == MapObject.machineGun) { - if (weapons[WeaponType.machineGun] == null) { - weapons[WeaponType.machineGun] = MachineGun(); - hasMachineGun = true; - } - addAmmo(8); - requestWeaponSwitch(WeaponType.machineGun); - pickupSfxId = WolfSound.getMachineGun; - pickedUp = true; - } - if (item.mapId == MapObject.chainGun) { - if (weapons[WeaponType.chainGun] == null) { - weapons[WeaponType.chainGun] = ChainGun(); - hasChainGun = true; - } - addAmmo(8); - requestWeaponSwitch(WeaponType.chainGun); - pickupSfxId = WolfSound.getGatling; - pickedUp = true; - } - break; - - case CollectibleType.key: - if (item.mapId == MapObject.goldKey) hasGoldKey = true; - if (item.mapId == MapObject.silverKey) hasSilverKey = true; - pickupSfxId = WolfSound.getAmmo; - pickedUp = true; - break; + if (effect.healthToRestore > 0) { + heal(effect.healthToRestore); } - return pickedUp ? pickupSfxId : null; + + if (effect.ammoToAdd > 0) { + addAmmo(effect.ammoToAdd); + } + + if (effect.scoreToAdd > 0) { + score += effect.scoreToAdd; + } + + if (effect.grantGoldKey) { + hasGoldKey = true; + } + + if (effect.grantSilverKey) { + hasSilverKey = true; + } + + if (effect.grantWeapon case final weaponType?) { + if (weapons[weaponType] == null) { + weapons[weaponType] = switch (weaponType) { + WeaponType.machineGun => MachineGun(), + WeaponType.chainGun => ChainGun(), + WeaponType.pistol => Pistol(), + WeaponType.knife => Knife(), + }; + } + + if (weaponType == WeaponType.machineGun) hasMachineGun = true; + if (weaponType == WeaponType.chainGun) hasChainGun = true; + } + + if (effect.requestWeaponSwitch case final weaponType?) { + requestWeaponSwitch(weaponType); + } + + return effect.pickupSfxId; } bool fire(int currentTime) { @@ -259,26 +231,9 @@ class Player { isWalkable: isWalkable, currentTime: currentTime, onEnemyKilled: (Enemy killedEnemy) { - // Dynamic scoring based on the enemy type! - int pointsToAdd = 0; - - switch (killedEnemy.runtimeType.toString()) { - case 'BrownGuard': - pointsToAdd = 100; - break; - case 'Dog': - pointsToAdd = 200; - break; - // You can easily plug in future enemies here! - // case 'SSOfficer': pointsToAdd = 500; break; - default: - pointsToAdd = 100; // Fallback - } - - score += pointsToAdd; - // Optional: Print to console so you can see it working + score += killedEnemy.scoreValue; print( - "Killed ${killedEnemy.runtimeType}! +$pointsToAdd (Score: $score)", + "Killed ${killedEnemy.runtimeType}! +${killedEnemy.scoreValue} (Score: $score)", ); }, ); 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 5503ec5..1cbfbed 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/collectible.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/collectible.dart @@ -1,33 +1,73 @@ -import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; +import 'package:wolf_3d_dart/src/entities/entities/weapon/weapon.dart'; import 'package:wolf_3d_dart/src/entities/entity.dart'; +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; -enum CollectibleType { ammo, health, treasure, weapon, key } +/// Immutable snapshot of player state needed to evaluate pickup rules. +class CollectiblePickupContext { + final int health; + final int ammo; + final bool hasGoldKey; + final bool hasSilverKey; + final bool hasMachineGun; + final bool hasChainGun; + final WeaponType currentWeaponType; -class Collectible extends Entity { - final CollectibleType type; + const CollectiblePickupContext({ + required this.health, + required this.ammo, + required this.hasGoldKey, + required this.hasSilverKey, + required this.hasMachineGun, + required this.hasChainGun, + required this.currentWeaponType, + }); +} - Collectible({ +/// Generic result returned when a collectible is consumed. +class CollectiblePickupEffect { + final int healthToRestore; + final int ammoToAdd; + final int scoreToAdd; + final int pickupSfxId; + final bool grantGoldKey; + final bool grantSilverKey; + final WeaponType? grantWeapon; + final WeaponType? requestWeaponSwitch; + + const CollectiblePickupEffect({ + this.healthToRestore = 0, + this.ammoToAdd = 0, + this.scoreToAdd = 0, + required this.pickupSfxId, + this.grantGoldKey = false, + this.grantSilverKey = false, + this.grantWeapon, + this.requestWeaponSwitch, + }); +} + +abstract class Collectible extends Entity { + Collectible._({ required super.x, required super.y, required super.spriteIndex, required super.mapId, - required this.type, }) : super(state: EntityState.staticObj); + /// Additional score granted when this item is collected. + int get scoreValue => 0; + + /// Returns `null` when pickup should be ignored (full health, full ammo, etc). + CollectiblePickupEffect? tryCollect(CollectiblePickupContext context); + + static int _spriteIndexFor(int objId) => objId - 21; + // Define which Map IDs are actually items you can pick up static bool isCollectible(int objId) { return (objId >= 43 && objId <= 44) || // Keys (objId >= 47 && objId <= 56); // Health, Ammo, Weapons, Treasure, 1-Up } - static CollectibleType _getType(int objId) { - if (objId == 43 || objId == 44) return CollectibleType.key; - if (objId == 47 || objId == 48) return CollectibleType.health; - if (objId == 49) return CollectibleType.ammo; - if (objId == 50 || objId == 51) return CollectibleType.weapon; - return CollectibleType.treasure; // 52-56 - } - static Collectible? trySpawn( int objId, double x, @@ -35,15 +75,171 @@ class Collectible extends Entity { Difficulty difficulty, { bool isSharewareMode = false, }) { - if (isCollectible(objId)) { - return Collectible( + return switch (objId) { + MapObject.goldKey || MapObject.silverKey => KeyCollectible( x: x, y: y, - spriteIndex: objId - 21, // Same VSWAP math as decorations! mapId: objId, - type: _getType(objId), + ), + MapObject.food || MapObject.medkit => HealthCollectible( + x: x, + y: y, + mapId: objId, + ), + MapObject.ammoClip => AmmoCollectible(x: x, y: y), + MapObject.machineGun || MapObject.chainGun => WeaponCollectible( + x: x, + y: y, + mapId: objId, + ), + MapObject.cross || + MapObject.chalice || + MapObject.chest || + MapObject.crown || + MapObject.extraLife => TreasureCollectible(x: x, y: y, mapId: objId), + _ => null, + }; + } +} + +class HealthCollectible extends Collectible { + HealthCollectible({ + required super.x, + required super.y, + required super.mapId, + }) : super._(spriteIndex: Collectible._spriteIndexFor(mapId)); + + @override + CollectiblePickupEffect? tryCollect(CollectiblePickupContext context) { + if (context.health >= 100) return null; + + final bool isSmallHealth = mapId == MapObject.food; + return CollectiblePickupEffect( + healthToRestore: isSmallHealth ? 4 : 25, + pickupSfxId: isSmallHealth + ? WolfSound.healthSmall + : WolfSound.healthLarge, + ); + } +} + +class AmmoCollectible extends Collectible { + AmmoCollectible({required super.x, required super.y}) + : super._( + mapId: MapObject.ammoClip, + spriteIndex: Collectible._spriteIndexFor(MapObject.ammoClip), + ); + + @override + CollectiblePickupEffect? tryCollect(CollectiblePickupContext context) { + if (context.ammo >= 99) return null; + + final bool shouldAutoswitchToPistol = + context.currentWeaponType == WeaponType.knife && context.ammo <= 0; + + return CollectiblePickupEffect( + ammoToAdd: 8, + pickupSfxId: WolfSound.getAmmo, + requestWeaponSwitch: shouldAutoswitchToPistol ? WeaponType.pistol : null, + ); + } +} + +class WeaponCollectible extends Collectible { + WeaponCollectible({ + required super.x, + required super.y, + required super.mapId, + }) : super._(spriteIndex: Collectible._spriteIndexFor(mapId)); + + @override + CollectiblePickupEffect? tryCollect(CollectiblePickupContext context) { + if (mapId == MapObject.machineGun) { + return const CollectiblePickupEffect( + ammoToAdd: 8, + pickupSfxId: WolfSound.getMachineGun, + grantWeapon: WeaponType.machineGun, + requestWeaponSwitch: WeaponType.machineGun, ); } + + if (mapId == MapObject.chainGun) { + return const CollectiblePickupEffect( + ammoToAdd: 8, + pickupSfxId: WolfSound.getGatling, + grantWeapon: WeaponType.chainGun, + requestWeaponSwitch: WeaponType.chainGun, + ); + } + return null; } } + +class KeyCollectible extends Collectible { + KeyCollectible({ + required super.x, + required super.y, + required super.mapId, + }) : super._(spriteIndex: Collectible._spriteIndexFor(mapId)); + + @override + CollectiblePickupEffect? tryCollect(CollectiblePickupContext context) { + if (mapId == MapObject.goldKey) { + if (context.hasGoldKey) return null; + return const CollectiblePickupEffect( + pickupSfxId: WolfSound.getAmmo, + grantGoldKey: true, + ); + } + + if (mapId == MapObject.silverKey) { + if (context.hasSilverKey) return null; + return const CollectiblePickupEffect( + pickupSfxId: WolfSound.getAmmo, + grantSilverKey: true, + ); + } + + return null; + } +} + +class TreasureCollectible extends Collectible { + TreasureCollectible({ + required super.x, + required super.y, + required super.mapId, + }) : super._(spriteIndex: Collectible._spriteIndexFor(mapId)); + + @override + int get scoreValue => switch (mapId) { + MapObject.cross => 100, + MapObject.chalice => 500, + MapObject.chest => 1000, + MapObject.crown => 5000, + _ => 0, + }; + + @override + CollectiblePickupEffect? tryCollect(CollectiblePickupContext context) { + if (mapId == MapObject.extraLife) { + return const CollectiblePickupEffect( + healthToRestore: 100, + ammoToAdd: 25, + pickupSfxId: WolfSound.extraLife, + ); + } + + return CollectiblePickupEffect( + scoreToAdd: scoreValue, + pickupSfxId: switch (mapId) { + MapObject.cross => WolfSound.treasure1, + MapObject.chalice => WolfSound.treasure2, + MapObject.chest => WolfSound.treasure3, + MapObject.crown => WolfSound.treasure4, + _ => WolfSound.getAmmo, + }, + ); + } +} 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 6774a91..7e4546d 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 @@ -54,6 +54,9 @@ abstract class Enemy extends Entity { /// The sound played when this enemy performs its attack animation. int get attackSoundId => type.attackSoundId; + /// The score awarded when this enemy is killed. + int get scoreValue => type.scoreValue; + /// The sound played once when this enemy starts dying. int get deathSoundId => type.deathSoundId; 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 dcb2569..4cec714 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 @@ -10,6 +10,7 @@ enum EnemyType { /// Standard Brown Guard (The most common enemy). guard( mapData: EnemyMapData(MapObject.guardStart), + scoreValue: 100, alertSoundId: WolfSound.guardHalt, attackSoundId: WolfSound.naziFire, deathSoundId: WolfSound.deathScream1, @@ -26,6 +27,7 @@ enum EnemyType { /// Attack Dog (Fast melee enemy). dog( mapData: EnemyMapData(MapObject.dogStart), + scoreValue: 200, alertSoundId: WolfSound.dogBark, attackSoundId: WolfSound.dogAttack, deathSoundId: WolfSound.dogDeath, @@ -48,6 +50,7 @@ enum EnemyType { /// SS Officer (Blue uniform, machine gun). ss( mapData: EnemyMapData(MapObject.ssStart), + scoreValue: 100, alertSoundId: WolfSound.ssSchutzstaffel, attackSoundId: WolfSound.naziFire, deathSoundId: WolfSound.ssMeinGott, @@ -64,6 +67,7 @@ enum EnemyType { /// Undead Mutant (Exclusive to later episodes/retail). mutant( mapData: EnemyMapData(MapObject.mutantStart, tierOffset: 18), + scoreValue: 100, alertSoundId: WolfSound.guardHalt, attackSoundId: WolfSound.naziFire, deathSoundId: WolfSound.deathScream2, @@ -81,6 +85,7 @@ enum EnemyType { /// High-ranking Officer (White uniform, fast pistol). officer( mapData: EnemyMapData(MapObject.officerStart), + scoreValue: 100, alertSoundId: WolfSound.guardHalt, attackSoundId: WolfSound.naziFire, deathSoundId: WolfSound.deathScream3, @@ -102,6 +107,9 @@ enum EnemyType { /// The animation ranges for this enemy type. final EnemyAnimationMap animations; + /// Score granted when this enemy is killed. + final int scoreValue; + /// The sound played when this enemy first becomes alerted. final int alertSoundId; @@ -117,6 +125,7 @@ enum EnemyType { const EnemyType({ required this.mapData, required this.animations, + required this.scoreValue, required this.alertSoundId, required this.attackSoundId, required this.deathSoundId, diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/shareware_asset_registry.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/shareware_asset_registry.dart index 66fefc7..5f90b43 100644 --- a/packages/wolf_3d_dart/lib/src/registry/built_in/shareware_asset_registry.dart +++ b/packages/wolf_3d_dart/lib/src/registry/built_in/shareware_asset_registry.dart @@ -15,13 +15,15 @@ import 'package:wolf_3d_dart/src/registry/built_in/shareware_music_module.dart'; /// [SharewareMenuPicModule.initWithImageSizes] after the VGA images are /// available so the shift is computed correctly. class SharewareAssetRegistry extends AssetRegistry { - SharewareAssetRegistry() + SharewareAssetRegistry({bool strictOriginalShareware = false}) : super( sfx: const RetailSfxModule(), music: const SharewareMusicModule(), entities: const SharewareEntityModule(), hud: const RetailHudModule(), - menu: SharewareMenuPicModule(), + menu: SharewareMenuPicModule( + useOriginalWl1Map: strictOriginalShareware, + ), ); /// Convenience accessor to the menu module for post-load initialisation. diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/shareware_menu_module.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/shareware_menu_module.dart index 30ebe87..81b0cf9 100644 --- a/packages/wolf_3d_dart/lib/src/registry/built_in/shareware_menu_module.dart +++ b/packages/wolf_3d_dart/lib/src/registry/built_in/shareware_menu_module.dart @@ -15,7 +15,12 @@ import 'package:wolf_3d_dart/src/registry/modules/menu_pic_module.dart'; /// module falls back to the retail indices (compatible with unrecognised /// or future shareware variants). class SharewareMenuPicModule extends MenuPicModule { - SharewareMenuPicModule(); + SharewareMenuPicModule({this.useOriginalWl1Map = false}); + + /// When true, use the canonical original WL1 index table directly. + /// + /// Unknown/custom shareware-like data should use the heuristic path instead. + final bool useOriginalWl1Map; // Retail-baseline indices (same layout as RetailMenuPicModule). static final Map _retailBaseline = { @@ -42,6 +47,27 @@ class SharewareMenuPicModule extends MenuPicModule { MenuPicKey.difficultyHard: 19, }; + // Canonical menu picture indices for the original v1.4 shareware data + // identity (DataVersion.version14Shareware). + static final Map _originalWl1Map = { + MenuPicKey.title: 96, + MenuPicKey.credits: 98, + MenuPicKey.pg13: 97, + MenuPicKey.controlBackground: 35, + MenuPicKey.footer: 27, + MenuPicKey.heading: 14, + MenuPicKey.optionsLabel: 19, + MenuPicKey.cursorActive: 20, + MenuPicKey.cursorInactive: 21, + MenuPicKey.markerSelected: 23, + MenuPicKey.markerUnselected: 22, + MenuPicKey.episode1: 39, + MenuPicKey.difficultyBaby: 28, + MenuPicKey.difficultyEasy: 29, + MenuPicKey.difficultyNormal: 30, + MenuPicKey.difficultyHard: 31, + }; + // Landmark constant: STATUSBARPIC in retail picture-table coords. static const int _retailStatusBarIndex = 83; @@ -79,9 +105,19 @@ class SharewareMenuPicModule extends MenuPicModule { @override MenuPicRef? resolve(MenuPicKey key) { + if (useOriginalWl1Map) { + final wl1 = _originalWl1Map[key]; + return wl1 == null ? null : MenuPicRef(wl1); + } + final base = _retailBaseline[key]; if (base == null) return null; - final shifted = base + (_offset ?? 0); + + // The shareware/release drift is observed around STATUSBAR and later + // full-screen art. Earlier control-panel assets keep retail indices. + final shifted = base >= _retailStatusBarIndex + ? base + (_offset ?? 0) + : base; return MenuPicRef(shifted); } @@ -95,8 +131,9 @@ class SharewareMenuPicModule extends MenuPicModule { MenuPicKey.episode5, MenuPicKey.episode6, ]; - if (episodeIndex >= 0 && episodeIndex < keys.length) + if (episodeIndex >= 0 && episodeIndex < keys.length) { return keys[episodeIndex]; + } return MenuPicKey.episode1; } diff --git a/packages/wolf_3d_dart/lib/src/registry/registry_resolver.dart b/packages/wolf_3d_dart/lib/src/registry/registry_resolver.dart index e444b49..8c9a158 100644 --- a/packages/wolf_3d_dart/lib/src/registry/registry_resolver.dart +++ b/packages/wolf_3d_dart/lib/src/registry/registry_resolver.dart @@ -47,7 +47,7 @@ class BuiltInAssetRegistryResolver implements AssetRegistryResolver { case DataVersion.version14Retail: return RetailAssetRegistry(); case DataVersion.version14Shareware: - return SharewareAssetRegistry(); + return SharewareAssetRegistry(strictOriginalShareware: true); case DataVersion.unknown: break; // fall through to GameVersion family check } diff --git a/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart b/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart index 052716d..6fbc6d5 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart @@ -370,7 +370,7 @@ class AsciiRenderer extends CliRendererBackend { final art = WolfClassicMenuArt(engine.data); if (engine.menuManager.activeMenu == WolfMenuScreen.gameSelect) { - final cursor = art.pic( + final cursor = art.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); const int rowYStart = 84; @@ -433,7 +433,7 @@ class AsciiRenderer extends CliRendererBackend { if (engine.menuManager.activeMenu == WolfMenuScreen.episodeSelect) { _fillRect320(12, 18, 296, 168, panelColor); - final cursor = art.pic( + final cursor = art.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); const int rowYStart = 24; @@ -534,7 +534,7 @@ class AsciiRenderer extends CliRendererBackend { _blitVgaImageAscii(face, 28 + 264 - face.width - 18, 92); } - final cursor = art.pic( + final cursor = art.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); const rowYStart = 86; diff --git a/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart b/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart index 91fa477..213e056 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart @@ -356,7 +356,7 @@ class SixelRenderer extends CliRendererBackend { final art = WolfClassicMenuArt(engine.data); if (engine.menuManager.activeMenu == WolfMenuScreen.gameSelect) { _drawMenuTextCentered('SELECT GAME', 48, headingIndex, scale: 2); - final cursor = art.pic( + final cursor = art.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); const int rowYStart = 84; @@ -387,7 +387,7 @@ class SixelRenderer extends CliRendererBackend { headingIndex, scale: 2, ); - final cursor = art.pic( + final cursor = art.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); const int rowYStart = 30; @@ -442,7 +442,7 @@ class SixelRenderer extends CliRendererBackend { scale: _menuHeadingScale, ); - final bottom = art.pic(15); + final bottom = art.mappedPic(15); if (bottom != null) { _blitVgaImage(bottom, (320 - bottom.width) ~/ 2, 200 - bottom.height - 8); } @@ -454,7 +454,7 @@ class SixelRenderer extends CliRendererBackend { _blitVgaImage(face, 28 + 264 - face.width - 10, 92); } - final cursor = art.pic( + final cursor = art.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); const rowYStart = 86; @@ -482,7 +482,7 @@ class SixelRenderer extends CliRendererBackend { } void _drawMenuFooterArt(WolfClassicMenuArt art) { - final bottom = art.pic(15); + final bottom = art.mappedPic(15); if (bottom == null) { return; } diff --git a/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart b/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart index a8de772..d332993 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart @@ -194,7 +194,7 @@ class SoftwareRenderer extends RendererBackend { _drawMenuTextCentered('SELECT GAME', 38, headingColor, scale: 2); - final cursor = art.pic( + final cursor = art.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); @@ -234,7 +234,7 @@ class SoftwareRenderer extends RendererBackend { _drawMenuTextCentered('WHICH EPISODE TO PLAY?', 6, headingColor, scale: 2); - final cursor = art.pic( + final cursor = art.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); const int rowYStart = 30; @@ -277,7 +277,7 @@ class SoftwareRenderer extends RendererBackend { } void _drawCenteredMenuFooter(WolfClassicMenuArt art) { - final bottom = art.pic(15); + final bottom = art.mappedPic(15); if (bottom != null) { final int x = ((width - bottom.width) ~/ 2).clamp(0, width - 1); final int y = (height - bottom.height - 8).clamp(0, height - 1); @@ -339,7 +339,7 @@ class SoftwareRenderer extends RendererBackend { _drawMenuTextCentered(Difficulty.menuText, 48, headingColor, scale: 2); - final bottom = art.pic(15); + final bottom = art.mappedPic(15); if (bottom != null) { final x = (width - bottom.width) ~/ 2; final y = height - bottom.height - 8; @@ -353,7 +353,7 @@ class SoftwareRenderer extends RendererBackend { _blitVgaImage(face, panelX + panelW - face.width - 10, panelY + 22); } - final cursor = art.pic( + final cursor = art.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); const int rowYStart = panelY + 16; diff --git a/packages/wolf_3d_dart/lib/wolf_3d_menu.dart b/packages/wolf_3d_dart/lib/wolf_3d_menu.dart index e408080..db4480c 100644 --- a/packages/wolf_3d_dart/lib/wolf_3d_menu.dart +++ b/packages/wolf_3d_dart/lib/wolf_3d_menu.dart @@ -98,103 +98,45 @@ class WolfClassicMenuArt { WolfClassicMenuArt(this.data); - int? _resolvedIndexOffset; - VgaImage? get controlBackground { - final preferred = mappedPic(WolfMenuPic.cControl); - if (_looksLikeMenuBackdrop(preferred)) { - return preferred; - } - - // Older data layouts may shift/control-panel art around nearby indices. - for (int delta = -4; delta <= 4; delta++) { - final candidate = mappedPic(WolfMenuPic.cControl + delta); - if (_looksLikeMenuBackdrop(candidate)) { - return candidate; - } - } - - return preferred; + return _imageForKey(MenuPicKey.controlBackground); } - VgaImage? get title => mappedPic(WolfMenuPic.title); + VgaImage? get title => _imageForKey(MenuPicKey.title); - VgaImage? get heading => mappedPic(WolfMenuPic.hTopWindow); + VgaImage? get heading => _imageForKey(MenuPicKey.heading); - VgaImage? get selectedMarker => mappedPic(WolfMenuPic.cSelected); + VgaImage? get selectedMarker => _imageForKey(MenuPicKey.markerSelected); - VgaImage? get unselectedMarker => mappedPic(WolfMenuPic.cNotSelected); + VgaImage? get unselectedMarker => _imageForKey(MenuPicKey.markerUnselected); - VgaImage? get optionsLabel => mappedPic(WolfMenuPic.cOptions); + VgaImage? get optionsLabel => _imageForKey(MenuPicKey.optionsLabel); - VgaImage? get credits => mappedPic(WolfMenuPic.credits); + VgaImage? get credits => _imageForKey(MenuPicKey.credits); VgaImage? episodeOption(int episodeIndex) { - if (episodeIndex < 0 || episodeIndex >= WolfMenuPic.episodePics.length) { + if (episodeIndex < 0) { return null; } - return mappedPic(WolfMenuPic.episodePics[episodeIndex]); + final key = data.registry.menu.episodeKey(episodeIndex); + return _imageForKey(key); } VgaImage? difficultyOption(Difficulty difficulty) { - switch (difficulty) { - case Difficulty.baby: - return mappedPic(WolfMenuPic.cBabyMode); - case Difficulty.easy: - return mappedPic(WolfMenuPic.cEasy); - case Difficulty.medium: - return mappedPic(WolfMenuPic.cNormal); - case Difficulty.hard: - return mappedPic(WolfMenuPic.cHard); - } + final key = data.registry.menu.difficultyKey(difficulty); + return _imageForKey(key); } - /// Returns [index] after applying a detected version/layout offset. + /// Legacy numeric lookup API retained for existing renderer call sites. + /// + /// Known legacy indices are mapped through symbolic registry keys first. + /// Unknown indices fall back to direct picture-table indexing. VgaImage? mappedPic(int index) { - return pic(index + _indexOffset); - } - - int get _indexOffset { - if (_resolvedIndexOffset != null) { - return _resolvedIndexOffset!; + final key = _legacyKeyForIndex(index); + if (key != null) { + return _imageForKey(key); } - - // Retail and shareware generally place STATUSBAR/TITLE/PG13/CREDITS as a - // contiguous block. If files are from a different release, infer a shift. - for (int i = 0; i < data.vgaImages.length - 3; i++) { - final status = data.vgaImages[i]; - if (!_looksLikeStatusBar(status)) { - continue; - } - - final title = data.vgaImages[i + 1]; - final pg13 = data.vgaImages[i + 2]; - final credits = data.vgaImages[i + 3]; - if (_looksLikeFullScreen(title) && - _looksLikeFullScreen(pg13) && - _looksLikeFullScreen(credits)) { - _resolvedIndexOffset = i - WolfMenuPic.statusBar; - return _resolvedIndexOffset!; - } - } - - _resolvedIndexOffset = 0; - return 0; - } - - bool _looksLikeStatusBar(VgaImage image) { - return image.width >= 280 && image.height >= 24 && image.height <= 64; - } - - bool _looksLikeFullScreen(VgaImage image) { - return image.width >= 280 && image.height >= 140; - } - - bool _looksLikeMenuBackdrop(VgaImage? image) { - if (image == null) { - return false; - } - return image.width >= 180 && image.height >= 100; + return pic(index); } VgaImage? pic(int index) { @@ -203,14 +145,67 @@ class WolfClassicMenuArt { } final image = data.vgaImages[index]; - // Ignore known gameplay HUD art in menu composition. - if (index == WolfMenuPic.statusBar + _indexOffset) { - return null; - } if (image.width <= 0 || image.height <= 0) { return null; } return image; } + + VgaImage? _imageForKey(MenuPicKey key) { + final ref = data.registry.menu.resolve(key); + if (ref == null) { + return null; + } + return pic(ref.pictureIndex); + } + + MenuPicKey? _legacyKeyForIndex(int index) { + switch (index) { + case WolfMenuPic.hTopWindow: + return MenuPicKey.heading; + case WolfMenuPic.cOptions: + return MenuPicKey.optionsLabel; + case WolfMenuPic.cCursor1: + return MenuPicKey.cursorActive; + case WolfMenuPic.cCursor2: + return MenuPicKey.cursorInactive; + case WolfMenuPic.cNotSelected: + return MenuPicKey.markerUnselected; + case WolfMenuPic.cSelected: + return MenuPicKey.markerSelected; + case 15: + return MenuPicKey.footer; + case WolfMenuPic.cBabyMode: + return MenuPicKey.difficultyBaby; + case WolfMenuPic.cEasy: + return MenuPicKey.difficultyEasy; + case WolfMenuPic.cNormal: + return MenuPicKey.difficultyNormal; + case WolfMenuPic.cHard: + return MenuPicKey.difficultyHard; + case WolfMenuPic.cControl: + return MenuPicKey.controlBackground; + case WolfMenuPic.cEpisode1: + return MenuPicKey.episode1; + case WolfMenuPic.cEpisode2: + return MenuPicKey.episode2; + case WolfMenuPic.cEpisode3: + return MenuPicKey.episode3; + case WolfMenuPic.cEpisode4: + return MenuPicKey.episode4; + case WolfMenuPic.cEpisode5: + return MenuPicKey.episode5; + case WolfMenuPic.cEpisode6: + return MenuPicKey.episode6; + case WolfMenuPic.title: + return MenuPicKey.title; + case WolfMenuPic.pg13: + return MenuPicKey.pg13; + case WolfMenuPic.credits: + return MenuPicKey.credits; + default: + return null; + } + } } diff --git a/packages/wolf_3d_dart/test/engine/audio_events_test.dart b/packages/wolf_3d_dart/test/engine/audio_events_test.dart index 0cbf73a..6213ea6 100644 --- a/packages/wolf_3d_dart/test/engine/audio_events_test.dart +++ b/packages/wolf_3d_dart/test/engine/audio_events_test.dart @@ -21,13 +21,7 @@ void main() { engine.init(); engine.entities.add( - Collectible( - x: engine.player.x, - y: engine.player.y, - spriteIndex: 0, - mapId: MapObject.ammoClip, - type: CollectibleType.ammo, - ), + AmmoCollectible(x: engine.player.x, y: engine.player.y), ); engine.tick(const Duration(milliseconds: 16)); diff --git a/packages/wolf_3d_dart/test/entities/scoring_ownership_test.dart b/packages/wolf_3d_dart/test/entities/scoring_ownership_test.dart new file mode 100644 index 0000000..f26ac15 --- /dev/null +++ b/packages/wolf_3d_dart/test/entities/scoring_ownership_test.dart @@ -0,0 +1,39 @@ +import 'package:test/test.dart'; +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; +import 'package:wolf_3d_dart/wolf_3d_entities.dart'; + +void main() { + group('Scoring ownership', () { + test('treasure collectibles expose score values', () { + final cross = TreasureCollectible(x: 1, y: 1, mapId: MapObject.cross); + final chalice = TreasureCollectible( + x: 1, + y: 1, + mapId: MapObject.chalice, + ); + final chest = TreasureCollectible(x: 1, y: 1, mapId: MapObject.chest); + final crown = TreasureCollectible(x: 1, y: 1, mapId: MapObject.crown); + final extraLife = TreasureCollectible( + x: 1, + y: 1, + mapId: MapObject.extraLife, + ); + + expect(cross.scoreValue, 100); + expect(chalice.scoreValue, 500); + expect(chest.scoreValue, 1000); + expect(crown.scoreValue, 5000); + expect(extraLife.scoreValue, 0); + }); + + 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 ss = SS(x: 1, y: 1, angle: 0, mapId: MapObject.ssStart); + + expect(guard.scoreValue, 100); + expect(dog.scoreValue, 200); + expect(ss.scoreValue, 100); + }); + }); +} diff --git a/packages/wolf_3d_dart/test/registry/shareware_menu_module_test.dart b/packages/wolf_3d_dart/test/registry/shareware_menu_module_test.dart new file mode 100644 index 0000000..8481fd2 --- /dev/null +++ b/packages/wolf_3d_dart/test/registry/shareware_menu_module_test.dart @@ -0,0 +1,88 @@ +import 'package:test/test.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/shareware_menu_module.dart'; +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; + +void main() { + group('SharewareMenuPicModule', () { + test('uses fixed canonical WL1 mapping when strict mode is enabled', () { + final module = SharewareMenuPicModule(useOriginalWl1Map: true); + + expect(module.resolve(MenuPicKey.title)?.pictureIndex, 96); + expect(module.resolve(MenuPicKey.pg13)?.pictureIndex, 97); + expect(module.resolve(MenuPicKey.credits)?.pictureIndex, 98); + expect(module.resolve(MenuPicKey.cursorActive)?.pictureIndex, 20); + expect(module.resolve(MenuPicKey.footer)?.pictureIndex, 27); + expect(module.resolve(MenuPicKey.difficultyEasy)?.pictureIndex, 29); + expect(module.resolve(MenuPicKey.episode1)?.pictureIndex, 39); + }); + + test('returns null for unavailable WL1 episode art in strict mode', () { + final module = SharewareMenuPicModule(useOriginalWl1Map: true); + + expect(module.resolve(MenuPicKey.episode2), isNull); + expect(module.resolve(MenuPicKey.episode3), isNull); + expect(module.resolve(MenuPicKey.episode4), isNull); + expect(module.resolve(MenuPicKey.episode5), isNull); + expect(module.resolve(MenuPicKey.episode6), isNull); + }); + + test('heuristic mode only shifts statusbar-era indices', () { + final module = SharewareMenuPicModule(); + final imageSizes = List.generate( + 90, + (_) => (width: 64, height: 64), + ); + + imageSizes[35] = (width: 320, height: 40); // STATUSBARPIC-like + imageSizes[36] = (width: 320, height: 200); // TITLEPIC-like + imageSizes[37] = (width: 320, height: 200); // PG13PIC-like + imageSizes[38] = (width: 320, height: 200); // CREDITSPIC-like + + module.initWithImageSizes(imageSizes); + + // 84 shifted by -48. + expect(module.resolve(MenuPicKey.title)?.pictureIndex, 36); + // Control-panel assets should remain unshifted. + expect(module.resolve(MenuPicKey.difficultyEasy)?.pictureIndex, 17); + expect(module.resolve(MenuPicKey.episode1)?.pictureIndex, 27); + }); + }); + + group('BuiltInAssetRegistryResolver shareware selection', () { + test( + 'uses strict canonical WL1 map for exact known shareware identity', + () { + const resolver = BuiltInAssetRegistryResolver(); + + final registry = resolver.resolve( + const RegistrySelectionContext( + gameVersion: GameVersion.shareware, + dataVersion: DataVersion.version14Shareware, + ), + ); + + expect(registry.menu.resolve(MenuPicKey.title)?.pictureIndex, 96); + expect( + registry.menu.resolve(MenuPicKey.cursorActive)?.pictureIndex, + 20, + ); + expect(registry.menu.resolve(MenuPicKey.episode2), isNull); + }, + ); + + test('uses flexible shareware fallback for unknown identities', () { + const resolver = BuiltInAssetRegistryResolver(); + + final registry = resolver.resolve( + const RegistrySelectionContext( + gameVersion: GameVersion.shareware, + dataVersion: DataVersion.unknown, + ), + ); + + // Unknown shareware falls back to baseline until image-size heuristic init. + expect(registry.menu.resolve(MenuPicKey.title)?.pictureIndex, 84); + expect(registry.menu.resolve(MenuPicKey.episode2)?.pictureIndex, 28); + }); + }); +} diff --git a/packages/wolf_3d_dart/test/registry/wolf_classic_menu_art_mapping_test.dart b/packages/wolf_3d_dart/test/registry/wolf_classic_menu_art_mapping_test.dart new file mode 100644 index 0000000..09fcb29 --- /dev/null +++ b/packages/wolf_3d_dart/test/registry/wolf_classic_menu_art_mapping_test.dart @@ -0,0 +1,43 @@ +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_menu.dart'; + +void main() { + test( + 'legacy numeric menu IDs are remapped through registry in shareware', + () { + final data = WolfensteinData( + version: GameVersion.shareware, + dataVersion: DataVersion.version14Shareware, + registry: SharewareAssetRegistry(strictOriginalShareware: true), + walls: const [], + sprites: const [], + sounds: const [], + adLibSounds: const [], + music: const [], + episodes: const [], + vgaImages: List.generate(120, (i) => _img(height: i + 1)), + ); + + final art = WolfClassicMenuArt(data); + + // Retail cursor constants 8/9 must resolve to shareware cursor IDs 20/21. + expect(art.mappedPic(8)?.height, 21); + expect(art.mappedPic(9)?.height, 22); + + // Retail footer constant 15 must resolve to shareware footer ID 27. + expect(art.mappedPic(15)?.height, 28); + }, + ); +} + +VgaImage _img({required int height}) { + const width = 4; + return VgaImage( + width: width, + height: height, + pixels: Uint8List(width * height), + ); +}