diff --git a/apps/wolf_3d_gui/lib/screens/game_screen.dart b/apps/wolf_3d_gui/lib/screens/game_screen.dart index ffd32ea..4b90fd3 100644 --- a/apps/wolf_3d_gui/lib/screens/game_screen.dart +++ b/apps/wolf_3d_gui/lib/screens/game_screen.dart @@ -159,6 +159,7 @@ class _GameScreenState extends State { supportedModes: supportedModes, supportsAsciiThemes: true, supportsHardwareEffects: true, + supportsBloom: true, supportsFpsCounter: true, ), rendererSettings: const WolfRendererSettings( @@ -298,6 +299,7 @@ class _GameScreenState extends State { return WolfGlslRenderer( engine: _engine, effectsEnabled: settings.hardwareEffectsEnabled, + bloomEnabled: settings.bloomEnabled, onKeyEvent: _handleRendererKeyEvent, onUnavailable: _onGlslUnavailable, ); diff --git a/packages/wolf_3d_dart/lib/src/engine/rendering/renderer_settings.dart b/packages/wolf_3d_dart/lib/src/engine/rendering/renderer_settings.dart index 7195b22..b190d00 100644 --- a/packages/wolf_3d_dart/lib/src/engine/rendering/renderer_settings.dart +++ b/packages/wolf_3d_dart/lib/src/engine/rendering/renderer_settings.dart @@ -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 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, ); } diff --git a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart index 7652777..d7ca72f 100644 --- a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart +++ b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart @@ -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); } 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 d04af03..8a8f9b3 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/collectible.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/collectible.dart @@ -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( diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/decorations/dead_aardwolf.dart b/packages/wolf_3d_dart/lib/src/entities/entities/decorations/dead_aardwolf.dart index 74f87ac..acfb6e4 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/decorations/dead_aardwolf.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/decorations/dead_aardwolf.dart @@ -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); diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/decorations/dead_guard.dart b/packages/wolf_3d_dart/lib/src/entities/entities/decorations/dead_guard.dart index db5f8cc..65f3830 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/decorations/dead_guard.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/decorations/dead_guard.dart @@ -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); diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/decorative.dart b/packages/wolf_3d_dart/lib/src/entities/entities/decorative.dart index 032ab3f..4103d29 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/decorative.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/decorative.dart @@ -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; diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/bosses/hans_grosse.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/bosses/hans_grosse.dart index d18873b..7f34b02 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/bosses/hans_grosse.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/bosses/hans_grosse.dart @@ -45,6 +45,7 @@ class HansGrosse extends Enemy { double y, Difficulty difficulty, { bool isSharewareMode = false, + AssetRegistry? registry, }) { if (objId == MapObject.bossHansGrosse) { return HansGrosse( diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/dog.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/dog.dart index 4e3a1a4..368adfd 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/dog.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/dog.dart @@ -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, 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 aa4ca14..3e02ec6 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 @@ -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; } } diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/guard.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/guard.dart index 74b8da3..77facd8 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/guard.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/guard.dart @@ -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, diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/mutant.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/mutant.dart index 88197e6..c6f9a90 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/mutant.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/mutant.dart @@ -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, diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/officer.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/officer.dart index 8663d42..dccbbbc 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/officer.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/officer.dart @@ -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, diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/ss.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/ss.dart index c498248..8827bb5 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/ss.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/ss.dart @@ -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, diff --git a/packages/wolf_3d_dart/lib/src/entities/entity_registry.dart b/packages/wolf_3d_dart/lib/src/entities/entity_registry.dart index 61b5a79..45fcdef 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entity_registry.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entity_registry.dart @@ -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; } diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_asset_registry.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_asset_registry.dart new file mode 100644 index 0000000..24ab45d --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_asset_registry.dart @@ -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(), + ); +} diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_entity_module.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_entity_module.dart new file mode 100644 index 0000000..36f3292 --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_entity_module.dart @@ -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 _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]; +} diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_hud_module.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_hud_module.dart new file mode 100644 index 0000000..d0071c6 --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_hud_module.dart @@ -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 _faceBands = [ + HudKey.faceHealthy, + HudKey.faceScratched, + HudKey.faceHurt, + HudKey.faceWounded, + HudKey.faceBadlyWounded, + HudKey.faceDying, + HudKey.faceNearDeath, + ]; + + static final Map _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]; + } +} diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_menu_module.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_menu_module.dart new file mode 100644 index 0000000..149d9a0 --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_menu_module.dart @@ -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 _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, + }; + } +} diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_sfx_module.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_sfx_module.dart new file mode 100644 index 0000000..10f53c1 --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_sfx_module.dart @@ -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 _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); + } +} 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 e1278ff..6fdce0f 100644 --- a/packages/wolf_3d_dart/lib/src/registry/registry_resolver.dart +++ b/packages/wolf_3d_dart/lib/src/registry/registry_resolver.dart @@ -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(); } } } 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 9c0ae91..bcd391d 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart @@ -649,8 +649,8 @@ class AsciiRenderer extends CliRendererBackend { ); 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 { ? 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 { y200: sectionHeaderY, ); - const int optionsRowStep = 15; + const int optionsRowStep = 14; final int optionsRowsHeight = optionCount <= 0 ? 0 : ((optionCount - 1) * optionsRowStep) + 10; 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 b4de566..599deb3 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart @@ -450,26 +450,8 @@ class SoftwareRenderer extends RendererBackend { 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 { 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 { 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 { _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; diff --git a/packages/wolf_3d_dart/lib/src/synth/wolf_3d_audio.dart b/packages/wolf_3d_dart/lib/src/synth/wolf_3d_audio.dart index b43e140..9232e29 100644 --- a/packages/wolf_3d_dart/lib/src/synth/wolf_3d_audio.dart +++ b/packages/wolf_3d_dart/lib/src/synth/wolf_3d_audio.dart @@ -152,6 +152,20 @@ class WolfAudio implements EngineAudio { Future 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)); } diff --git a/packages/wolf_3d_dart/lib/wolf_3d_data_types.dart b/packages/wolf_3d_dart/lib/wolf_3d_data_types.dart index 80d8f67..b9da144 100644 --- a/packages/wolf_3d_dart/lib/wolf_3d_data_types.dart +++ b/packages/wolf_3d_dart/lib/wolf_3d_data_types.dart @@ -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; diff --git a/packages/wolf_3d_dart/test/registry/spear_demo_registry_test.dart b/packages/wolf_3d_dart/test/registry/spear_demo_registry_test.dart new file mode 100644 index 0000000..cccc927 --- /dev/null +++ b/packages/wolf_3d_dart/test/registry/spear_demo_registry_test.dart @@ -0,0 +1,80 @@ +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('SpearDemoAssetRegistry', () { + test('resolves SDM menu and HUD VGA indices', () { + final registry = SpearDemoAssetRegistry(); + + expect( + registry.menu.resolve(MenuPicKey.controlBackground)?.pictureIndex, + 12, + ); + expect(registry.menu.resolve(MenuPicKey.optionsLabel)?.pictureIndex, 13); + expect(registry.menu.resolve(MenuPicKey.episode1), isNull); + + expect(registry.hud.resolve(HudKey.statusBar)?.vgaIndex, 73); + expect(registry.hud.resolve(HudKey.digit0)?.vgaIndex, 84); + expect(registry.hud.resolve(HudKey.pistolIcon)?.vgaIndex, 77); + }); + + test('resolves SDM enemy sprite ranges with +4 SPEAR shift', () { + final registry = SpearDemoAssetRegistry(); + + final guard = registry.entities.resolve(EntityKey.guard); + final dog = registry.entities.resolve(EntityKey.dog); + + expect(guard?.animations?.idle.start, 54); + expect(guard?.animations?.attacking.end, 102); + expect(dog?.animations?.walking.start, 103); + expect(dog?.animations?.dead.start, 138); + }); + + test('uses SDM digitized sound slot mapping', () { + final registry = SpearDemoAssetRegistry(); + + expect(registry.sfx.resolve(SoundEffect.openDoor)?.slotIndex, 3); + expect(registry.sfx.resolve(SoundEffect.closeDoor)?.slotIndex, 2); + expect(registry.sfx.resolve(SoundEffect.pushWall)?.slotIndex, 13); + expect(registry.sfx.resolve(SoundEffect.levelComplete)?.slotIndex, 22); + }); + }); + + group('BuiltInAssetRegistryResolver Spear SDM selection', () { + test('uses Spear demo registry for exact SDM identity', () { + const resolver = BuiltInAssetRegistryResolver(); + + final registry = resolver.resolve( + const RegistrySelectionContext( + gameVersion: GameVersion.spearOfDestinyDemo, + dataVersion: DataVersion.spearOfDestinyShareware, + ), + ); + + expect(registry, isA()); + expect(registry.hud.resolve(HudKey.statusBar)?.vgaIndex, 73); + expect(registry.sfx.resolve(SoundEffect.openDoor)?.slotIndex, 3); + expect( + registry.entities.resolve(EntityKey.guard)?.animations?.idle.start, + 54, + ); + }); + + test('runtime enemy spawn consumes registry animation ranges', () { + final registry = SpearDemoAssetRegistry(); + + final enemy = Enemy.spawn( + MapObject.guardStart, + 4.5, + 5.5, + Difficulty.medium, + registry: registry, + ); + + expect(enemy, isA()); + expect(enemy?.state, EntityState.ambushing); + expect(enemy?.spriteIndex, 54); // SDM guard idle start + }); + }); +} diff --git a/packages/wolf_3d_renderer/lib/wolf_3d_glsl_renderer.dart b/packages/wolf_3d_renderer/lib/wolf_3d_glsl_renderer.dart index c29173d..72caf73 100644 --- a/packages/wolf_3d_renderer/lib/wolf_3d_glsl_renderer.dart +++ b/packages/wolf_3d_renderer/lib/wolf_3d_glsl_renderer.dart @@ -14,6 +14,9 @@ class WolfGlslRenderer extends BaseWolfRenderer { /// Whether CRT-like post effects are enabled in the shader pass. final bool effectsEnabled; + /// Whether CRT phosphor bloom is enabled in the shader pass. + final bool bloomEnabled; + /// Callback when shader loading fails and software fallback should be used. final VoidCallback? onUnavailable; @@ -21,6 +24,7 @@ class WolfGlslRenderer extends BaseWolfRenderer { const WolfGlslRenderer({ required super.engine, this.effectsEnabled = false, + this.bloomEnabled = false, super.onKeyEvent, this.onUnavailable, super.key, @@ -116,6 +120,7 @@ class _WolfGlslRendererState extends BaseWolfRendererState { frame: _renderedFrame!, shader: _shader!, effectsEnabled: widget.effectsEnabled, + bloomEnabled: widget.bloomEnabled, elapsedSeconds: widget.engine.timeAliveMs / 1000.0, ), child: const SizedBox.expand(), @@ -153,12 +158,14 @@ class _GlslFramePainter extends CustomPainter { final ui.Image frame; final ui.FragmentShader shader; final bool effectsEnabled; + final bool bloomEnabled; final double elapsedSeconds; _GlslFramePainter({ required this.frame, required this.shader, required this.effectsEnabled, + required this.bloomEnabled, required this.elapsedSeconds, }); @@ -173,6 +180,7 @@ class _GlslFramePainter extends CustomPainter { ..setFloat(3, texelY) ..setFloat(4, effectsEnabled ? 1.0 : 0.0) ..setFloat(5, elapsedSeconds) + ..setFloat(6, bloomEnabled ? 1.0 : 0.0) ..setImageSampler(0, frame); final Paint paint = Paint() @@ -187,6 +195,7 @@ class _GlslFramePainter extends CustomPainter { return oldDelegate.frame != frame || oldDelegate.shader != shader || oldDelegate.effectsEnabled != effectsEnabled || + oldDelegate.bloomEnabled != bloomEnabled || oldDelegate.elapsedSeconds != elapsedSeconds; } } diff --git a/packages/wolf_3d_renderer/shaders/wolf_world.frag b/packages/wolf_3d_renderer/shaders/wolf_world.frag index 00af15d..df8c5fd 100644 --- a/packages/wolf_3d_renderer/shaders/wolf_world.frag +++ b/packages/wolf_3d_renderer/shaders/wolf_world.frag @@ -8,6 +8,8 @@ uniform vec2 uTexel; uniform float uEffectsEnabled; // Engine time in seconds used to animate scanline travel. uniform float uTime; +// 1.0 enables CRT phosphor bloom glow, 0.0 disables it. +uniform float uBloomEnabled; // Source frame produced by the software renderer. uniform sampler2D uTexture; @@ -46,6 +48,7 @@ void main() { texture(uTexture, bleedUv1).rgb * 0.28 + texture(uTexture, bleedUv2).rgb * 0.14 + texture(uTexture, bleedUv3).rgb * 0.06; + float edgeBleedLuma = luma(edgeBleedColor); // Approximate concave bezel depth by measuring how far this fragment is // from the emissive screen boundary in aspect-corrected UV space. @@ -57,20 +60,24 @@ void main() { vec2 clampedCentered = clampedUv * 2.0 - 1.0; float cornerFactor = smoothstep(0.60, 1.15, length(clampedCentered)); - float verticalShade = 0.88 + 0.07 * (1.0 - (FlutterFragCoord().y / uResolution.y)); - float depthShade = 1.0 - smoothstep(0.0, 0.058, overflow) * 0.34; - float grain = sin(FlutterFragCoord().x * 0.21 + FlutterFragCoord().y * 0.11) * 0.006; - float moldedHighlight = smoothstep(0.072, 0.0, overflow) * 0.028; + float verticalShade = 0.88 + 0.07 * (1.0 - (FlutterFragCoord().y / uResolution.y)); + float depthShade = 1.0 - smoothstep(0.0, 0.058, overflow) * 0.34; + float grain = sin(FlutterFragCoord().x * 0.21 + FlutterFragCoord().y * 0.11) * 0.006; + float moldedHighlight = smoothstep(0.072, 0.0, overflow) * 0.028; - // Deeper arcade-style profile: tighter, scene-tinted bleed rolloff. - float bezelGlow = exp(-bezelDistance * 82.0) * mix(1.0, 0.56, cornerFactor); - float innerLip = exp(-bezelDistance * 170.0) * 0.10; - float bleedStrength = smoothstep(0.12, 0.0, overflow) * (0.78 - cornerFactor * 0.26); + // Deeper arcade-style profile: tighter, scene-tinted bleed rolloff. + float bezelGlow = exp(-bezelDistance * 82.0) * mix(1.0, 0.56, cornerFactor); + float innerLip = exp(-bezelDistance * 170.0) * 0.10; + float bleedStrength = smoothstep(0.12, 0.0, overflow) * (0.78 - cornerFactor * 0.26); + float bloomBezelBoost = 1.0 + + uBloomEnabled * smoothstep(0.16, 0.82, edgeBleedLuma) * 0.75; + float bloomLipBoost = 1.0 + + uBloomEnabled * smoothstep(0.10, 0.68, edgeBleedLuma) * 0.45; vec3 bezelColor = vec3(0.225, 0.225, 0.215) * verticalShade * depthShade + - edgeBleedColor * bezelGlow * bleedStrength * 1.12 + - edgeBleedColor * innerLip * 0.36 + + edgeBleedColor * bezelGlow * bleedStrength * 1.12 * bloomBezelBoost + + edgeBleedColor * innerLip * 0.36 * bloomLipBoost + vec3(moldedHighlight) + vec3(grain); fragColor = vec4(bezelColor, 1.0); @@ -127,5 +134,42 @@ void main() { outColor *= mix(0.62, 1.0, vignette); } + if (uBloomEnabled > 0.5) { + // CRT phosphor bloom: bright areas spread a soft luminance glow. + // Sample a three-ring cross pattern directly from the source texture so + // the spread is measured in source-texel space and stays resolution-stable. + vec2 s1 = uTexel * 3.0; + vec2 s2 = uTexel * 7.0; + vec2 s3 = uTexel * 13.0; + + vec3 glow = vec3(0.0); + // Inner ring — weight 1.0 each + glow += texture(uTexture, uv + vec2( s1.x, 0.0)).rgb; + glow += texture(uTexture, uv + vec2(-s1.x, 0.0)).rgb; + glow += texture(uTexture, uv + vec2( 0.0, s1.y)).rgb; + glow += texture(uTexture, uv + vec2( 0.0, -s1.y)).rgb; + // Mid ring — weight 0.5 each + glow += texture(uTexture, uv + vec2( s2.x, 0.0)).rgb * 0.5; + glow += texture(uTexture, uv + vec2(-s2.x, 0.0)).rgb * 0.5; + glow += texture(uTexture, uv + vec2( 0.0, s2.y)).rgb * 0.5; + glow += texture(uTexture, uv + vec2( 0.0, -s2.y)).rgb * 0.5; + // Outer ring — weight 0.25 each + glow += texture(uTexture, uv + vec2( s3.x, 0.0)).rgb * 0.25; + glow += texture(uTexture, uv + vec2(-s3.x, 0.0)).rgb * 0.25; + glow += texture(uTexture, uv + vec2( 0.0, s3.y)).rgb * 0.25; + glow += texture(uTexture, uv + vec2( 0.0, -s3.y)).rgb * 0.25; + // Normalize: 4*1.0 + 4*0.5 + 4*0.25 = 7.0 + glow /= 7.0; + + // Only bright pixels contribute — gate the bloom contribution on luma. + float glowLuma = luma(glow); + float bloomStrength = smoothstep(0.18, 0.82, glowLuma); + + // Add bloom additively then apply a gentle Reinhard-style tone-map to + // prevent over-saturation while keeping dark areas clean. + outColor = outColor + glow * bloomStrength * 0.42; + outColor = outColor / (outColor + vec3(0.75)) * 1.75; + } + fragColor = vec4(outColor, centerSample.a); }