feat: Add Spear of Destiny demo support with dedicated asset registry and entity definitions

- Introduced SpearDemoAssetRegistry for managing assets specific to the Spear of Destiny demo.
- Created SpearDemoEntityModule to define enemy animations with adjusted sprite ranges.
- Implemented SpearDemoHudModule and SpearDemoMenuPicModule for HUD and menu assets.
- Added SpearDemoSfxModule for sound effect mappings specific to the demo version.
- Updated enemy classes (Guard, Mutant, Officer, SS) to support custom animation sets.
- Modified entity registry to accept a custom AssetRegistry for spawning entities.
- Enhanced rendering with CRT phosphor bloom effect in GLSL shaders.
- Adjusted ASCII and software renderer layouts for improved UI spacing.
- Added tests for SpearDemoAssetRegistry to ensure correct asset resolution and enemy spawning.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-23 10:37:50 +01:00
parent 528d6276b1
commit a84c677845
28 changed files with 641 additions and 70 deletions
@@ -0,0 +1,19 @@
import 'package:wolf_3d_dart/src/data_types/game_version.dart';
import 'package:wolf_3d_dart/src/registry/asset_registry.dart';
import 'package:wolf_3d_dart/src/registry/built_in/built_in_music_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/spear_demo_entity_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/spear_demo_hud_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/spear_demo_menu_module.dart';
import 'package:wolf_3d_dart/src/registry/built_in/spear_demo_sfx_module.dart';
/// Built-in [AssetRegistry] for Spear of Destiny demo/shareware (`.SDM`).
class SpearDemoAssetRegistry extends AssetRegistry {
SpearDemoAssetRegistry()
: super(
sfx: const SpearDemoSfxModule(),
music: const BuiltInMusicModule(GameVersion.spearOfDestinyDemo),
entities: const SpearDemoEntityModule(),
hud: const SpearDemoHudModule(),
menu: const SpearDemoMenuPicModule(),
);
}
@@ -0,0 +1,52 @@
import 'package:wolf_3d_dart/src/data_types/enemy_animation.dart';
import 'package:wolf_3d_dart/src/data_types/sprite_frame_range.dart';
import 'package:wolf_3d_dart/src/registry/keys/entity_key.dart';
import 'package:wolf_3d_dart/src/registry/modules/entity_asset_module.dart';
/// Built-in entity module for Spear of Destiny demo/shareware (`.SDM`).
///
/// The sprite enum in the original source inserts four additional static
/// sprites before enemy animations for SPEAR builds, so all Wolf3D enemy
/// animation ranges are shifted by +4.
class SpearDemoEntityModule extends EntityAssetModule {
const SpearDemoEntityModule();
static final Map<EntityKey, EntityAssetDefinition> _defs = {
EntityKey.guard: const EntityAssetDefinition(
availableInShareware: true,
animations: EnemyAnimationMap(
idle: SpriteFrameRange(54, 61),
walking: SpriteFrameRange(62, 93),
dying: SpriteFrameRange(94, 97),
pain: SpriteFrameRange(98, 98),
dead: SpriteFrameRange(99, 99),
attacking: SpriteFrameRange(100, 102),
),
),
EntityKey.dog: const EntityAssetDefinition(
availableInShareware: true,
animations: EnemyAnimationMap(
idle: SpriteFrameRange(103, 110),
walking: SpriteFrameRange(103, 134),
attacking: SpriteFrameRange(139, 141),
pain: SpriteFrameRange(0, 0),
dying: SpriteFrameRange(135, 137),
dead: SpriteFrameRange(138, 138),
),
),
EntityKey.ss: const EntityAssetDefinition(
availableInShareware: true,
animations: EnemyAnimationMap(
idle: SpriteFrameRange(142, 149),
walking: SpriteFrameRange(150, 181),
attacking: SpriteFrameRange(188, 190),
pain: SpriteFrameRange(186, 186),
dying: SpriteFrameRange(182, 185),
dead: SpriteFrameRange(187, 187),
),
),
};
@override
EntityAssetDefinition? resolve(EntityKey key) => _defs[key];
}
@@ -0,0 +1,57 @@
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 Spear of Destiny demo/shareware (`.SDM`).
///
/// VGA picture indices are derived from `GFXV_SDM.H` (`chunkId - STARTPICS`).
class SpearDemoHudModule extends HudModule {
const SpearDemoHudModule();
static const List<HudKey> _faceBands = [
HudKey.faceHealthy,
HudKey.faceScratched,
HudKey.faceHurt,
HudKey.faceWounded,
HudKey.faceBadlyWounded,
HudKey.faceDying,
HudKey.faceNearDeath,
];
static final Map<HudKey, int> _indices = {
HudKey.statusBar: 73, // STATUSBARPIC (76 - 3)
HudKey.digit0: 84, // N_0PIC
HudKey.digit1: 85,
HudKey.digit2: 86,
HudKey.digit3: 87,
HudKey.digit4: 88,
HudKey.digit5: 89,
HudKey.digit6: 90,
HudKey.digit7: 91,
HudKey.digit8: 92,
HudKey.digit9: 93,
HudKey.faceHealthy: 94, // FACE1APIC
HudKey.faceScratched: 97,
HudKey.faceHurt: 100,
HudKey.faceWounded: 103,
HudKey.faceBadlyWounded: 106,
HudKey.faceDying: 109,
HudKey.faceNearDeath: 112,
HudKey.faceDead: 122, // BJOUCHPIC
HudKey.pistolIcon: 77, // GUNPIC
HudKey.machineGunIcon: 78, // MACHINEGUNPIC
HudKey.chainGunIcon: 79, // GATLINGGUNPIC
};
@override
HudAssetRef? resolve(HudKey key) {
final index = _indices[key];
return index != null ? HudAssetRef(index) : null;
}
@override
HudKey faceKeyForHealth(int health) {
if (health <= 0) return HudKey.faceDead;
final band = ((100 - health) ~/ 16).clamp(0, _faceBands.length - 1);
return _faceBands[band];
}
}
@@ -0,0 +1,53 @@
import 'package:wolf_3d_dart/src/data_types/difficulty.dart';
import 'package:wolf_3d_dart/src/registry/keys/menu_pic_key.dart';
import 'package:wolf_3d_dart/src/registry/modules/menu_pic_module.dart';
/// Built-in menu-picture module for Spear of Destiny demo/shareware (`.SDM`).
///
/// Picture indices are derived from `GFXV_SDM.H` (`chunkId - STARTPICS`).
class SpearDemoMenuPicModule extends MenuPicModule {
const SpearDemoMenuPicModule();
static final Map<MenuPicKey, int> _indices = {
MenuPicKey.title: 71, // TITLE1PIC
MenuPicKey.credits: 75, // CREDITSPIC
MenuPicKey.pg13: 74, // PG13PIC
MenuPicKey.controlBackground: 12, // C_CONTROLPIC
MenuPicKey.footer: 1, // C_MOUSELBACKPIC
MenuPicKey.heading: 0, // C_BACKDROPPIC
MenuPicKey.optionsLabel: 13, // C_OPTIONSPIC
MenuPicKey.customizeLabel: 6, // C_CUSTOMIZEPIC
MenuPicKey.cursorActive: 2, // C_CURSOR1PIC
MenuPicKey.cursorInactive: 3, // C_CURSOR2PIC
MenuPicKey.markerSelected: 5, // C_SELECTEDPIC
MenuPicKey.markerUnselected: 4, // C_NOTSELECTEDPIC
MenuPicKey.difficultyBaby: 18, // C_BABYMODEPIC
MenuPicKey.difficultyEasy: 19, // C_EASYPIC
MenuPicKey.difficultyNormal: 20, // C_NORMALPIC
MenuPicKey.difficultyHard: 21, // C_HARDPIC
};
@override
MenuPicRef? resolve(MenuPicKey key) {
final index = _indices[key];
return index != null ? MenuPicRef(index) : null;
}
@override
MenuPicKey episodeKey(int episodeIndex) {
return MenuPicKey.episode1;
}
@override
MenuPicKey difficultyKey(Difficulty difficulty) {
return switch (difficulty) {
Difficulty.baby => MenuPicKey.difficultyBaby,
Difficulty.easy => MenuPicKey.difficultyEasy,
Difficulty.medium => MenuPicKey.difficultyNormal,
Difficulty.hard => MenuPicKey.difficultyHard,
};
}
}
@@ -0,0 +1,41 @@
import 'package:wolf_3d_dart/src/registry/keys/sfx_key.dart';
import 'package:wolf_3d_dart/src/registry/modules/sfx_module.dart';
/// Built-in digitized SFX slot mapping for Spear of Destiny demo/shareware.
///
/// Slots follow the original SPEAR/SPEARDEMO DigiMap table in `WL_MAIN.C`.
/// Only sounds present in the SDM digitized table are mapped.
class SpearDemoSfxModule extends SfxModule {
const SpearDemoSfxModule();
static final Map<SoundEffect, int> _slots = {
SoundEffect.openDoor: 3,
SoundEffect.closeDoor: 2,
SoundEffect.pushWall: 13,
SoundEffect.pistolFire: 5,
SoundEffect.machineGunFire: 4,
SoundEffect.chainGunFire: 6,
SoundEffect.enemyFire: 17,
SoundEffect.getChainGun: 38,
SoundEffect.guardHalt: 0,
SoundEffect.ssAlert: 7,
SoundEffect.deathScream1: 10,
SoundEffect.deathScream2: 11,
SoundEffect.deathScream3: 23,
SoundEffect.ssDeath: 28,
SoundEffect.bossActive: 8,
SoundEffect.hansGrosseDeath: 24,
SoundEffect.levelComplete: 22,
};
@override
SoundAssetRef? resolve(SoundEffect key) {
final slot = _slots[key];
return slot == null ? null : SoundAssetRef(slot);
}
}
@@ -3,6 +3,7 @@ import 'package:wolf_3d_dart/src/data_types/game_version.dart';
import 'package:wolf_3d_dart/src/registry/asset_registry.dart';
import 'package:wolf_3d_dart/src/registry/built_in/retail_asset_registry.dart';
import 'package:wolf_3d_dart/src/registry/built_in/shareware_asset_registry.dart';
import 'package:wolf_3d_dart/src/registry/built_in/spear_demo_asset_registry.dart';
/// The input used by [AssetRegistryResolver] to select or build a registry.
class RegistrySelectionContext {
@@ -47,8 +48,9 @@ class BuiltInAssetRegistryResolver implements AssetRegistryResolver {
case DataVersion.version14Retail:
return RetailAssetRegistry();
case DataVersion.version14Shareware:
case DataVersion.spearOfDestinyShareware:
return SharewareAssetRegistry(strictOriginalShareware: true);
case DataVersion.spearOfDestinyShareware:
return SpearDemoAssetRegistry();
case _:
break; // fall through to GameVersion family check
}
@@ -61,9 +63,7 @@ class BuiltInAssetRegistryResolver implements AssetRegistryResolver {
return SharewareAssetRegistry();
case GameVersion.spearOfDestiny:
case GameVersion.spearOfDestinyDemo:
// Spear-specific modules are not yet implemented; retail layout is
// the closest structural match and will be replaced in a later phase.
return RetailAssetRegistry();
return SpearDemoAssetRegistry();
}
}
}