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 <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-19 14:25:14 +01:00
parent 6e53da7095
commit 57dde0f31c
15 changed files with 584 additions and 223 deletions

View File

@@ -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;
if (effect.healthToRestore > 0) {
heal(effect.healthToRestore);
}
case CollectibleType.ammo:
if (ammo >= 99) return null;
int previousAmmo = ammo;
addAmmo(8);
if (currentWeapon is Knife && previousAmmo <= 0) {
requestWeaponSwitch(WeaponType.pistol);
if (effect.ammoToAdd > 0) {
addAmmo(effect.ammoToAdd);
}
pickupSfxId = WolfSound.getAmmo;
pickedUp = true;
break;
case CollectibleType.treasure:
if (item.mapId == MapObject.cross) {
score += 100;
pickupSfxId = WolfSound.treasure1;
if (effect.scoreToAdd > 0) {
score += effect.scoreToAdd;
}
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;
if (effect.grantGoldKey) {
hasGoldKey = 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.grantSilverKey) {
hasSilverKey = true;
}
return pickedUp ? pickupSfxId : null;
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)",
);
},
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<MenuPicKey, int> _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<MenuPicKey, int> _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;
}

View File

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

View File

@@ -370,7 +370,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
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<dynamic> {
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<dynamic> {
_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;

View File

@@ -356,7 +356,7 @@ class SixelRenderer extends CliRendererBackend<String> {
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<String> {
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<String> {
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<String> {
_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<String> {
}
void _drawMenuFooterArt(WolfClassicMenuArt art) {
final bottom = art.pic(15);
final bottom = art.mappedPic(15);
if (bottom == null) {
return;
}

View File

@@ -194,7 +194,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
_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<FrameBuffer> {
_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<FrameBuffer> {
}
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<FrameBuffer> {
_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<FrameBuffer> {
_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;

View File

@@ -98,103 +98,45 @@ class WolfClassicMenuArt {
WolfClassicMenuArt(this.data);
int? _resolvedIndexOffset;
VgaImage? get controlBackground {
final preferred = mappedPic(WolfMenuPic.cControl);
if (_looksLikeMenuBackdrop(preferred)) {
return preferred;
return _imageForKey(MenuPicKey.controlBackground);
}
// 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;
}
}
VgaImage? get title => _imageForKey(MenuPicKey.title);
return preferred;
}
VgaImage? get heading => _imageForKey(MenuPicKey.heading);
VgaImage? get title => mappedPic(WolfMenuPic.title);
VgaImage? get selectedMarker => _imageForKey(MenuPicKey.markerSelected);
VgaImage? get heading => mappedPic(WolfMenuPic.hTopWindow);
VgaImage? get unselectedMarker => _imageForKey(MenuPicKey.markerUnselected);
VgaImage? get selectedMarker => mappedPic(WolfMenuPic.cSelected);
VgaImage? get optionsLabel => _imageForKey(MenuPicKey.optionsLabel);
VgaImage? get unselectedMarker => mappedPic(WolfMenuPic.cNotSelected);
VgaImage? get optionsLabel => mappedPic(WolfMenuPic.cOptions);
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);
final key = _legacyKeyForIndex(index);
if (key != null) {
return _imageForKey(key);
}
int get _indexOffset {
if (_resolvedIndexOffset != null) {
return _resolvedIndexOffset!;
}
// 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;
}
}
}

View File

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

View File

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

View File

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

View File

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