From d63b316f1bd57aa02038240746262e92655cf49f Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 23 Mar 2026 11:00:48 +0100 Subject: [PATCH] feat: Implement fizzle fade transition effects for menus and intros, enhancing visual transitions Signed-off-by: Hans Kokx --- .../lib/src/menu/menu_manager.dart | 95 +++++++- .../lib/src/rendering/ascii_renderer.dart | 166 ++++++++++++- .../lib/src/rendering/fizzle_fade.dart | 66 +++++ .../lib/src/rendering/sixel_renderer.dart | 132 +++++++++- .../lib/src/rendering/software_renderer.dart | 122 +++++++++- .../level_state_and_pause_menu_test.dart | 40 +++ .../rendering/fizzle_fade_renderer_test.dart | 227 ++++++++++++++++++ 7 files changed, 815 insertions(+), 33 deletions(-) create mode 100644 packages/wolf_3d_dart/lib/src/rendering/fizzle_fade.dart create mode 100644 packages/wolf_3d_dart/test/rendering/fizzle_fade_renderer_test.dart diff --git a/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart b/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart index a414aab..0835c61 100644 --- a/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart +++ b/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart @@ -13,6 +13,10 @@ enum WolfMenuScreen { enum WolfIntroSlide { retailWarning, pg13, title } +enum WolfTransitionEffect { none, normalFade, fizzleFade } + +enum WolfTransitionPhase { idle, covering, revealing } + enum _WolfIntroPhase { fadeIn, hold, fadeOut } enum WolfMenuMainAction { @@ -102,9 +106,11 @@ class MenuManager { WolfMenuScreen _activeMenu = WolfMenuScreen.difficultySelect; WolfMenuScreen? _transitionTarget; + WolfTransitionEffect _transitionEffect = WolfTransitionEffect.normalFade; int _transitionElapsedMs = 0; bool _transitionSwappedMenu = false; WolfMenuScreen _introLandingMenu = WolfMenuScreen.mainMenu; + WolfTransitionEffect _introEffect = WolfTransitionEffect.normalFade; int _introSlideIndex = 0; int _introElapsedMs = 0; _WolfIntroPhase _introPhase = _WolfIntroPhase.fadeIn; @@ -182,6 +188,34 @@ class MenuManager { } } + WolfTransitionEffect get introOverlayEffect { + if (!isIntroSplashActive || _introPhase == _WolfIntroPhase.hold) { + return WolfTransitionEffect.none; + } + return _introEffect; + } + + WolfTransitionPhase get introOverlayPhase { + if (!isIntroSplashActive) { + return WolfTransitionPhase.idle; + } + switch (_introPhase) { + case _WolfIntroPhase.fadeIn: + return WolfTransitionPhase.revealing; + case _WolfIntroPhase.hold: + return WolfTransitionPhase.idle; + case _WolfIntroPhase.fadeOut: + return WolfTransitionPhase.covering; + } + } + + double get introOverlayPhaseProgress { + if (!isIntroSplashActive || _introPhase == _WolfIntroPhase.hold) { + return 0.0; + } + return (_introElapsedMs / introFadeDurationMs).clamp(0.0, 1.0); + } + /// Returns the fade alpha during transitions (0.0..1.0). double get transitionAlpha { if (!isTransitioning) { @@ -195,6 +229,35 @@ class MenuManager { return (1.0 - (fadeInElapsed / half)).clamp(0.0, 1.0); } + WolfTransitionEffect get transitionEffect { + if (!isTransitioning) { + return WolfTransitionEffect.none; + } + return _transitionEffect; + } + + WolfTransitionPhase get transitionPhase { + if (!isTransitioning) { + return WolfTransitionPhase.idle; + } + final int half = transitionDurationMs ~/ 2; + if (_transitionElapsedMs < half) { + return WolfTransitionPhase.covering; + } + return WolfTransitionPhase.revealing; + } + + double get transitionPhaseProgress { + if (!isTransitioning) { + return 0.0; + } + final int half = transitionDurationMs ~/ 2; + if (_transitionElapsedMs < half) { + return (_transitionElapsedMs / half).clamp(0.0, 1.0); + } + return ((_transitionElapsedMs - half) / half).clamp(0.0, 1.0); + } + int get selectedMainIndex => _selectedMainIndex; int get selectedGameIndex => _selectedGameIndex; @@ -272,6 +335,7 @@ class MenuManager { Difficulty? initialDifficulty, bool hasResumableGame = false, bool initialGameIsRetail = false, + WolfTransitionEffect introEffect = WolfTransitionEffect.normalFade, }) { _gameCount = gameCount; _showResumeOption = hasResumableGame; @@ -286,6 +350,7 @@ class MenuManager { _introLandingMenu = WolfMenuScreen.mainMenu; if (gameCount > 1) { _activeMenu = WolfMenuScreen.gameSelect; + _introEffect = introEffect; _introElapsedMs = 0; _introPhase = _WolfIntroPhase.fadeIn; _introSlideIndex = 0; @@ -294,9 +359,13 @@ class MenuManager { WolfIntroSlide.title, ]; } else { - _startIntroSequence(includeRetailWarning: initialGameIsRetail); + _startIntroSequence( + includeRetailWarning: initialGameIsRetail, + effect: introEffect, + ); } _transitionTarget = null; + _transitionEffect = WolfTransitionEffect.normalFade; _transitionElapsedMs = 0; _transitionSwappedMenu = false; _resetEdgeState(); @@ -306,12 +375,17 @@ class MenuManager { void beginIntroSplash({ WolfMenuScreen landingMenu = WolfMenuScreen.mainMenu, bool includeRetailWarning = false, + WolfTransitionEffect effect = WolfTransitionEffect.normalFade, }) { _introLandingMenu = landingMenu; _transitionTarget = null; + _transitionEffect = WolfTransitionEffect.normalFade; _transitionElapsedMs = 0; _transitionSwappedMenu = false; - _startIntroSequence(includeRetailWarning: includeRetailWarning); + _startIntroSequence( + includeRetailWarning: includeRetailWarning, + effect: effect, + ); _resetEdgeState(); } @@ -336,8 +410,10 @@ class MenuManager { } _activeMenu = WolfMenuScreen.mainMenu; _transitionTarget = null; + _transitionEffect = WolfTransitionEffect.normalFade; _transitionElapsedMs = 0; _transitionSwappedMenu = false; + _introEffect = WolfTransitionEffect.normalFade; _introElapsedMs = 0; _resetEdgeState(); } @@ -449,6 +525,7 @@ class MenuManager { _isSelectableChangeViewIndex, ); _transitionTarget = null; + _transitionEffect = WolfTransitionEffect.normalFade; _transitionElapsedMs = 0; _transitionSwappedMenu = false; _resetEdgeState(); @@ -464,6 +541,7 @@ class MenuManager { _isSelectableRendererOptionIndex, ); _transitionTarget = null; + _transitionEffect = WolfTransitionEffect.normalFade; _transitionElapsedMs = 0; _transitionSwappedMenu = false; _resetEdgeState(); @@ -473,11 +551,15 @@ class MenuManager { /// /// Hosts can reuse this fade timing for future pre-menu splash/image /// sequences so transitions feel consistent across the whole app. - void startTransition(WolfMenuScreen target) { + void startTransition( + WolfMenuScreen target, { + WolfTransitionEffect effect = WolfTransitionEffect.normalFade, + }) { if (_activeMenu == target) { return; } _transitionTarget = target; + _transitionEffect = effect; _transitionElapsedMs = 0; _transitionSwappedMenu = false; _resetEdgeState(); @@ -501,6 +583,7 @@ class MenuManager { } if (_transitionElapsedMs >= transitionDurationMs) { _transitionTarget = null; + _transitionEffect = WolfTransitionEffect.normalFade; _transitionElapsedMs = 0; _transitionSwappedMenu = false; } @@ -527,8 +610,12 @@ class MenuManager { _consumeEdgeState(input); } - void _startIntroSequence({required bool includeRetailWarning}) { + void _startIntroSequence({ + required bool includeRetailWarning, + required WolfTransitionEffect effect, + }) { _activeMenu = WolfMenuScreen.introSplash; + _introEffect = effect; _introSlides = includeRetailWarning ? [ WolfIntroSlide.retailWarning, 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 bcd391d..57936fb 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'package:arcane_helper_utils/arcane_helper_utils.dart'; import 'package:wolf_3d_dart/src/menu/menu_manager.dart'; +import 'package:wolf_3d_dart/src/rendering/fizzle_fade.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_engine.dart'; import 'package:wolf_3d_dart/wolf_3d_menu.dart'; @@ -130,6 +131,8 @@ class AsciiRenderer extends CliRendererBackend { late List> _screen; late List> _scenePixels; + List> _screenScratch = const >[]; + List> _scenePixelsScratch = const >[]; List? _mainMenuBandFirstColumn; String? _lastLoggedThemeName; @@ -488,7 +491,7 @@ class AsciiRenderer extends CliRendererBackend { } _drawCenteredMenuFooter(); - _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); + _applyMenuTransition(engine.menuManager, bgColor); return; } @@ -538,7 +541,7 @@ class AsciiRenderer extends CliRendererBackend { } _drawCenteredMenuFooter(); - _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); + _applyMenuTransition(engine.menuManager, bgColor); return; } @@ -597,7 +600,7 @@ class AsciiRenderer extends CliRendererBackend { ); } _drawCenteredMenuFooter(); - _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); + _applyMenuTransition(engine.menuManager, bgColor); return; } @@ -638,7 +641,7 @@ class AsciiRenderer extends CliRendererBackend { } _drawCenteredMenuFooter(); - _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); + _applyMenuTransition(engine.menuManager, bgColor); return; } @@ -742,7 +745,7 @@ class AsciiRenderer extends CliRendererBackend { } _drawCenteredMenuFooter(); - _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); + _applyMenuTransition(engine.menuManager, bgColor); return; } @@ -795,7 +798,7 @@ class AsciiRenderer extends CliRendererBackend { } _drawCenteredMenuFooter(); - _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); + _applyMenuTransition(engine.menuManager, bgColor); return; } @@ -851,7 +854,7 @@ class AsciiRenderer extends CliRendererBackend { ); } _drawCenteredMenuFooter(); - _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); + _applyMenuTransition(engine.menuManager, bgColor); return; } @@ -878,7 +881,7 @@ class AsciiRenderer extends CliRendererBackend { } _drawCenteredMenuFooter(); - _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); + _applyMenuTransition(engine.menuManager, bgColor); } String _gameTitle(GameVersion version) { @@ -932,10 +935,7 @@ class AsciiRenderer extends CliRendererBackend { _blitVgaImageAscii(image, x, y); } - _applyMenuFade( - engine.menuManager.introOverlayAlpha, - _rgbToPaletteColor(0x000000), - ); + _applyIntroTransition(engine.menuManager, _rgbToPaletteColor(0x000000)); } void _drawRetailWarningIntro( @@ -1085,6 +1085,148 @@ class AsciiRenderer extends CliRendererBackend { } } + void _applyMenuTransition(MenuManager menuManager, int coverColor) { + switch (menuManager.transitionEffect) { + case WolfTransitionEffect.none: + return; + case WolfTransitionEffect.normalFade: + _applyMenuFade(menuManager.transitionAlpha, coverColor); + return; + case WolfTransitionEffect.fizzleFade: + _applyFizzleTransition( + phase: menuManager.transitionPhase, + progress: menuManager.transitionPhaseProgress, + coverColor: coverColor, + ); + return; + } + } + + void _applyIntroTransition(MenuManager menuManager, int coverColor) { + switch (menuManager.introOverlayEffect) { + case WolfTransitionEffect.none: + return; + case WolfTransitionEffect.normalFade: + _applyMenuFade(menuManager.introOverlayAlpha, coverColor); + return; + case WolfTransitionEffect.fizzleFade: + _applyFizzleTransition( + phase: menuManager.introOverlayPhase, + progress: menuManager.introOverlayPhaseProgress, + coverColor: coverColor, + ); + return; + } + } + + void _applyFizzleTransition({ + required WolfTransitionPhase phase, + required double progress, + required int coverColor, + }) { + switch (phase) { + case WolfTransitionPhase.idle: + return; + case WolfTransitionPhase.covering: + _applyFizzleCover(progress, coverColor); + return; + case WolfTransitionPhase.revealing: + _applyFizzleReveal(progress, coverColor); + return; + } + } + + void _applyFizzleCover(double progress, int coverColor) { + final int coverCount = FizzleFade.revealCountForProgress(progress); + if (coverCount <= 0) { + return; + } + + FizzleFade.forEachCanonicalPixel(coverCount, (int x, int y) { + _fillRect320(x, y, 1, 1, coverColor); + }); + } + + void _applyFizzleReveal(double progress, int coverColor) { + final int revealCount = FizzleFade.revealCountForProgress(progress); + if (revealCount <= 0) { + if (_usesTerminalLayout) { + _fillTerminalRect(0, 0, width, _terminalPixelHeight, coverColor); + } else { + _fillRect(0, 0, width, height, ' ', coverColor); + } + return; + } + if (revealCount >= FizzleFade.canonicalPixelCount) { + return; + } + + _captureTransitionScratch(); + if (_usesTerminalLayout) { + _fillTerminalRect(0, 0, width, _terminalPixelHeight, coverColor); + } else { + _fillRect(0, 0, width, height, ' ', coverColor); + } + + FizzleFade.forEachCanonicalPixel(revealCount, (int x, int y) { + _copyCanonicalPixelFromScratch(x, y); + }); + } + + void _captureTransitionScratch() { + if (_usesTerminalLayout) { + _scenePixelsScratch = List>.generate( + _scenePixels.length, + (int y) => List.from(_scenePixels[y]), + ); + return; + } + + _screenScratch = List>.generate( + _screen.length, + (int y) => List.from(_screen[y]), + ); + } + + void _copyCanonicalPixelFromScratch(int x320, int y200) { + final double scaleX = + (_usesTerminalLayout ? projectionWidth : width) / 320.0; + final double scaleY = + (_usesTerminalLayout ? _terminalPixelHeight : height) / 200.0; + final int offsetX = _usesTerminalLayout ? projectionOffsetX : 0; + final int startX = offsetX + (x320 * scaleX).floor(); + final int endX = offsetX + ((x320 + 1) * scaleX).ceil(); + final int startY = (y200 * scaleY).floor(); + final int endY = ((y200 + 1) * scaleY).ceil(); + + if (_usesTerminalLayout) { + for (int y = startY; y < endY; y++) { + if (y < 0 || y >= _terminalPixelHeight) { + continue; + } + for (int x = startX; x < endX; x++) { + if (x < 0 || x >= width) { + continue; + } + _scenePixels[y][x] = _scenePixelsScratch[y][x]; + } + } + return; + } + + for (int y = startY; y < endY; y++) { + if (y < 0 || y >= height) { + continue; + } + for (int x = startX; x < endX; x++) { + if (x < 0 || x >= width) { + continue; + } + _screen[y][x] = _screenScratch[y][x]; + } + } + } + void _applyMenuFade(double alpha, int fadeColor) { if (alpha <= 0.0) { return; diff --git a/packages/wolf_3d_dart/lib/src/rendering/fizzle_fade.dart b/packages/wolf_3d_dart/lib/src/rendering/fizzle_fade.dart new file mode 100644 index 0000000..91f41b1 --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/rendering/fizzle_fade.dart @@ -0,0 +1,66 @@ +import 'dart:collection'; + +/// Canonical Wolf3D-style 17-bit Galois LFSR used for Fizzle Fade ordering. +/// +/// The sequence mirrors the original coordinate extraction strategy: +/// low 8 bits minus one form `y`, the next 9 bits form `x`, and out-of-bounds +/// points are skipped. For menu and intro rendering we use the original +/// canonical 320x200 coordinate space and let renderers scale that to their +/// output buffer. +abstract final class FizzleFade { + static const int canonicalWidth = 320; + static const int canonicalHeight = 200; + static const int canonicalPixelCount = canonicalWidth * canonicalHeight; + static const int seed = 1; + static const int _xorMask = 0x12000; + static final List _canonicalSequence = List.unmodifiable( + _buildSequence(width: canonicalWidth, height: canonicalHeight), + ); + + static List get canonicalSequence => _canonicalSequence; + + static int nextState(int state) { + final bool lsbSet = (state & 1) != 0; + int next = state >> 1; + if (lsbSet) { + next ^= _xorMask; + } + return next; + } + + static int xForState(int state) => (state >> 8) & 0x1FF; + + static int yForState(int state) => ((state & 0xFF) - 1) & 0xFF; + + static bool isInBounds(int state, {required int width, required int height}) { + return xForState(state) < width && yForState(state) < height; + } + + static int revealCountForProgress(double progress, {int? pixelCount}) { + final int total = pixelCount ?? canonicalPixelCount; + return (progress.clamp(0.0, 1.0) * total).round().clamp(0, total); + } + + static void forEachCanonicalPixel( + int count, + void Function(int x, int y) plot, + ) { + final int limit = count.clamp(0, canonicalPixelCount); + for (int i = 0; i < limit; i++) { + final int packed = _canonicalSequence[i]; + plot(packed & 0xFFFF, packed >> 16); + } + } + + static List _buildSequence({required int width, required int height}) { + final List points = []; + int state = seed; + do { + if (isInBounds(state, width: width, height: height)) { + points.add((yForState(state) << 16) | xForState(state)); + } + state = nextState(state); + } while (state != seed); + return UnmodifiableListView(points); + } +} diff --git a/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart b/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart index c900235..a31f97a 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart @@ -8,6 +8,7 @@ import 'dart:typed_data'; import 'package:wolf_3d_dart/src/input/cli_input.dart'; import 'package:wolf_3d_dart/src/menu/menu_manager.dart'; +import 'package:wolf_3d_dart/src/rendering/fizzle_fade.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_engine.dart'; import 'package:wolf_3d_dart/wolf_3d_menu.dart'; @@ -36,6 +37,7 @@ class SixelRenderer extends CliRendererBackend { static const int _headerHeadingY = 24; late Uint8List _screen; + Uint8List _transitionScratch = Uint8List(0); List? _mainMenuBandFirstColumn; int _offsetColumns = 0; int _offsetRows = 0; @@ -435,7 +437,7 @@ class SixelRenderer extends CliRendererBackend { scale: 1, ); } - _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); + _applyMenuTransition(engine.menuManager, bgColor); return; } @@ -470,7 +472,7 @@ class SixelRenderer extends CliRendererBackend { scale: 1, ); } - _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); + _applyMenuTransition(engine.menuManager, bgColor); return; } @@ -522,7 +524,7 @@ class SixelRenderer extends CliRendererBackend { ); } } - _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); + _applyMenuTransition(engine.menuManager, bgColor); return; } @@ -615,7 +617,7 @@ class SixelRenderer extends CliRendererBackend { scale: 1, ); } - _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); + _applyMenuTransition(engine.menuManager, bgColor); return; } @@ -662,7 +664,7 @@ class SixelRenderer extends CliRendererBackend { scale: 1, ); } - _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); + _applyMenuTransition(engine.menuManager, bgColor); return; } @@ -676,7 +678,7 @@ class SixelRenderer extends CliRendererBackend { _fillRect320(28, 70, 264, 82, panelColor); if (_useCompactMenuLayout) { _drawCompactMenu(selectedDifficultyIndex, headingIndex, panelColor); - _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); + _applyMenuTransition(engine.menuManager, bgColor); return; } @@ -717,7 +719,7 @@ class SixelRenderer extends CliRendererBackend { ); } - _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); + _applyMenuTransition(engine.menuManager, bgColor); } void _drawCustomizeMenuHeader( @@ -816,10 +818,7 @@ class SixelRenderer extends CliRendererBackend { _blitVgaImage(image, x, y); } - _applyMenuFade( - engine.menuManager.introOverlayAlpha, - _rgbToPaletteIndex(0x000000), - ); + _applyIntroTransition(engine.menuManager, _rgbToPaletteIndex(0x000000)); } void _drawRetailWarningIntro(int backgroundColor) { @@ -861,6 +860,117 @@ class SixelRenderer extends CliRendererBackend { return bestIndex; } + void _applyMenuTransition(MenuManager menuManager, int coverColor) { + switch (menuManager.transitionEffect) { + case WolfTransitionEffect.none: + return; + case WolfTransitionEffect.normalFade: + _applyMenuFade(menuManager.transitionAlpha, coverColor); + return; + case WolfTransitionEffect.fizzleFade: + _applyFizzleTransition( + phase: menuManager.transitionPhase, + progress: menuManager.transitionPhaseProgress, + coverColor: coverColor, + ); + return; + } + } + + void _applyIntroTransition(MenuManager menuManager, int coverColor) { + switch (menuManager.introOverlayEffect) { + case WolfTransitionEffect.none: + return; + case WolfTransitionEffect.normalFade: + _applyMenuFade(menuManager.introOverlayAlpha, coverColor); + return; + case WolfTransitionEffect.fizzleFade: + _applyFizzleTransition( + phase: menuManager.introOverlayPhase, + progress: menuManager.introOverlayPhaseProgress, + coverColor: coverColor, + ); + return; + } + } + + void _applyFizzleTransition({ + required WolfTransitionPhase phase, + required double progress, + required int coverColor, + }) { + switch (phase) { + case WolfTransitionPhase.idle: + return; + case WolfTransitionPhase.covering: + _applyFizzleCover(progress, coverColor); + return; + case WolfTransitionPhase.revealing: + _applyFizzleReveal(progress, coverColor); + return; + } + } + + void _applyFizzleCover(double progress, int coverColor) { + final int coverCount = FizzleFade.revealCountForProgress(progress); + if (coverCount <= 0) { + return; + } + + FizzleFade.forEachCanonicalPixel(coverCount, (int x, int y) { + _fillRect320(x, y, 1, 1, coverColor); + }); + } + + void _applyFizzleReveal(double progress, int coverColor) { + final int revealCount = FizzleFade.revealCountForProgress(progress); + if (revealCount <= 0) { + _screen.fillRange(0, _screen.length, coverColor); + return; + } + if (revealCount >= FizzleFade.canonicalPixelCount) { + return; + } + + _ensureTransitionScratch(); + _transitionScratch.setAll(0, _screen); + _screen.fillRange(0, _screen.length, coverColor); + + FizzleFade.forEachCanonicalPixel(revealCount, (int x, int y) { + _copyCanonicalPixelFromScratch(x, y); + }); + } + + void _ensureTransitionScratch() { + if (_transitionScratch.length == _screen.length) { + return; + } + _transitionScratch = Uint8List(_screen.length); + } + + void _copyCanonicalPixelFromScratch(int x320, int y200) { + final double scaleX = width / 320.0; + final double scaleY = height / 200.0; + final int startX = (x320 * scaleX).floor(); + final int endX = ((x320 + 1) * scaleX).ceil(); + final int startY = (y200 * scaleY).floor(); + final int endY = ((y200 + 1) * scaleY).ceil(); + + for (int y = startY; y < endY; y++) { + if (y < 0 || y >= height) { + continue; + } + final int rowOffset = y * width; + for (int x = startX; x < endX; x++) { + if (x < 0 || x >= width) { + continue; + } + final int index = rowOffset + x; + _screen[index] = _transitionScratch[index]; + } + } + } + void _applyMenuFade(double alpha, int bgColor) { if (alpha <= 0.0) { return; 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 599deb3..6925aff 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart @@ -1,6 +1,8 @@ import 'dart:math' as math; +import 'dart:typed_data'; import 'package:wolf_3d_dart/src/menu/menu_manager.dart'; +import 'package:wolf_3d_dart/src/rendering/fizzle_fade.dart'; import 'package:wolf_3d_dart/src/rendering/menu_font.dart'; import 'package:wolf_3d_dart/src/rendering/renderer_backend.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; @@ -18,6 +20,7 @@ class SoftwareRenderer extends RendererBackend { static const int _headerHeadingY = 24; late FrameBuffer _buffer; + Uint32List _transitionScratch = Uint32List(0); List? _mainMenuBandFirstColumn; @override @@ -233,7 +236,7 @@ class SoftwareRenderer extends RendererBackend { break; } - _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); + _applyMenuTransition(engine.menuManager, bgColor); } void _drawIntroSplash(WolfEngine engine, WolfClassicMenuArt art) { @@ -275,10 +278,7 @@ class SoftwareRenderer extends RendererBackend { } } - _applyMenuFade( - engine.menuManager.introOverlayAlpha, - _rgbToFrameColor(0x000000), - ); + _applyIntroTransition(engine.menuManager, _rgbToFrameColor(0x000000)); } void _drawRetailWarningIntro(int backgroundColor) { @@ -550,7 +550,8 @@ class SoftwareRenderer extends RendererBackend { ? 0 : ((optionEntries.length - 1) * optionsRowStep) + 10; final int optionsRowStart = - optionsPanelY + ((optionsPanelH - optionsRowsHeight) ~/ 2).clamp(0, 200); + 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; @@ -937,6 +938,40 @@ class SoftwareRenderer extends RendererBackend { } } + void _applyMenuTransition(MenuManager menuManager, int coverColor) { + switch (menuManager.transitionEffect) { + case WolfTransitionEffect.none: + return; + case WolfTransitionEffect.normalFade: + _applyMenuFade(menuManager.transitionAlpha, coverColor); + return; + case WolfTransitionEffect.fizzleFade: + _applyFizzleTransition( + phase: menuManager.transitionPhase, + progress: menuManager.transitionPhaseProgress, + coverColor: coverColor, + ); + return; + } + } + + void _applyIntroTransition(MenuManager menuManager, int coverColor) { + switch (menuManager.introOverlayEffect) { + case WolfTransitionEffect.none: + return; + case WolfTransitionEffect.normalFade: + _applyMenuFade(menuManager.introOverlayAlpha, coverColor); + return; + case WolfTransitionEffect.fizzleFade: + _applyFizzleTransition( + phase: menuManager.introOverlayPhase, + progress: menuManager.introOverlayPhaseProgress, + coverColor: coverColor, + ); + return; + } + } + void _applyMenuFade(double alpha, int fadeColor) { if (alpha <= 0.0) { return; @@ -959,6 +994,81 @@ class SoftwareRenderer extends RendererBackend { } } + void _applyFizzleTransition({ + required WolfTransitionPhase phase, + required double progress, + required int coverColor, + }) { + switch (phase) { + case WolfTransitionPhase.idle: + return; + case WolfTransitionPhase.covering: + _applyFizzleCover(progress, coverColor); + return; + case WolfTransitionPhase.revealing: + _applyFizzleReveal(progress, coverColor); + return; + } + } + + void _applyFizzleCover(double progress, int coverColor) { + final int coverCount = FizzleFade.revealCountForProgress(progress); + if (coverCount <= 0) { + return; + } + + FizzleFade.forEachCanonicalPixel(coverCount, (int x, int y) { + _fillCanonicalRect(x, y, 1, 1, coverColor); + }); + } + + void _applyFizzleReveal(double progress, int coverColor) { + final int revealCount = FizzleFade.revealCountForProgress(progress); + if (revealCount <= 0) { + _buffer.pixels.fillRange(0, _buffer.pixels.length, coverColor); + return; + } + if (revealCount >= FizzleFade.canonicalPixelCount) { + return; + } + + _ensureTransitionScratch(); + _transitionScratch.setAll(0, _buffer.pixels); + _buffer.pixels.fillRange(0, _buffer.pixels.length, coverColor); + + FizzleFade.forEachCanonicalPixel(revealCount, (int x, int y) { + _copyCanonicalPixelFromScratch(x, y); + }); + } + + void _ensureTransitionScratch() { + if (_transitionScratch.length == _buffer.pixels.length) { + return; + } + _transitionScratch = Uint32List(_buffer.pixels.length); + } + + void _copyCanonicalPixelFromScratch(int startX320, int startY200) { + final int startX = (startX320 * _uiScaleX).floor(); + final int endX = ((startX320 + 1) * _uiScaleX).ceil(); + final int startY = (startY200 * _uiScaleY).floor(); + final int endY = ((startY200 + 1) * _uiScaleY).ceil(); + + for (int y = startY; y < endY; y++) { + if (y < 0 || y >= height) { + continue; + } + final int rowStart = y * width; + for (int x = startX; x < endX; x++) { + if (x < 0 || x >= width) { + continue; + } + final int index = rowStart + x; + _buffer.pixels[index] = _transitionScratch[index]; + } + } + } + /// Converts an `RRGGBB` menu color into the framebuffer's packed channel /// order (`0xAABBGGRR`) used throughout this renderer. int _rgbToFrameColor(int rgb) { diff --git a/packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.dart b/packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.dart index 7fcf395..0b80111 100644 --- a/packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.dart +++ b/packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.dart @@ -223,6 +223,46 @@ void main() { expect(manager.selectedMainIndex, 0); }); + test('menu transition defaults to normal fade and can opt into fizzle', () { + final manager = MenuManager(); + + manager.showMainMenu(hasResumableGame: false); + manager.startTransition(WolfMenuScreen.difficultySelect); + + expect(manager.transitionEffect, WolfTransitionEffect.normalFade); + expect(manager.transitionPhase, WolfTransitionPhase.covering); + expect(manager.transitionPhaseProgress, 0.0); + expect(manager.activeMenu, WolfMenuScreen.mainMenu); + + manager.tickTransition(MenuManager.transitionDurationMs ~/ 4); + expect(manager.transitionPhase, WolfTransitionPhase.covering); + expect(manager.transitionPhaseProgress, closeTo(0.5, 0.001)); + expect(manager.activeMenu, WolfMenuScreen.mainMenu); + + manager.tickTransition(MenuManager.transitionDurationMs ~/ 4); + expect(manager.transitionPhase, WolfTransitionPhase.revealing); + expect(manager.transitionPhaseProgress, 0.0); + expect(manager.activeMenu, WolfMenuScreen.difficultySelect); + + manager.tickTransition(MenuManager.transitionDurationMs ~/ 4); + expect(manager.transitionPhase, WolfTransitionPhase.revealing); + expect(manager.transitionPhaseProgress, closeTo(0.5, 0.001)); + + manager.tickTransition(MenuManager.transitionDurationMs ~/ 4); + expect(manager.isTransitioning, isFalse); + expect(manager.transitionEffect, WolfTransitionEffect.none); + expect(manager.transitionPhase, WolfTransitionPhase.idle); + expect(manager.transitionPhaseProgress, 0.0); + + manager.startTransition( + WolfMenuScreen.mainMenu, + effect: WolfTransitionEffect.fizzleFade, + ); + + expect(manager.transitionEffect, WolfTransitionEffect.fizzleFade); + expect(manager.transitionPhase, WolfTransitionPhase.covering); + }); + test('quit selection triggers dedicated quit callback', () { final input = _TestInput(); int quitCalls = 0; diff --git a/packages/wolf_3d_dart/test/rendering/fizzle_fade_renderer_test.dart b/packages/wolf_3d_dart/test/rendering/fizzle_fade_renderer_test.dart new file mode 100644 index 0000000..fef5386 --- /dev/null +++ b/packages/wolf_3d_dart/test/rendering/fizzle_fade_renderer_test.dart @@ -0,0 +1,227 @@ +import 'dart:typed_data'; + +import 'package:test/test.dart'; +import 'package:wolf_3d_dart/src/menu/menu_manager.dart'; +import 'package:wolf_3d_dart/src/rendering/fizzle_fade.dart'; +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; +import 'package:wolf_3d_dart/wolf_3d_engine.dart'; +import 'package:wolf_3d_dart/wolf_3d_input.dart'; +import 'package:wolf_3d_dart/wolf_3d_renderer.dart'; + +void main() { + group('Fizzle fade sequence', () { + test('covers every canonical menu pixel exactly once', () { + final sequence = FizzleFade.canonicalSequence; + + expect(sequence.length, FizzleFade.canonicalPixelCount); + expect(sequence.toSet().length, FizzleFade.canonicalPixelCount); + expect(sequence.first, 0); + expect(FizzleFade.nextState(FizzleFade.seed), 0x12000); + }); + }); + + group('Software renderer transitions', () { + test( + 'default intro fade-in starts fully black and reveals by hold phase', + () { + final engine = _buildEngine(frameBuffer: FrameBuffer(320, 200)); + final renderer = SoftwareRenderer(); + final int black = _rgbToFrameColor(0x000000); + + engine.init(); + + final FrameBuffer startFrame = renderer.render(engine); + expect(startFrame.pixels.every((pixel) => pixel == black), isTrue); + + engine.menuManager.tickTransition(MenuManager.introFadeDurationMs); + + final FrameBuffer revealedFrame = renderer.render(engine); + expect(revealedFrame.pixels.any((pixel) => pixel != black), isTrue); + }, + ); + + test( + 'fizzle menu transition covers at midpoint and reveals target menu on completion', + () { + final engine = _buildEngine(frameBuffer: FrameBuffer(320, 200)); + final baselineEngine = _buildEngine(frameBuffer: FrameBuffer(320, 200)); + final difficultyEngine = _buildEngine( + frameBuffer: FrameBuffer(320, 200), + ); + final renderer = SoftwareRenderer(); + final int bgColor = _rgbToFrameColor( + engine.menuManager.menuBackgroundRgb, + ); + + engine.init(); + baselineEngine.init(); + difficultyEngine.init(); + + engine.menuManager.showMainMenu(hasResumableGame: false); + baselineEngine.menuManager.showMainMenu(hasResumableGame: false); + difficultyEngine.menuManager.beginDifficultySelection(); + + final List mainMenuFrame = List.from( + renderer.render(baselineEngine).pixels, + ); + + engine.menuManager.startTransition( + WolfMenuScreen.difficultySelect, + effect: WolfTransitionEffect.fizzleFade, + ); + engine.menuManager.tickTransition( + MenuManager.transitionDurationMs ~/ 4, + ); + final List partialFrame = List.from( + renderer.render(engine).pixels, + ); + + expect( + _countDifferentPixels(partialFrame, mainMenuFrame), + greaterThan(0), + ); + + engine.menuManager.tickTransition( + MenuManager.transitionDurationMs ~/ 4, + ); + final List midpointFrame = List.from( + renderer.render(engine).pixels, + ); + + expect(midpointFrame.every((pixel) => pixel == bgColor), isTrue); + + engine.menuManager.tickTransition( + MenuManager.transitionDurationMs ~/ 2, + ); + final List completedFrame = List.from( + renderer.render(engine).pixels, + ); + final List expectedDifficultyFrame = List.from( + renderer.render(difficultyEngine).pixels, + ); + + expect(completedFrame, orderedEquals(expectedDifficultyFrame)); + }, + ); + }); +} + +WolfEngine _buildEngine({required FrameBuffer frameBuffer}) { + return WolfEngine( + data: _buildTestData(), + difficulty: null, + startingEpisode: 0, + frameBuffer: frameBuffer, + input: _TestInput(), + engineAudio: _SilentAudio(), + onGameWon: () {}, + ); +} + +WolfensteinData _buildTestData() { + final wallGrid = _buildGrid(); + final objectGrid = _buildGrid(); + + _fillBoundaries(wallGrid, 2); + objectGrid[2][2] = MapObject.playerEast; + + return WolfensteinData( + version: GameVersion.retail, + dataVersion: DataVersion.unknown, + registry: RetailAssetRegistry(), + walls: [ + _solidSprite(1), + _solidSprite(1), + _solidSprite(2), + _solidSprite(2), + ], + sprites: List.generate(436, (_) => _solidSprite(255)), + sounds: List.generate(200, (_) => PcmSound(Uint8List(1))), + adLibSounds: const [], + music: const [], + vgaImages: const [], + episodes: [ + Episode( + name: 'Episode 1', + levels: [ + WolfLevel( + name: 'Level 1', + wallGrid: wallGrid, + areaGrid: List.generate(64, (_) => List.filled(64, -1)), + objectGrid: objectGrid, + music: Music.level01, + ), + ], + ), + ], + ); +} + +class _TestInput extends Wolf3dInput { + @override + void update() {} +} + +class _SilentAudio implements EngineAudio { + @override + WolfensteinData? activeGame; + + @override + Future debugSoundTest() async {} + + @override + Future init() async {} + + @override + void playLevelMusic(Music music) {} + + @override + void playMenuMusic() {} + + @override + void playSoundEffect(SoundEffect effect) {} + + @override + void playSoundEffectId(int sfxId) {} + + @override + void stopMusic() {} + + @override + Future stopAllAudio() async {} + + @override + void dispose() {} +} + +int _countDifferentPixels(List a, List b) { + int count = 0; + for (int i = 0; i < a.length; i++) { + if (a[i] != b[i]) { + count++; + } + } + return count; +} + +int _rgbToFrameColor(int rgb) { + final int r = (rgb >> 16) & 0xFF; + final int g = (rgb >> 8) & 0xFF; + final int b = rgb & 0xFF; + return 0xFF000000 | (b << 16) | (g << 8) | r; +} + +SpriteMap _buildGrid() => List.generate(64, (_) => List.filled(64, 0)); + +void _fillBoundaries(SpriteMap grid, int wallId) { + for (int i = 0; i < 64; i++) { + grid[0][i] = wallId; + grid[63][i] = wallId; + grid[i][0] = wallId; + grid[i][63] = wallId; + } +} + +Sprite _solidSprite(int colorIndex) { + return Sprite(Uint8List.fromList(List.filled(64 * 64, colorIndex))); +}