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
@@ -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<dynamic> {
late List<List<ColoredChar>> _screen;
late List<List<int>> _scenePixels;
List<List<ColoredChar>> _screenScratch = const <List<ColoredChar>>[];
List<List<int>> _scenePixelsScratch = const <List<int>>[];
List<int>? _mainMenuBandFirstColumn;
String? _lastLoggedThemeName;
@@ -488,7 +491,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
}
_drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
_applyMenuTransition(engine.menuManager, bgColor);
return;
}
@@ -538,7 +541,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
}
_drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
_applyMenuTransition(engine.menuManager, bgColor);
return;
}
@@ -597,7 +600,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
);
}
_drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
_applyMenuTransition(engine.menuManager, bgColor);
return;
}
@@ -638,7 +641,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
}
_drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
_applyMenuTransition(engine.menuManager, bgColor);
return;
}
@@ -742,7 +745,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
}
_drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
_applyMenuTransition(engine.menuManager, bgColor);
return;
}
@@ -795,7 +798,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
}
_drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
_applyMenuTransition(engine.menuManager, bgColor);
return;
}
@@ -851,7 +854,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
);
}
_drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
_applyMenuTransition(engine.menuManager, bgColor);
return;
}
@@ -878,7 +881,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
}
_drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
_applyMenuTransition(engine.menuManager, bgColor);
}
String _gameTitle(GameVersion version) {
@@ -932,10 +935,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
_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<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) {
if (alpha <= 0.0) {
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/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<String> {
static const int _headerHeadingY = 24;
late Uint8List _screen;
Uint8List _transitionScratch = Uint8List(0);
List<int>? _mainMenuBandFirstColumn;
int _offsetColumns = 0;
int _offsetRows = 0;
@@ -435,7 +437,7 @@ class SixelRenderer extends CliRendererBackend<String> {
scale: 1,
);
}
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
_applyMenuTransition(engine.menuManager, bgColor);
return;
}
@@ -470,7 +472,7 @@ class SixelRenderer extends CliRendererBackend<String> {
scale: 1,
);
}
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
_applyMenuTransition(engine.menuManager, bgColor);
return;
}
@@ -522,7 +524,7 @@ class SixelRenderer extends CliRendererBackend<String> {
);
}
}
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
_applyMenuTransition(engine.menuManager, bgColor);
return;
}
@@ -615,7 +617,7 @@ class SixelRenderer extends CliRendererBackend<String> {
scale: 1,
);
}
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
_applyMenuTransition(engine.menuManager, bgColor);
return;
}
@@ -662,7 +664,7 @@ class SixelRenderer extends CliRendererBackend<String> {
scale: 1,
);
}
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
_applyMenuTransition(engine.menuManager, bgColor);
return;
}
@@ -676,7 +678,7 @@ class SixelRenderer extends CliRendererBackend<String> {
_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<String> {
);
}
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
_applyMenuTransition(engine.menuManager, bgColor);
}
void _drawCustomizeMenuHeader(
@@ -816,10 +818,7 @@ class SixelRenderer extends CliRendererBackend<String> {
_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<String> {
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;
@@ -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<FrameBuffer> {
static const int _headerHeadingY = 24;
late FrameBuffer _buffer;
Uint32List _transitionScratch = Uint32List(0);
List<int>? _mainMenuBandFirstColumn;
@override
@@ -233,7 +236,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
break;
}
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
_applyMenuTransition(engine.menuManager, bgColor);
}
void _drawIntroSplash(WolfEngine engine, WolfClassicMenuArt art) {
@@ -275,10 +278,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
}
}
_applyMenuFade(
engine.menuManager.introOverlayAlpha,
_rgbToFrameColor(0x000000),
);
_applyIntroTransition(engine.menuManager, _rgbToFrameColor(0x000000));
}
void _drawRetailWarningIntro(int backgroundColor) {
@@ -550,7 +550,8 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
? 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<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) {
if (alpha <= 0.0) {
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
/// order (`0xAABBGGRR`) used throughout this renderer.
int _rgbToFrameColor(int rgb) {