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
@@ -18,6 +18,7 @@ enum WolfRendererMode {
enum WolfRendererOptionId {
asciiTheme,
hardwareEffects,
crtBloom,
fpsCounter,
}
@@ -27,12 +28,14 @@ class WolfRendererCapabilities {
required this.supportedModes,
this.supportsAsciiThemes = false,
this.supportsHardwareEffects = false,
this.supportsBloom = false,
this.supportsFpsCounter = false,
});
final Set<WolfRendererMode> supportedModes;
final bool supportsAsciiThemes;
final bool supportsHardwareEffects;
final bool supportsBloom;
final bool supportsFpsCounter;
bool supportsMode(WolfRendererMode mode) => supportedModes.contains(mode);
@@ -44,6 +47,7 @@ class WolfRendererSettings {
this.mode = WolfRendererMode.software,
this.asciiThemeId = asciiThemeBlocks,
this.hardwareEffectsEnabled = false,
this.bloomEnabled = false,
this.fpsCounterEnabled = false,
});
@@ -58,12 +62,14 @@ class WolfRendererSettings {
final WolfRendererMode mode;
final String asciiThemeId;
final bool hardwareEffectsEnabled;
final bool bloomEnabled;
final bool fpsCounterEnabled;
WolfRendererSettings copyWith({
WolfRendererMode? mode,
String? asciiThemeId,
bool? hardwareEffectsEnabled,
bool? bloomEnabled,
bool? fpsCounterEnabled,
}) {
return WolfRendererSettings(
@@ -71,6 +77,7 @@ class WolfRendererSettings {
asciiThemeId: asciiThemeId ?? this.asciiThemeId,
hardwareEffectsEnabled:
hardwareEffectsEnabled ?? this.hardwareEffectsEnabled,
bloomEnabled: bloomEnabled ?? this.bloomEnabled,
fpsCounterEnabled: fpsCounterEnabled ?? this.fpsCounterEnabled,
);
}
@@ -96,6 +103,7 @@ class WolfRendererSettings {
'mode': mode.name,
'asciiThemeId': asciiThemeId,
'hardwareEffectsEnabled': hardwareEffectsEnabled,
'bloomEnabled': bloomEnabled,
'fpsCounterEnabled': fpsCounterEnabled,
};
}
@@ -117,6 +125,7 @@ class WolfRendererSettings {
mode: mode,
asciiThemeId: asciiThemeId,
hardwareEffectsEnabled: json['hardwareEffectsEnabled'] == true,
bloomEnabled: json['bloomEnabled'] == true,
fpsCounterEnabled: json['fpsCounterEnabled'] == true,
);
}
@@ -284,9 +284,24 @@ class WolfEngine {
if (!rendererCapabilities.supportsHardwareEffects) {
return;
}
final bool nextEnabled = !rendererSettings.hardwareEffectsEnabled;
updateRendererSettings(
rendererSettings.copyWith(
hardwareEffectsEnabled: !rendererSettings.hardwareEffectsEnabled,
hardwareEffectsEnabled: nextEnabled,
bloomEnabled: nextEnabled ? rendererSettings.bloomEnabled : false,
),
);
}
/// Toggles CRT bloom glow for hardware renderer hosts.
void toggleBloom() {
if (!rendererCapabilities.supportsBloom ||
!rendererSettings.hardwareEffectsEnabled) {
return;
}
updateRendererSettings(
rendererSettings.copyWith(
bloomEnabled: !rendererSettings.bloomEnabled,
),
);
}
@@ -328,6 +343,15 @@ class WolfEngine {
);
}
if (!rendererSettings.hardwareEffectsEnabled &&
rendererSettings.bloomEnabled) {
rendererSettings = rendererSettings.copyWith(bloomEnabled: false);
}
if (!rendererCapabilities.supportsBloom && rendererSettings.bloomEnabled) {
rendererSettings = rendererSettings.copyWith(bloomEnabled: false);
}
if (!rendererCapabilities.supportsFpsCounter &&
rendererSettings.fpsCounterEnabled) {
rendererSettings = rendererSettings.copyWith(fpsCounterEnabled: false);
@@ -364,6 +388,18 @@ class WolfEngine {
);
}
if (activeMode == WolfRendererMode.hardware &&
rendererCapabilities.supportsBloom) {
options.add(
WolfMenuRendererOptionEntry(
id: WolfRendererOptionId.crtBloom,
label: 'CRT BLOOM',
isEnabled: rendererSettings.hardwareEffectsEnabled,
isChecked: rendererSettings.bloomEnabled,
),
);
}
if (rendererCapabilities.supportsFpsCounter) {
options.add(
WolfMenuRendererOptionEntry(
@@ -628,6 +664,9 @@ class WolfEngine {
case WolfRendererOptionId.hardwareEffects:
toggleHardwareEffects();
break;
case WolfRendererOptionId.crtBloom:
toggleBloom();
break;
case WolfRendererOptionId.fpsCounter:
toggleFpsCounter();
break;
@@ -651,6 +690,9 @@ class WolfEngine {
case WolfRendererOptionId.hardwareEffects:
toggleHardwareEffects();
break;
case WolfRendererOptionId.crtBloom:
toggleBloom();
break;
case WolfRendererOptionId.fpsCounter:
toggleFpsCounter();
break;
@@ -758,6 +800,7 @@ class WolfEngine {
difficulty!,
data.sprites.length,
isSharewareMode: data.version == GameVersion.shareware,
registry: data.registry,
);
if (newEntity != null) entities.add(newEntity);
}
@@ -81,6 +81,7 @@ abstract class Collectible extends Entity {
double y,
Difficulty difficulty, {
bool isSharewareMode = false,
AssetRegistry? registry,
}) {
return switch (objId) {
MapObject.goldKey || MapObject.silverKey => KeyCollectible(
@@ -15,6 +15,7 @@ class DeadAardwolf extends Decorative {
double y,
Difficulty difficulty, {
bool isSharewareMode = false,
AssetRegistry? registry,
}) {
if (objId == 125) {
return DeadAardwolf(x: x, y: y);
@@ -21,6 +21,7 @@ class DeadGuard extends Decorative {
double y,
Difficulty difficulty, {
bool isSharewareMode = false,
AssetRegistry? registry,
}) {
if (objId == 124) {
return DeadGuard(x: x, y: y);
@@ -1,5 +1,5 @@
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/src/entities/entity.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
class Decorative extends Entity {
Decorative({
@@ -41,6 +41,7 @@ class Decorative extends Entity {
double y,
Difficulty difficulty, {
bool isSharewareMode = false,
AssetRegistry? registry,
}) {
// 2. Standard props (Table, Lamp, etc) use the tiered check
if (!MapObject.isDifficultyAllowed(objId, difficulty)) return null;
@@ -45,6 +45,7 @@ class HansGrosse extends Enemy {
double y,
Difficulty difficulty, {
bool isSharewareMode = false,
AssetRegistry? registry,
}) {
if (objId == MapObject.bossHansGrosse) {
return HansGrosse(
@@ -25,14 +25,26 @@ class Dog extends Enemy {
required super.y,
required super.angle,
required super.mapId,
super.animationSet,
Difficulty difficulty = Difficulty.medium,
}) : super(
spriteIndex: EnemyType.dog.animations.walking.start,
spriteIndex: (animationSet ?? EnemyType.dog.animations).walking.start,
state: EntityState.patrolling,
) {
health = type.hitPointsFor(difficulty);
}
@override
EnemyAnimation animationForState(EntityState state) {
return switch (state) {
EntityState.patrolling || EntityState.ambushing => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
_ => EnemyAnimation.idle,
};
}
@override
({Coordinate2D movement, double newAngle}) update({
required int elapsedMs,
@@ -314,15 +326,9 @@ class Dog extends Enemy {
diff -= 2 * math.pi;
}
EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling || EntityState.ambushing => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
_ => EnemyAnimation.idle,
};
final currentAnim = animationForState(state);
spriteIndex = type.getSpriteFromAnimation(
spriteIndex = spriteForAnimation(
animation: currentAnim,
elapsedMs: elapsedMs,
lastActionTime: lastActionTime,
@@ -24,11 +24,14 @@ abstract class Enemy extends Entity {
required super.x,
required super.y,
required super.spriteIndex,
EnemyAnimationMap? animationSet,
super.angle,
super.state,
super.mapId,
super.lastActionTime,
});
}) : _animationSetOverride = animationSet;
EnemyAnimationMap? _animationSetOverride;
/// The current "Tic" count remaining for the active animation frame.
int _ticCount = 0;
@@ -66,6 +69,66 @@ abstract class Enemy extends Entity {
/// The sound played once when this enemy starts dying.
SoundEffect get deathSound => type.deathSound;
EnemyAnimationMap get animationSet =>
_animationSetOverride ?? type.animations;
EnemyAnimation animationForState(EntityState state) {
return switch (state) {
EntityState.patrolling => EnemyAnimation.walking,
EntityState.ambushing => EnemyAnimation.idle,
EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
_ => EnemyAnimation.idle,
};
}
void setAnimationSet(
EnemyAnimationMap set, {
bool alignSpriteToState = false,
}) {
_animationSetOverride = set;
if (alignSpriteToState) {
final anim = animationForState(state);
spriteIndex = animationSet.getRange(anim).start;
}
}
int spriteForAnimation({
required EnemyAnimation animation,
required int elapsedMs,
required int lastActionTime,
double angleDiff = 0,
int? walkFrameOverride,
}) {
final range = animationSet.getRange(animation);
int octant = ((angleDiff + (math.pi / 8)) / (math.pi / 4)).floor() % 8;
if (octant < 0) octant += 8;
return switch (animation) {
EnemyAnimation.idle => range.start + octant,
EnemyAnimation.walking => () {
int framesPerAngle = range.length ~/ 8;
if (framesPerAngle < 1) framesPerAngle = 1;
int frame = walkFrameOverride ?? (elapsedMs ~/ 150) % framesPerAngle;
return range.start + (frame * 8) + octant;
}(),
EnemyAnimation.attacking => () {
int time = elapsedMs - lastActionTime;
int mappedFrame = (time ~/ 150).clamp(0, range.length - 1);
return range.start + mappedFrame;
}(),
EnemyAnimation.pain => range.start,
EnemyAnimation.dying => () {
int time = elapsedMs - lastActionTime;
int mappedFrame = (time ~/ 150).clamp(0, range.length - 1);
return range.start + mappedFrame;
}(),
EnemyAnimation.dead => range.start,
};
}
/// Ensures enemies drop only one item (like ammo or a key) upon death.
bool hasDroppedItem = false;
@@ -555,6 +618,7 @@ abstract class Enemy extends Entity {
double y,
Difficulty difficulty, {
bool isSharewareMode = false,
AssetRegistry? registry,
}) {
// Filter out decorative bodies or player starts
if (objId == MapObject.deadGuard || objId == MapObject.deadAardwolf) {
@@ -572,20 +636,33 @@ abstract class Enemy extends Entity {
EnemyType? matchedType;
int? normalizedId;
EnemyAnimationMap? matchedAnimationSet;
// Find which enemy type claims this ID for the current difficulty
for (final type in EnemyType.values) {
if (isSharewareMode && !type.existsInShareware) continue;
final animationSet = _resolveAnimationSet(type, registry);
if (animationSet == null) {
continue;
}
if (registry == null && isSharewareMode && !type.existsInShareware) {
continue;
}
normalizedId = type.mapData.getNormalizedId(objId, difficulty);
if (normalizedId != null) {
matchedType = type;
matchedAnimationSet = animationSet;
break;
}
}
// If no type claimed it, or the difficulty was too low, abort spawn
if (matchedType == null || normalizedId == null) return null;
if (matchedType == null ||
normalizedId == null ||
matchedAnimationSet == null) {
return null;
}
// Resolve spawn orientation using the NORMALIZED ID (0-7 offset from base)
// This prevents offset math bugs (like the Mutant's 18-ID shift) from breaking facing directions.
@@ -606,13 +683,14 @@ abstract class Enemy extends Entity {
}
// Return the specific instance
return switch (matchedType) {
final spawned = switch (matchedType) {
EnemyType.guard => Guard(
x: x,
y: y,
angle: spawnAngle,
mapId: objId,
difficulty: difficulty,
animationSet: matchedAnimationSet,
),
EnemyType.dog => Dog(
x: x,
@@ -620,6 +698,7 @@ abstract class Enemy extends Entity {
angle: spawnAngle,
mapId: objId,
difficulty: difficulty,
animationSet: matchedAnimationSet,
),
EnemyType.ss => SS(
x: x,
@@ -627,6 +706,7 @@ abstract class Enemy extends Entity {
angle: spawnAngle,
mapId: objId,
difficulty: difficulty,
animationSet: matchedAnimationSet,
),
EnemyType.mutant => Mutant(
x: x,
@@ -634,6 +714,7 @@ abstract class Enemy extends Entity {
angle: spawnAngle,
mapId: objId,
difficulty: difficulty,
animationSet: matchedAnimationSet,
),
EnemyType.officer => Officer(
x: x,
@@ -641,7 +722,33 @@ abstract class Enemy extends Entity {
angle: spawnAngle,
mapId: objId,
difficulty: difficulty,
animationSet: matchedAnimationSet,
),
}..state = spawnState;
};
spawned
..state = spawnState
..setAnimationSet(matchedAnimationSet, alignSpriteToState: true);
return spawned;
}
static EnemyAnimationMap? _resolveAnimationSet(
EnemyType type,
AssetRegistry? registry,
) {
if (registry == null) {
return type.animations;
}
final key = switch (type) {
EnemyType.guard => EntityKey.guard,
EnemyType.dog => EntityKey.dog,
EnemyType.ss => EntityKey.ss,
EnemyType.mutant => EntityKey.mutant,
EnemyType.officer => EntityKey.officer,
};
return registry.entities.resolve(key)?.animations;
}
}
@@ -18,9 +18,10 @@ class Guard extends Enemy {
required super.y,
required super.angle,
required super.mapId,
super.animationSet,
Difficulty difficulty = Difficulty.medium,
}) : super(
spriteIndex: EnemyType.guard.animations.idle.start,
spriteIndex: (animationSet ?? EnemyType.guard.animations).idle.start,
state: EntityState.idle,
) {
health = type.hitPointsFor(difficulty);
@@ -150,14 +151,15 @@ class Guard extends Enemy {
}
EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling || EntityState.ambushing => EnemyAnimation.walking,
EntityState.patrolling => EnemyAnimation.walking,
EntityState.ambushing => EnemyAnimation.idle,
EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
_ => EnemyAnimation.idle,
};
spriteIndex = type.getSpriteFromAnimation(
spriteIndex = spriteForAnimation(
animation: currentAnim,
elapsedMs: elapsedMs,
lastActionTime: lastActionTime,
@@ -16,9 +16,10 @@ class Mutant extends Enemy {
required super.y,
required super.angle,
required super.mapId,
super.animationSet,
Difficulty difficulty = Difficulty.medium,
}) : super(
spriteIndex: EnemyType.mutant.animations.idle.start,
spriteIndex: (animationSet ?? EnemyType.mutant.animations).idle.start,
state: EntityState.idle,
) {
health = type.hitPointsFor(difficulty);
@@ -65,14 +66,15 @@ class Mutant extends Enemy {
}
EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling || EntityState.ambushing => EnemyAnimation.walking,
EntityState.patrolling => EnemyAnimation.walking,
EntityState.ambushing => EnemyAnimation.idle,
EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
_ => EnemyAnimation.idle,
};
spriteIndex = type.getSpriteFromAnimation(
spriteIndex = spriteForAnimation(
animation: currentAnim,
elapsedMs: elapsedMs,
lastActionTime: lastActionTime,
@@ -16,9 +16,10 @@ class Officer extends Enemy {
required super.y,
required super.angle,
required super.mapId,
super.animationSet,
Difficulty difficulty = Difficulty.medium,
}) : super(
spriteIndex: EnemyType.officer.animations.idle.start,
spriteIndex: (animationSet ?? EnemyType.officer.animations).idle.start,
state: EntityState.idle,
) {
health = type.hitPointsFor(difficulty);
@@ -65,14 +66,15 @@ class Officer extends Enemy {
}
EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling || EntityState.ambushing => EnemyAnimation.walking,
EntityState.patrolling => EnemyAnimation.walking,
EntityState.ambushing => EnemyAnimation.idle,
EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
_ => EnemyAnimation.idle,
};
spriteIndex = type.getSpriteFromAnimation(
spriteIndex = spriteForAnimation(
animation: currentAnim,
elapsedMs: elapsedMs,
lastActionTime: lastActionTime,
@@ -16,9 +16,10 @@ class SS extends Enemy {
required super.y,
required super.angle,
required super.mapId,
super.animationSet,
Difficulty difficulty = Difficulty.medium,
}) : super(
spriteIndex: EnemyType.ss.animations.idle.start,
spriteIndex: (animationSet ?? EnemyType.ss.animations).idle.start,
state: EntityState.idle,
) {
health = type.hitPointsFor(difficulty);
@@ -64,14 +65,15 @@ class SS extends Enemy {
}
EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling || EntityState.ambushing => EnemyAnimation.walking,
EntityState.patrolling => EnemyAnimation.walking,
EntityState.ambushing => EnemyAnimation.idle,
EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
_ => EnemyAnimation.idle,
};
spriteIndex = type.getSpriteFromAnimation(
spriteIndex = spriteForAnimation(
animation: currentAnim,
elapsedMs: elapsedMs,
lastActionTime: lastActionTime,
@@ -15,6 +15,7 @@ typedef EntitySpawner =
double y,
Difficulty difficulty, {
bool isSharewareMode,
AssetRegistry? registry,
});
/// The central factory for instantiating all dynamic objects in a Wolf3D level.
@@ -52,6 +53,7 @@ abstract class EntityRegistry {
Difficulty difficulty,
int maxSprites, {
bool isSharewareMode = false,
AssetRegistry? registry,
}) {
if (objId == 0) return null;
@@ -62,6 +64,7 @@ abstract class EntityRegistry {
y,
difficulty,
isSharewareMode: isSharewareMode,
registry: registry,
);
if (entity != null) return entity;
}
@@ -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();
}
}
}
@@ -649,8 +649,8 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
);
final selectedMarker = art.selectedMarker;
final unselectedMarker = art.unselectedMarker;
const int rowYStart = 66;
const int rowStep = 18;
const int rowYStart = 64;
const int rowStep = 16;
const int cursorX = 62;
const int markerX = 92;
const int textX = 122;
@@ -664,11 +664,11 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
? 0
: ((modeCount - 1) * rowStep) + 12;
final int modesPanelHeight = math.max(56, modesContentHeight + 14);
final int sectionHeaderY = modesPanelY + modesPanelHeight + 6;
final int sectionHeaderY = modesPanelY + modesPanelHeight + 4;
final int optionsPanelY = sectionHeaderY + 14;
final int optionsContentHeight = optionCount <= 0
? 0
: ((optionCount - 1) * 15) + 12;
: ((optionCount - 1) * 14) + 10;
final int optionsPanelHeight = math.max(30, optionsContentHeight + 10);
_fillRect320(46, modesPanelY, 228, modesPanelHeight, panelColor);
@@ -706,7 +706,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
y200: sectionHeaderY,
);
const int optionsRowStep = 15;
const int optionsRowStep = 14;
final int optionsRowsHeight = optionCount <= 0
? 0
: ((optionCount - 1) * optionsRowStep) + 10;
@@ -450,26 +450,8 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
const int modesPanelX = 46;
const int modesPanelY = 52;
const int modesPanelW = 228;
const int modesPanelH = 74;
_fillCanonicalRect(
modesPanelX,
modesPanelY,
modesPanelW,
modesPanelH,
panelColor,
);
const int optionsPanelX = 46;
const int optionsPanelY = 146;
const int optionsPanelW = 228;
const int optionsPanelH = 42;
_fillCanonicalRect(
optionsPanelX,
optionsPanelY,
optionsPanelW,
optionsPanelH,
panelColor,
);
final VgaImage? heading = art.customizeLabel ?? art.optionsLabel;
if (heading != null) {
@@ -490,8 +472,8 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const int rowYStart = 66;
const int rowStep = 18;
const int rowYStart = 64;
const int rowStep = 16;
const int cursorX = 62;
const int markerX = 92;
const int textX = 122;
@@ -500,6 +482,38 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
final optionEntries = engine.menuManager.rendererOptionEntries;
final int modeCount = entries.length;
final int selectedIndex = engine.menuManager.selectedChangeViewIndex;
final int modesContentHeight = entries.isEmpty
? 0
: ((entries.length - 1) * rowStep) + 12;
final int modesPanelH = math.max(56, modesContentHeight + 14);
final int sectionHeaderY = modesPanelY + modesPanelH + 4;
final int optionsPanelY = sectionHeaderY + 14;
const int optionsRowStep = 14;
final int optionsContentHeight = optionEntries.isEmpty
? 0
: ((optionEntries.length - 1) * optionsRowStep) + 10;
final int optionsPanelH = math.max(
30,
math.min(
_menuFooterY - 6 - optionsPanelY,
optionsContentHeight + 10,
),
);
_fillCanonicalRect(
modesPanelX,
modesPanelY,
modesPanelW,
modesPanelH,
panelColor,
);
_fillCanonicalRect(
optionsPanelX,
optionsPanelY,
optionsPanelW,
optionsPanelH,
panelColor,
);
for (int i = 0; i < entries.length; i++) {
final bool isSelected = i == selectedIndex;
@@ -528,12 +542,15 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
_drawMenuSectionHeader(
text: engine.menuManager.rendererOptionsTitle,
y200: 132,
y200: sectionHeaderY,
textColor: ColorPalette.vga32Bit[8],
);
const int optionsRowStart = 159;
const int optionsRowStep = 15;
final int optionsRowsHeight = optionEntries.isEmpty
? 0
: ((optionEntries.length - 1) * optionsRowStep) + 10;
final int optionsRowStart =
optionsPanelY + ((optionsPanelH - optionsRowsHeight) ~/ 2).clamp(0, 200);
for (int i = 0; i < optionEntries.length; i++) {
final int optionIndex = modeCount + i;
final bool isSelected = optionIndex == selectedIndex;
@@ -152,6 +152,20 @@ class WolfAudio implements EngineAudio {
Future<void> playSoundEffect(SoundEffect effect) async {
final data = activeGame;
if (data == null) return;
final resolved = data.registry.sfx.resolve(effect);
if (resolved != null) {
await playSoundEffectId(resolved.slotIndex);
return;
}
// Spear demo/shareware has a much smaller digitized table than retail.
// If a sound is not explicitly mapped for that variant, skip it rather
// than probing an invalid or unrelated slot.
if (data.version == GameVersion.spearOfDestinyDemo) {
return;
}
await playSoundEffectId(effect.idFor(data.version));
}
@@ -30,6 +30,8 @@ export 'src/registry/built_in/retail_asset_registry.dart'
show RetailAssetRegistry;
export 'src/registry/built_in/shareware_asset_registry.dart'
show SharewareAssetRegistry;
export 'src/registry/built_in/spear_demo_asset_registry.dart'
show SpearDemoAssetRegistry;
export 'src/registry/keys/entity_key.dart' show EntityKey;
export 'src/registry/keys/hud_key.dart' show HudKey;
export 'src/registry/keys/menu_pic_key.dart' show MenuPicKey;