feat: Implement fizzle fade transition effects for menus and intros, enhancing visual transitions

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-23 11:00:48 +01:00
parent a84c677845
commit d63b316f1b
7 changed files with 815 additions and 33 deletions
@@ -13,6 +13,10 @@ enum WolfMenuScreen {
enum WolfIntroSlide { retailWarning, pg13, title } enum WolfIntroSlide { retailWarning, pg13, title }
enum WolfTransitionEffect { none, normalFade, fizzleFade }
enum WolfTransitionPhase { idle, covering, revealing }
enum _WolfIntroPhase { fadeIn, hold, fadeOut } enum _WolfIntroPhase { fadeIn, hold, fadeOut }
enum WolfMenuMainAction { enum WolfMenuMainAction {
@@ -102,9 +106,11 @@ class MenuManager {
WolfMenuScreen _activeMenu = WolfMenuScreen.difficultySelect; WolfMenuScreen _activeMenu = WolfMenuScreen.difficultySelect;
WolfMenuScreen? _transitionTarget; WolfMenuScreen? _transitionTarget;
WolfTransitionEffect _transitionEffect = WolfTransitionEffect.normalFade;
int _transitionElapsedMs = 0; int _transitionElapsedMs = 0;
bool _transitionSwappedMenu = false; bool _transitionSwappedMenu = false;
WolfMenuScreen _introLandingMenu = WolfMenuScreen.mainMenu; WolfMenuScreen _introLandingMenu = WolfMenuScreen.mainMenu;
WolfTransitionEffect _introEffect = WolfTransitionEffect.normalFade;
int _introSlideIndex = 0; int _introSlideIndex = 0;
int _introElapsedMs = 0; int _introElapsedMs = 0;
_WolfIntroPhase _introPhase = _WolfIntroPhase.fadeIn; _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). /// Returns the fade alpha during transitions (0.0..1.0).
double get transitionAlpha { double get transitionAlpha {
if (!isTransitioning) { if (!isTransitioning) {
@@ -195,6 +229,35 @@ class MenuManager {
return (1.0 - (fadeInElapsed / half)).clamp(0.0, 1.0); 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 selectedMainIndex => _selectedMainIndex;
int get selectedGameIndex => _selectedGameIndex; int get selectedGameIndex => _selectedGameIndex;
@@ -272,6 +335,7 @@ class MenuManager {
Difficulty? initialDifficulty, Difficulty? initialDifficulty,
bool hasResumableGame = false, bool hasResumableGame = false,
bool initialGameIsRetail = false, bool initialGameIsRetail = false,
WolfTransitionEffect introEffect = WolfTransitionEffect.normalFade,
}) { }) {
_gameCount = gameCount; _gameCount = gameCount;
_showResumeOption = hasResumableGame; _showResumeOption = hasResumableGame;
@@ -286,6 +350,7 @@ class MenuManager {
_introLandingMenu = WolfMenuScreen.mainMenu; _introLandingMenu = WolfMenuScreen.mainMenu;
if (gameCount > 1) { if (gameCount > 1) {
_activeMenu = WolfMenuScreen.gameSelect; _activeMenu = WolfMenuScreen.gameSelect;
_introEffect = introEffect;
_introElapsedMs = 0; _introElapsedMs = 0;
_introPhase = _WolfIntroPhase.fadeIn; _introPhase = _WolfIntroPhase.fadeIn;
_introSlideIndex = 0; _introSlideIndex = 0;
@@ -294,9 +359,13 @@ class MenuManager {
WolfIntroSlide.title, WolfIntroSlide.title,
]; ];
} else { } else {
_startIntroSequence(includeRetailWarning: initialGameIsRetail); _startIntroSequence(
includeRetailWarning: initialGameIsRetail,
effect: introEffect,
);
} }
_transitionTarget = null; _transitionTarget = null;
_transitionEffect = WolfTransitionEffect.normalFade;
_transitionElapsedMs = 0; _transitionElapsedMs = 0;
_transitionSwappedMenu = false; _transitionSwappedMenu = false;
_resetEdgeState(); _resetEdgeState();
@@ -306,12 +375,17 @@ class MenuManager {
void beginIntroSplash({ void beginIntroSplash({
WolfMenuScreen landingMenu = WolfMenuScreen.mainMenu, WolfMenuScreen landingMenu = WolfMenuScreen.mainMenu,
bool includeRetailWarning = false, bool includeRetailWarning = false,
WolfTransitionEffect effect = WolfTransitionEffect.normalFade,
}) { }) {
_introLandingMenu = landingMenu; _introLandingMenu = landingMenu;
_transitionTarget = null; _transitionTarget = null;
_transitionEffect = WolfTransitionEffect.normalFade;
_transitionElapsedMs = 0; _transitionElapsedMs = 0;
_transitionSwappedMenu = false; _transitionSwappedMenu = false;
_startIntroSequence(includeRetailWarning: includeRetailWarning); _startIntroSequence(
includeRetailWarning: includeRetailWarning,
effect: effect,
);
_resetEdgeState(); _resetEdgeState();
} }
@@ -336,8 +410,10 @@ class MenuManager {
} }
_activeMenu = WolfMenuScreen.mainMenu; _activeMenu = WolfMenuScreen.mainMenu;
_transitionTarget = null; _transitionTarget = null;
_transitionEffect = WolfTransitionEffect.normalFade;
_transitionElapsedMs = 0; _transitionElapsedMs = 0;
_transitionSwappedMenu = false; _transitionSwappedMenu = false;
_introEffect = WolfTransitionEffect.normalFade;
_introElapsedMs = 0; _introElapsedMs = 0;
_resetEdgeState(); _resetEdgeState();
} }
@@ -449,6 +525,7 @@ class MenuManager {
_isSelectableChangeViewIndex, _isSelectableChangeViewIndex,
); );
_transitionTarget = null; _transitionTarget = null;
_transitionEffect = WolfTransitionEffect.normalFade;
_transitionElapsedMs = 0; _transitionElapsedMs = 0;
_transitionSwappedMenu = false; _transitionSwappedMenu = false;
_resetEdgeState(); _resetEdgeState();
@@ -464,6 +541,7 @@ class MenuManager {
_isSelectableRendererOptionIndex, _isSelectableRendererOptionIndex,
); );
_transitionTarget = null; _transitionTarget = null;
_transitionEffect = WolfTransitionEffect.normalFade;
_transitionElapsedMs = 0; _transitionElapsedMs = 0;
_transitionSwappedMenu = false; _transitionSwappedMenu = false;
_resetEdgeState(); _resetEdgeState();
@@ -473,11 +551,15 @@ class MenuManager {
/// ///
/// Hosts can reuse this fade timing for future pre-menu splash/image /// Hosts can reuse this fade timing for future pre-menu splash/image
/// sequences so transitions feel consistent across the whole app. /// sequences so transitions feel consistent across the whole app.
void startTransition(WolfMenuScreen target) { void startTransition(
WolfMenuScreen target, {
WolfTransitionEffect effect = WolfTransitionEffect.normalFade,
}) {
if (_activeMenu == target) { if (_activeMenu == target) {
return; return;
} }
_transitionTarget = target; _transitionTarget = target;
_transitionEffect = effect;
_transitionElapsedMs = 0; _transitionElapsedMs = 0;
_transitionSwappedMenu = false; _transitionSwappedMenu = false;
_resetEdgeState(); _resetEdgeState();
@@ -501,6 +583,7 @@ class MenuManager {
} }
if (_transitionElapsedMs >= transitionDurationMs) { if (_transitionElapsedMs >= transitionDurationMs) {
_transitionTarget = null; _transitionTarget = null;
_transitionEffect = WolfTransitionEffect.normalFade;
_transitionElapsedMs = 0; _transitionElapsedMs = 0;
_transitionSwappedMenu = false; _transitionSwappedMenu = false;
} }
@@ -527,8 +610,12 @@ class MenuManager {
_consumeEdgeState(input); _consumeEdgeState(input);
} }
void _startIntroSequence({required bool includeRetailWarning}) { void _startIntroSequence({
required bool includeRetailWarning,
required WolfTransitionEffect effect,
}) {
_activeMenu = WolfMenuScreen.introSplash; _activeMenu = WolfMenuScreen.introSplash;
_introEffect = effect;
_introSlides = includeRetailWarning _introSlides = includeRetailWarning
? <WolfIntroSlide>[ ? <WolfIntroSlide>[
WolfIntroSlide.retailWarning, WolfIntroSlide.retailWarning,
@@ -3,6 +3,7 @@ import 'dart:math' as math;
import 'package:arcane_helper_utils/arcane_helper_utils.dart'; 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/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_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart'; import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_menu.dart'; import 'package:wolf_3d_dart/wolf_3d_menu.dart';
@@ -130,6 +131,8 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
late List<List<ColoredChar>> _screen; late List<List<ColoredChar>> _screen;
late List<List<int>> _scenePixels; late List<List<int>> _scenePixels;
List<List<ColoredChar>> _screenScratch = const <List<ColoredChar>>[];
List<List<int>> _scenePixelsScratch = const <List<int>>[];
List<int>? _mainMenuBandFirstColumn; List<int>? _mainMenuBandFirstColumn;
String? _lastLoggedThemeName; String? _lastLoggedThemeName;
@@ -488,7 +491,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
} }
_drawCenteredMenuFooter(); _drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor); _applyMenuTransition(engine.menuManager, bgColor);
return; return;
} }
@@ -538,7 +541,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
} }
_drawCenteredMenuFooter(); _drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor); _applyMenuTransition(engine.menuManager, bgColor);
return; return;
} }
@@ -597,7 +600,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
); );
} }
_drawCenteredMenuFooter(); _drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor); _applyMenuTransition(engine.menuManager, bgColor);
return; return;
} }
@@ -638,7 +641,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
} }
_drawCenteredMenuFooter(); _drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor); _applyMenuTransition(engine.menuManager, bgColor);
return; return;
} }
@@ -742,7 +745,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
} }
_drawCenteredMenuFooter(); _drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor); _applyMenuTransition(engine.menuManager, bgColor);
return; return;
} }
@@ -795,7 +798,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
} }
_drawCenteredMenuFooter(); _drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor); _applyMenuTransition(engine.menuManager, bgColor);
return; return;
} }
@@ -851,7 +854,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
); );
} }
_drawCenteredMenuFooter(); _drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor); _applyMenuTransition(engine.menuManager, bgColor);
return; return;
} }
@@ -878,7 +881,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
} }
_drawCenteredMenuFooter(); _drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor); _applyMenuTransition(engine.menuManager, bgColor);
} }
String _gameTitle(GameVersion version) { String _gameTitle(GameVersion version) {
@@ -932,10 +935,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
_blitVgaImageAscii(image, x, y); _blitVgaImageAscii(image, x, y);
} }
_applyMenuFade( _applyIntroTransition(engine.menuManager, _rgbToPaletteColor(0x000000));
engine.menuManager.introOverlayAlpha,
_rgbToPaletteColor(0x000000),
);
} }
void _drawRetailWarningIntro( void _drawRetailWarningIntro(
@@ -1085,6 +1085,148 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
} }
} }
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<List<int>>.generate(
_scenePixels.length,
(int y) => List<int>.from(_scenePixels[y]),
);
return;
}
_screenScratch = List<List<ColoredChar>>.generate(
_screen.length,
(int y) => List<ColoredChar>.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) { void _applyMenuFade(double alpha, int fadeColor) {
if (alpha <= 0.0) { if (alpha <= 0.0) {
return; return;
@@ -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<int> _canonicalSequence = List<int>.unmodifiable(
_buildSequence(width: canonicalWidth, height: canonicalHeight),
);
static List<int> 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<int> _buildSequence({required int width, required int height}) {
final List<int> points = <int>[];
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<int>(points);
}
}
@@ -8,6 +8,7 @@ import 'dart:typed_data';
import 'package:wolf_3d_dart/src/input/cli_input.dart'; 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/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_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart'; import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_menu.dart'; import 'package:wolf_3d_dart/wolf_3d_menu.dart';
@@ -36,6 +37,7 @@ class SixelRenderer extends CliRendererBackend<String> {
static const int _headerHeadingY = 24; static const int _headerHeadingY = 24;
late Uint8List _screen; late Uint8List _screen;
Uint8List _transitionScratch = Uint8List(0);
List<int>? _mainMenuBandFirstColumn; List<int>? _mainMenuBandFirstColumn;
int _offsetColumns = 0; int _offsetColumns = 0;
int _offsetRows = 0; int _offsetRows = 0;
@@ -435,7 +437,7 @@ class SixelRenderer extends CliRendererBackend<String> {
scale: 1, scale: 1,
); );
} }
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor); _applyMenuTransition(engine.menuManager, bgColor);
return; return;
} }
@@ -470,7 +472,7 @@ class SixelRenderer extends CliRendererBackend<String> {
scale: 1, scale: 1,
); );
} }
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor); _applyMenuTransition(engine.menuManager, bgColor);
return; return;
} }
@@ -522,7 +524,7 @@ class SixelRenderer extends CliRendererBackend<String> {
); );
} }
} }
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor); _applyMenuTransition(engine.menuManager, bgColor);
return; return;
} }
@@ -615,7 +617,7 @@ class SixelRenderer extends CliRendererBackend<String> {
scale: 1, scale: 1,
); );
} }
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor); _applyMenuTransition(engine.menuManager, bgColor);
return; return;
} }
@@ -662,7 +664,7 @@ class SixelRenderer extends CliRendererBackend<String> {
scale: 1, scale: 1,
); );
} }
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor); _applyMenuTransition(engine.menuManager, bgColor);
return; return;
} }
@@ -676,7 +678,7 @@ class SixelRenderer extends CliRendererBackend<String> {
_fillRect320(28, 70, 264, 82, panelColor); _fillRect320(28, 70, 264, 82, panelColor);
if (_useCompactMenuLayout) { if (_useCompactMenuLayout) {
_drawCompactMenu(selectedDifficultyIndex, headingIndex, panelColor); _drawCompactMenu(selectedDifficultyIndex, headingIndex, panelColor);
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor); _applyMenuTransition(engine.menuManager, bgColor);
return; return;
} }
@@ -717,7 +719,7 @@ class SixelRenderer extends CliRendererBackend<String> {
); );
} }
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor); _applyMenuTransition(engine.menuManager, bgColor);
} }
void _drawCustomizeMenuHeader( void _drawCustomizeMenuHeader(
@@ -816,10 +818,7 @@ class SixelRenderer extends CliRendererBackend<String> {
_blitVgaImage(image, x, y); _blitVgaImage(image, x, y);
} }
_applyMenuFade( _applyIntroTransition(engine.menuManager, _rgbToPaletteIndex(0x000000));
engine.menuManager.introOverlayAlpha,
_rgbToPaletteIndex(0x000000),
);
} }
void _drawRetailWarningIntro(int backgroundColor) { void _drawRetailWarningIntro(int backgroundColor) {
@@ -861,6 +860,117 @@ class SixelRenderer extends CliRendererBackend<String> {
return bestIndex; 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) { void _applyMenuFade(double alpha, int bgColor) {
if (alpha <= 0.0) { if (alpha <= 0.0) {
return; return;
@@ -1,6 +1,8 @@
import 'dart:math' as math; 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/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/menu_font.dart';
import 'package:wolf_3d_dart/src/rendering/renderer_backend.dart'; import 'package:wolf_3d_dart/src/rendering/renderer_backend.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
@@ -18,6 +20,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
static const int _headerHeadingY = 24; static const int _headerHeadingY = 24;
late FrameBuffer _buffer; late FrameBuffer _buffer;
Uint32List _transitionScratch = Uint32List(0);
List<int>? _mainMenuBandFirstColumn; List<int>? _mainMenuBandFirstColumn;
@override @override
@@ -233,7 +236,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
break; break;
} }
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor); _applyMenuTransition(engine.menuManager, bgColor);
} }
void _drawIntroSplash(WolfEngine engine, WolfClassicMenuArt art) { void _drawIntroSplash(WolfEngine engine, WolfClassicMenuArt art) {
@@ -275,10 +278,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
} }
} }
_applyMenuFade( _applyIntroTransition(engine.menuManager, _rgbToFrameColor(0x000000));
engine.menuManager.introOverlayAlpha,
_rgbToFrameColor(0x000000),
);
} }
void _drawRetailWarningIntro(int backgroundColor) { void _drawRetailWarningIntro(int backgroundColor) {
@@ -550,7 +550,8 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
? 0 ? 0
: ((optionEntries.length - 1) * optionsRowStep) + 10; : ((optionEntries.length - 1) * optionsRowStep) + 10;
final int optionsRowStart = 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++) { for (int i = 0; i < optionEntries.length; i++) {
final int optionIndex = modeCount + i; final int optionIndex = modeCount + i;
final bool isSelected = optionIndex == selectedIndex; final bool isSelected = optionIndex == selectedIndex;
@@ -937,6 +938,40 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
} }
} }
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) { void _applyMenuFade(double alpha, int fadeColor) {
if (alpha <= 0.0) { if (alpha <= 0.0) {
return; return;
@@ -959,6 +994,81 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
} }
} }
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 /// Converts an `RRGGBB` menu color into the framebuffer's packed channel
/// order (`0xAABBGGRR`) used throughout this renderer. /// order (`0xAABBGGRR`) used throughout this renderer.
int _rgbToFrameColor(int rgb) { int _rgbToFrameColor(int rgb) {
@@ -223,6 +223,46 @@ void main() {
expect(manager.selectedMainIndex, 0); 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', () { test('quit selection triggers dedicated quit callback', () {
final input = _TestInput(); final input = _TestInput();
int quitCalls = 0; int quitCalls = 0;
@@ -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<int> mainMenuFrame = List<int>.from(
renderer.render(baselineEngine).pixels,
);
engine.menuManager.startTransition(
WolfMenuScreen.difficultySelect,
effect: WolfTransitionEffect.fizzleFade,
);
engine.menuManager.tickTransition(
MenuManager.transitionDurationMs ~/ 4,
);
final List<int> partialFrame = List<int>.from(
renderer.render(engine).pixels,
);
expect(
_countDifferentPixels(partialFrame, mainMenuFrame),
greaterThan(0),
);
engine.menuManager.tickTransition(
MenuManager.transitionDurationMs ~/ 4,
);
final List<int> midpointFrame = List<int>.from(
renderer.render(engine).pixels,
);
expect(midpointFrame.every((pixel) => pixel == bgColor), isTrue);
engine.menuManager.tickTransition(
MenuManager.transitionDurationMs ~/ 2,
);
final List<int> completedFrame = List<int>.from(
renderer.render(engine).pixels,
);
final List<int> expectedDifficultyFrame = List<int>.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<void> debugSoundTest() async {}
@override
Future<void> 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<void> stopAllAudio() async {}
@override
void dispose() {}
}
int _countDifferentPixels(List<int> a, List<int> 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)));
}