Add shareware HUD module and integrate with asset registry for improved HUD handling

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-19 14:29:12 +01:00
parent 57dde0f31c
commit 8bf0dbd57c
5 changed files with 180 additions and 21 deletions

View File

@@ -228,6 +228,7 @@ abstract class WLParser {
.map((img) => (width: img.width, height: img.height))
.toList();
registry.sharewareMenu.initWithImageSizes(sizes);
registry.sharewareHud.initWithImageSizes(sizes);
}
return registry;

View File

@@ -1,7 +1,7 @@
import 'package:wolf_3d_dart/src/registry/asset_registry.dart';
import 'package:wolf_3d_dart/src/registry/built_in/retail_hud_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/retail_sfx_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/shareware_entity_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/shareware_hud_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/shareware_menu_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/shareware_music_module.dart';
@@ -10,7 +10,7 @@ import 'package:wolf_3d_dart/src/registry/built_in/shareware_music_module.dart';
/// - SFX slots are identical to retail (same AUDIOT layout).
/// - Music routing uses the 10-level shareware table.
/// - Entity definitions are limited to the three shareware enemies.
/// - HUD indices are identical to retail (same relative VGA layout).
/// - HUD indices are shareware-aware and offset from retail layout.
/// - Menu picture indices are resolved via runtime heuristic offset; call
/// [SharewareMenuPicModule.initWithImageSizes] after the VGA images are
/// available so the shift is computed correctly.
@@ -20,7 +20,9 @@ class SharewareAssetRegistry extends AssetRegistry {
sfx: const RetailSfxModule(),
music: const SharewareMusicModule(),
entities: const SharewareEntityModule(),
hud: const RetailHudModule(),
hud: SharewareHudModule(
useOriginalWl1Map: strictOriginalShareware,
),
menu: SharewareMenuPicModule(
useOriginalWl1Map: strictOriginalShareware,
),
@@ -28,4 +30,7 @@ class SharewareAssetRegistry extends AssetRegistry {
/// Convenience accessor to the menu module for post-load initialisation.
SharewareMenuPicModule get sharewareMenu => menu as SharewareMenuPicModule;
/// Convenience accessor to the HUD module for post-load initialisation.
SharewareHudModule get sharewareHud => hud as SharewareHudModule;
}

View File

@@ -0,0 +1,83 @@
import 'package:wolf_3d_dart/src/registry/keys/hud_key.dart';
import 'package:wolf_3d_dart/src/registry/modules/hud_module.dart';
/// Built-in HUD module for Wolf3D shareware data variants.
///
/// For known original WL1 data, the HUD picture table is shifted by +12 from
/// retail indices. For unknown shareware-like data, the offset is detected from
/// the STATUSBARPIC landmark at runtime.
class SharewareHudModule extends HudModule {
SharewareHudModule({this.useOriginalWl1Map = false});
final bool useOriginalWl1Map;
static const int _originalWl1Offset = 12;
static const int _retailStatusBarIndex = 83;
static const List<HudKey> _faceBands = [
HudKey.faceHealthy,
HudKey.faceScratched,
HudKey.faceHurt,
HudKey.faceWounded,
HudKey.faceBadlyWounded,
HudKey.faceDying,
HudKey.faceNearDeath,
];
static final Map<HudKey, int> _retailBaseline = {
HudKey.statusBar: 83,
HudKey.digit0: 96,
HudKey.digit1: 97,
HudKey.digit2: 98,
HudKey.digit3: 99,
HudKey.digit4: 100,
HudKey.digit5: 101,
HudKey.digit6: 102,
HudKey.digit7: 103,
HudKey.digit8: 104,
HudKey.digit9: 105,
HudKey.faceHealthy: 106,
HudKey.faceScratched: 109,
HudKey.faceHurt: 112,
HudKey.faceWounded: 115,
HudKey.faceBadlyWounded: 118,
HudKey.faceDying: 121,
HudKey.faceNearDeath: 124,
HudKey.faceDead: 127,
HudKey.pistolIcon: 89,
HudKey.machineGunIcon: 90,
HudKey.chainGunIcon: 91,
};
int? _offset;
void initWithImageSizes(List<({int width, int height})> imageSizes) {
_offset = _computeOffset(imageSizes);
}
int _computeOffset(List<({int width, int height})> sizes) {
for (int i = 0; i < sizes.length; i++) {
final s = sizes[i];
if (s.width >= 280 && s.height >= 24 && s.height <= 64) {
return i - _retailStatusBarIndex;
}
}
return 0;
}
int get _effectiveOffset => useOriginalWl1Map ? _originalWl1Offset : (_offset ?? 0);
@override
HudAssetRef? resolve(HudKey key) {
final base = _retailBaseline[key];
if (base == null) return null;
return HudAssetRef(base + _effectiveOffset);
}
@override
HudKey faceKeyForHealth(int health) {
if (health <= 0) return HudKey.faceDead;
final band = ((100 - health) ~/ 16).clamp(0, _faceBands.length - 1);
return _faceBands[band];
}
}

View File

@@ -151,36 +151,49 @@ abstract class RendererBackend<T>
/// backend can scale/map them consistently via [blitHudVgaImage].
void drawStandardVgaHud(WolfEngine engine) {
final List<VgaImage> vgaImages = engine.data.vgaImages;
final int statusBarIndex = vgaImages.indexWhere(
(img) => img.width == 320 && img.height == 40,
);
if (statusBarIndex == -1) return;
final statusBarRef = engine.data.registry.hud.resolve(HudKey.statusBar);
final int statusBarIndex = statusBarRef?.vgaIndex ?? -1;
if (statusBarIndex >= 0 && statusBarIndex < vgaImages.length) {
blitHudVgaImage(vgaImages[statusBarIndex], 0, 160);
}
blitHudVgaImage(vgaImages[statusBarIndex], 0, 160);
_drawHudNumber(vgaImages, 1, 32, 176);
_drawHudNumber(vgaImages, engine.player.score, 96, 176);
_drawHudNumber(vgaImages, 3, 120, 176);
_drawHudNumber(vgaImages, engine.player.health, 192, 176);
_drawHudNumber(vgaImages, engine.player.ammo, 232, 176);
_drawHudNumber(engine, vgaImages, 1, 32, 176);
_drawHudNumber(engine, vgaImages, engine.player.score, 96, 176);
_drawHudNumber(engine, vgaImages, 3, 120, 176);
_drawHudNumber(engine, vgaImages, engine.player.health, 192, 176);
_drawHudNumber(engine, vgaImages, engine.player.ammo, 232, 176);
_drawHudFace(engine, vgaImages);
_drawHudWeaponIcon(engine, vgaImages);
}
void _drawHudNumber(
WolfEngine engine,
List<VgaImage> vgaImages,
int value,
int rightAlignX,
int startY,
) {
// HUD numbers are rendered with fixed-width VGA glyphs (8 px advance).
const int zeroIndex = 96;
const digitKeys = [
HudKey.digit0,
HudKey.digit1,
HudKey.digit2,
HudKey.digit3,
HudKey.digit4,
HudKey.digit5,
HudKey.digit6,
HudKey.digit7,
HudKey.digit8,
HudKey.digit9,
];
final String numStr = value.toString();
int currentX = rightAlignX - (numStr.length * 8);
for (int i = 0; i < numStr.length; i++) {
final int digit = int.parse(numStr[i]);
final int imageIndex = zeroIndex + digit;
if (imageIndex < vgaImages.length) {
final ref = engine.data.registry.hud.resolve(digitKeys[digit]);
final imageIndex = ref?.vgaIndex ?? -1;
if (imageIndex >= 0 && imageIndex < vgaImages.length) {
blitHudVgaImage(vgaImages[imageIndex], currentX, startY);
}
currentX += 8;
@@ -188,15 +201,24 @@ abstract class RendererBackend<T>
}
void _drawHudFace(WolfEngine engine, List<VgaImage> vgaImages) {
final int faceIndex = hudFaceVgaIndex(engine.player.health);
if (faceIndex < vgaImages.length) {
final faceRef = engine.data.registry.hud.faceForHealth(
engine.player.health,
);
final int faceIndex = faceRef?.vgaIndex ?? -1;
if (faceIndex >= 0 && faceIndex < vgaImages.length) {
blitHudVgaImage(vgaImages[faceIndex], 136, 164);
}
}
void _drawHudWeaponIcon(WolfEngine engine, List<VgaImage> vgaImages) {
final int weaponIndex = hudWeaponVgaIndex(engine);
if (weaponIndex < vgaImages.length) {
final HudKey weaponKey = engine.player.hasChainGun
? HudKey.chainGunIcon
: engine.player.hasMachineGun
? HudKey.machineGunIcon
: HudKey.pistolIcon;
final weaponRef = engine.data.registry.hud.resolve(weaponKey);
final int weaponIndex = weaponRef?.vgaIndex ?? -1;
if (weaponIndex >= 0 && weaponIndex < vgaImages.length) {
blitHudVgaImage(vgaImages[weaponIndex], 256, 164);
}
}

View File

@@ -0,0 +1,48 @@
import 'package:test/test.dart';
import 'package:wolf_3d_dart/src/registry/built_in/shareware_hud_module.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
void main() {
group('SharewareHudModule', () {
test('uses fixed WL1 offset in strict mode', () {
final module = SharewareHudModule(useOriginalWl1Map: true);
expect(module.resolve(HudKey.statusBar)?.vgaIndex, 95);
expect(module.resolve(HudKey.digit0)?.vgaIndex, 108);
expect(module.resolve(HudKey.digit9)?.vgaIndex, 117);
expect(module.resolve(HudKey.faceHealthy)?.vgaIndex, 118);
expect(module.resolve(HudKey.faceDead)?.vgaIndex, 139);
expect(module.resolve(HudKey.pistolIcon)?.vgaIndex, 101);
expect(module.resolve(HudKey.machineGunIcon)?.vgaIndex, 102);
expect(module.resolve(HudKey.chainGunIcon)?.vgaIndex, 103);
});
test('detects shareware HUD offset from statusbar in heuristic mode', () {
final module = SharewareHudModule();
final sizes = List.generate(140, (_) => (width: 16, height: 16));
sizes[95] = (width: 320, height: 40);
module.initWithImageSizes(sizes);
expect(module.resolve(HudKey.statusBar)?.vgaIndex, 95);
expect(module.resolve(HudKey.digit0)?.vgaIndex, 108);
expect(module.resolve(HudKey.faceHealthy)?.vgaIndex, 118);
});
});
group('BuiltInAssetRegistryResolver HUD selection', () {
test('uses shareware HUD mapping for exact WL1 identity', () {
const resolver = BuiltInAssetRegistryResolver();
final registry = resolver.resolve(
const RegistrySelectionContext(
gameVersion: GameVersion.shareware,
dataVersion: DataVersion.version14Shareware,
),
);
expect(registry.hud.resolve(HudKey.statusBar)?.vgaIndex, 95);
expect(registry.hud.resolve(HudKey.digit0)?.vgaIndex, 108);
expect(registry.hud.resolve(HudKey.faceDead)?.vgaIndex, 139);
});
});
}