From b23c02f71637fdfc6452cefd7e42d2bca5fa703e Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Fri, 20 Mar 2026 12:05:34 +0100 Subject: [PATCH] feat: Add intro splash screen with transition effects and rendering support Signed-off-by: Hans Kokx --- .../lib/src/engine/wolf_3d_engine_base.dart | 5 +- .../lib/src/menu/menu_manager.dart | 149 +++++++++++++++++- .../lib/src/rendering/ascii_renderer.dart | 58 +++++++ .../lib/src/rendering/sixel_renderer.dart | 56 +++++++ .../lib/src/rendering/software_renderer.dart | 105 +++++++++++- .../lib/wolf_3d_glsl_renderer.dart | 8 +- 6 files changed, 367 insertions(+), 14 deletions(-) diff --git a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart index 68afd48..153e4c0 100644 --- a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart +++ b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart @@ -295,6 +295,9 @@ class WolfEngine { } switch (menuManager.activeMenu) { + case WolfMenuScreen.introSplash: + menuManager.updateIntroSplash(input); + break; case WolfMenuScreen.mainMenu: _tickMainMenu(input); break; @@ -370,7 +373,7 @@ class WolfEngine { _currentEpisodeIndex = 0; onEpisodeSelected?.call(null); menuManager.clearEpisodeSelection(); - menuManager.startTransition(WolfMenuScreen.mainMenu); + menuManager.beginIntroSplash(); } } 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 5468bee..3b9b3a9 100644 --- a/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart +++ b/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart @@ -1,7 +1,15 @@ import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_engine.dart'; -enum WolfMenuScreen { mainMenu, gameSelect, episodeSelect, difficultySelect } +enum WolfMenuScreen { + introSplash, + mainMenu, + gameSelect, + episodeSelect, + difficultySelect, +} + +enum _WolfIntroPhase { fadeIn, hold, fadeOut } enum WolfMenuMainAction { newGame, @@ -52,11 +60,20 @@ bool _isWiredMainMenuAction(WolfMenuMainAction action) { /// Handles menu-only input state such as selection movement and edge triggers. class MenuManager { static const int transitionDurationMs = 280; + static const int introFadeDurationMs = 280; + static const int _introSlideCount = 2; + static const int _introPg13BackgroundRgb = 0x33A2E8; + static const int _introTitleBackgroundRgb = 0x000000; WolfMenuScreen _activeMenu = WolfMenuScreen.difficultySelect; WolfMenuScreen? _transitionTarget; int _transitionElapsedMs = 0; bool _transitionSwappedMenu = false; + WolfMenuScreen _introLandingMenu = WolfMenuScreen.mainMenu; + int _introSlideIndex = 0; + int _introElapsedMs = 0; + _WolfIntroPhase _introPhase = _WolfIntroPhase.fadeIn; + bool _introAdvanceRequested = false; int _selectedMainIndex = 0; int _selectedGameIndex = 0; @@ -74,6 +91,27 @@ class MenuManager { bool get isTransitioning => _transitionTarget != null; + bool get isIntroSplashActive => _activeMenu == WolfMenuScreen.introSplash; + + bool get isIntroPg13Slide => _introSlideIndex == 0; + + int get introBackgroundRgb => + isIntroPg13Slide ? _introPg13BackgroundRgb : _introTitleBackgroundRgb; + + double get introOverlayAlpha { + if (!isIntroSplashActive) { + return 0.0; + } + switch (_introPhase) { + case _WolfIntroPhase.fadeIn: + return (1.0 - (_introElapsedMs / introFadeDurationMs)).clamp(0.0, 1.0); + case _WolfIntroPhase.hold: + return 0.0; + case _WolfIntroPhase.fadeOut: + return (_introElapsedMs / introFadeDurationMs).clamp(0.0, 1.0); + } + } + /// Returns the fade alpha during transitions (0.0..1.0). double get transitionAlpha { if (!isTransitioning) { @@ -162,15 +200,33 @@ class MenuManager { : Difficulty.values .indexOf(initialDifficulty) .clamp(0, Difficulty.values.length - 1); - _activeMenu = gameCount > 1 - ? WolfMenuScreen.gameSelect - : WolfMenuScreen.mainMenu; + _introLandingMenu = WolfMenuScreen.mainMenu; + if (gameCount > 1) { + _activeMenu = WolfMenuScreen.gameSelect; + _introElapsedMs = 0; + _introPhase = _WolfIntroPhase.fadeIn; + _introSlideIndex = 0; + } else { + _startIntroSequence(); + } _transitionTarget = null; _transitionElapsedMs = 0; _transitionSwappedMenu = false; _resetEdgeState(); } + /// Starts the intro splash sequence and lands on [landingMenu] when done. + void beginIntroSplash({ + WolfMenuScreen landingMenu = WolfMenuScreen.mainMenu, + }) { + _introLandingMenu = landingMenu; + _transitionTarget = null; + _transitionElapsedMs = 0; + _transitionSwappedMenu = false; + _startIntroSequence(); + _resetEdgeState(); + } + /// Resets menu navigation state for a new difficulty selection flow. void beginDifficultySelection({Difficulty? initialDifficulty}) { beginSelectionFlow( @@ -194,6 +250,7 @@ class MenuManager { _transitionTarget = null; _transitionElapsedMs = 0; _transitionSwappedMenu = false; + _introElapsedMs = 0; _resetEdgeState(); } @@ -213,6 +270,11 @@ class MenuManager { /// Advances transition timers and swaps menu at midpoint. void tickTransition(int deltaMs) { + if (isIntroSplashActive) { + _tickIntro(deltaMs); + return; + } + if (!isTransitioning) { return; } @@ -229,6 +291,85 @@ class MenuManager { } } + void updateIntroSplash(EngineInput input) { + if (!isIntroSplashActive) { + return; + } + + final bool confirmNow = input.isInteracting; + if (confirmNow && !_prevConfirm) { + if (_introPhase == _WolfIntroPhase.fadeOut) { + // Ignore repeat confirms while already transitioning out. + } else if (_introPhase == _WolfIntroPhase.hold) { + _introPhase = _WolfIntroPhase.fadeOut; + _introElapsedMs = 0; + } else { + // Queue advance while fade-in is still in progress. + _introAdvanceRequested = true; + } + } + + _consumeEdgeState(input); + } + + void _startIntroSequence() { + _activeMenu = WolfMenuScreen.introSplash; + _introSlideIndex = 0; + _introElapsedMs = 0; + _introPhase = _WolfIntroPhase.fadeIn; + _introAdvanceRequested = false; + } + + void _tickIntro(int deltaMs) { + if (!isIntroSplashActive) { + return; + } + + _introElapsedMs += deltaMs; + + switch (_introPhase) { + case _WolfIntroPhase.fadeIn: + if (_introElapsedMs >= introFadeDurationMs) { + _introElapsedMs = 0; + if (_introAdvanceRequested) { + _introPhase = _WolfIntroPhase.fadeOut; + _introAdvanceRequested = false; + } else { + _introPhase = _WolfIntroPhase.hold; + } + } + break; + case _WolfIntroPhase.hold: + // Hold indefinitely until the user confirms. + _introElapsedMs = 0; + if (_introAdvanceRequested) { + _introPhase = _WolfIntroPhase.fadeOut; + _introAdvanceRequested = false; + } + break; + case _WolfIntroPhase.fadeOut: + if (_introElapsedMs >= introFadeDurationMs) { + _advanceIntroSlide(); + } + break; + } + } + + void _advanceIntroSlide() { + if (_introSlideIndex < _introSlideCount - 1) { + _introSlideIndex += 1; + _introElapsedMs = 0; + _introPhase = _WolfIntroPhase.fadeIn; + _introAdvanceRequested = false; + return; + } + + _activeMenu = _introLandingMenu; + _introElapsedMs = 0; + _introPhase = _WolfIntroPhase.fadeIn; + _introAdvanceRequested = false; + } + void clearEpisodeSelection() { _selectedEpisodeIndex = 0; } 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 f03bf24..c78e8d9 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart @@ -419,6 +419,11 @@ class AsciiRenderer extends CliRendererBackend { final art = WolfClassicMenuArt(engine.data); + if (engine.menuManager.activeMenu == WolfMenuScreen.introSplash) { + _drawIntroSplash(engine, art); + return; + } + if (engine.menuManager.activeMenu == WolfMenuScreen.mainMenu) { _fillRect320(68, 52, 178, 136, panelColor); @@ -703,6 +708,59 @@ class AsciiRenderer extends CliRendererBackend { } } + void _drawIntroSplash(WolfEngine engine, WolfClassicMenuArt art) { + final image = engine.menuManager.isIntroPg13Slide + ? art.mappedPic(WolfMenuPic.pg13) + : art.mappedPic(WolfMenuPic.title); + + int splashBg = _rgbToPaletteColor(engine.menuManager.introBackgroundRgb); + if (engine.menuManager.isIntroPg13Slide && + image != null && + image.pixels.isNotEmpty) { + splashBg = ColorPalette.vga32Bit[_dominantPaletteIndex(image)]; + } + + if (_usesTerminalLayout) { + _fillTerminalRect(0, 0, width, _terminalPixelHeight, splashBg); + } else { + _fillRect(0, 0, width, height, activeTheme.solid, splashBg); + } + + if (image != null) { + final int x = engine.menuManager.isIntroPg13Slide + ? (320 - image.width).clamp(0, 319) + : ((320 - image.width) ~/ 2).clamp(0, 319); + final int y = engine.menuManager.isIntroPg13Slide + ? (200 - image.height).clamp(0, 199) + : ((200 - image.height) ~/ 2).clamp(0, 199); + _blitVgaImageAscii(image, x, y); + } + + _applyMenuFade( + engine.menuManager.introOverlayAlpha, + _rgbToPaletteColor(0x000000), + ); + } + + int _dominantPaletteIndex(VgaImage image) { + final List histogram = List.filled(256, 0); + for (final int colorIndex in image.pixels) { + if (colorIndex >= 0 && colorIndex < histogram.length) { + histogram[colorIndex]++; + } + } + + int bestIndex = 0; + int bestCount = -1; + for (int i = 0; i < histogram.length; i++) { + if (histogram[i] > bestCount) { + bestCount = histogram[i]; + bestIndex = i; + } + } + return bestIndex; + } + void _applyMenuFade(double alpha, int fadeColor) { if (alpha <= 0.0) { return; 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 3a6edf3..93eed6e 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart @@ -372,6 +372,11 @@ class SixelRenderer extends CliRendererBackend { // Draw footer first so menu panels can clip overlap in the center. _drawMenuFooterArt(art); + if (engine.menuManager.activeMenu == WolfMenuScreen.introSplash) { + _drawIntroSplash(engine, art); + return; + } + if (engine.menuManager.activeMenu == WolfMenuScreen.mainMenu) { _fillRect320(68, 52, 178, 136, panelColor); @@ -555,6 +560,57 @@ class SixelRenderer extends CliRendererBackend { } } + void _drawIntroSplash(WolfEngine engine, WolfClassicMenuArt art) { + final image = engine.menuManager.isIntroPg13Slide + ? art.mappedPic(WolfMenuPic.pg13) + : art.mappedPic(WolfMenuPic.title); + + int splashBg = _rgbToPaletteIndex(engine.menuManager.introBackgroundRgb); + if (engine.menuManager.isIntroPg13Slide && + image != null && + image.pixels.isNotEmpty) { + splashBg = _dominantPaletteIndex(image); + } + + for (int i = 0; i < _screen.length; i++) { + _screen[i] = splashBg; + } + + if (image != null) { + final int x = engine.menuManager.isIntroPg13Slide + ? (320 - image.width).clamp(0, 319) + : ((320 - image.width) ~/ 2).clamp(0, 319); + final int y = engine.menuManager.isIntroPg13Slide + ? (200 - image.height).clamp(0, 199) + : ((200 - image.height) ~/ 2).clamp(0, 199); + _blitVgaImage(image, x, y); + } + + _applyMenuFade( + engine.menuManager.introOverlayAlpha, + _rgbToPaletteIndex(0x000000), + ); + } + + int _dominantPaletteIndex(VgaImage image) { + final List histogram = List.filled(256, 0); + for (final int colorIndex in image.pixels) { + if (colorIndex >= 0 && colorIndex < histogram.length) { + histogram[colorIndex]++; + } + } + + int bestIndex = 0; + int bestCount = -1; + for (int i = 0; i < histogram.length; i++) { + if (histogram[i] > bestCount) { + bestCount = histogram[i]; + bestIndex = i; + } + } + return bestIndex; + } + 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 f1ebfd6..b663c09 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart @@ -159,6 +159,9 @@ class SoftwareRenderer extends RendererBackend { _drawCenteredMenuFooter(art); switch (engine.menuManager.activeMenu) { + case WolfMenuScreen.introSplash: + _drawIntroSplash(engine, art); + break; case WolfMenuScreen.mainMenu: _drawMainMenu( engine, @@ -205,6 +208,99 @@ class SoftwareRenderer extends RendererBackend { _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); } + void _drawIntroSplash(WolfEngine engine, WolfClassicMenuArt art) { + final image = engine.menuManager.isIntroPg13Slide + ? art.mappedPic(WolfMenuPic.pg13) + : art.mappedPic(WolfMenuPic.title); + + int splashBgColor = _rgbToFrameColor(engine.menuManager.introBackgroundRgb); + int? matteIndex; + if (engine.menuManager.isIntroPg13Slide && + image != null && + image.pixels.isNotEmpty) { + matteIndex = _dominantPaletteIndex(image); + splashBgColor = + ColorPalette.vga32Bit[_pg13BackgroundPaletteIndex(image, matteIndex)]; + } + + for (int i = 0; i < _buffer.pixels.length; i++) { + _buffer.pixels[i] = splashBgColor; + } + + if (image != null) { + final int x = engine.menuManager.isIntroPg13Slide + ? (320 - image.width).clamp(0, 319) + : ((320 - image.width) ~/ 2).clamp(0, 319); + final int y = engine.menuManager.isIntroPg13Slide + ? (200 - image.height).clamp(0, 199) + : ((200 - image.height) ~/ 2).clamp(0, 199); + if (engine.menuManager.isIntroPg13Slide && matteIndex != null) { + _blitVgaImage(image, x, y, transparentIndex: matteIndex); + } else { + _blitVgaImage(image, x, y); + } + } + + _applyMenuFade( + engine.menuManager.introOverlayAlpha, + _rgbToFrameColor(0x000000), + ); + } + + int _dominantPaletteIndex(VgaImage image) { + final List histogram = List.filled(256, 0); + for (final int colorIndex in image.pixels) { + if (colorIndex >= 0 && colorIndex < histogram.length) { + histogram[colorIndex]++; + } + } + + int bestIndex = 0; + int bestCount = -1; + for (int i = 0; i < histogram.length; i++) { + if (histogram[i] > bestCount) { + bestCount = histogram[i]; + bestIndex = i; + } + } + return bestIndex; + } + + int _pg13BackgroundPaletteIndex(VgaImage image, int matteIndex) { + final List<(int x, int y)> probes = <(int x, int y)>[ + (0, 0), + (image.width - 1, 0), + (0, image.height - 1), + (image.width - 1, image.height - 1), + (image.width - 8, image.height - 8), + (image.width - 16, image.height - 16), + ]; + final Map counts = {}; + for (final probe in probes) { + final int x = probe.$1.clamp(0, image.width - 1); + final int y = probe.$2.clamp(0, image.height - 1); + final int idx = image.decodePixel(x, y); + if (idx == matteIndex) { + continue; + } + counts[idx] = (counts[idx] ?? 0) + 1; + } + + if (counts.isEmpty) { + return matteIndex; + } + + int bestIndex = counts.keys.first; + int bestCount = counts[bestIndex] ?? 0; + for (final MapEntry entry in counts.entries) { + if (entry.value > bestCount) { + bestCount = entry.value; + bestIndex = entry.key; + } + } + return bestIndex; + } + void _drawMainMenu( WolfEngine engine, WolfClassicMenuArt art, @@ -663,7 +759,12 @@ class SoftwareRenderer extends RendererBackend { /// /// UI coordinates are expressed in canonical 320x200 space and scaled to the /// current framebuffer so higher-resolution render targets preserve layout. - void _blitVgaImage(VgaImage image, int startX, int startY) { + void _blitVgaImage( + VgaImage image, + int startX, + int startY, { + int? transparentIndex, + }) { final int destStartX = (startX * _uiScaleX).floor(); final int destStartY = (startY * _uiScaleY).floor(); final int destWidth = math.max(1, (image.width * _uiScaleX).ceil()); @@ -678,7 +779,7 @@ class SoftwareRenderer extends RendererBackend { final int srcX = (dx / _uiScaleX).toInt().clamp(0, image.width - 1); final int srcY = (dy / _uiScaleY).toInt().clamp(0, image.height - 1); final int colorByte = image.decodePixel(srcX, srcY); - if (colorByte != 255) { + if (colorByte != 255 && colorByte != transparentIndex) { _buffer.pixels[drawY * width + drawX] = ColorPalette.vga32Bit[colorByte]; } diff --git a/packages/wolf_3d_renderer/lib/wolf_3d_glsl_renderer.dart b/packages/wolf_3d_renderer/lib/wolf_3d_glsl_renderer.dart index 572ca6a..51528fb 100644 --- a/packages/wolf_3d_renderer/lib/wolf_3d_glsl_renderer.dart +++ b/packages/wolf_3d_renderer/lib/wolf_3d_glsl_renderer.dart @@ -53,9 +53,7 @@ class _WolfGlslRendererState extends BaseWolfRendererState { } @override - Color get scaffoldColor => widget.engine.difficulty == null - ? _colorFromRgb(widget.engine.menuBackgroundRgb) - : const Color.fromARGB(255, 4, 64, 64); + Color get scaffoldColor => Colors.black; @override void dispose() { @@ -149,10 +147,6 @@ class _WolfGlslRendererState extends BaseWolfRendererState { widget.onUnavailable?.call(); } } - - Color _colorFromRgb(int rgb) { - return Color(0xFF000000 | (rgb & 0x00FFFFFF)); - } } class _GlslFramePainter extends CustomPainter {