diff --git a/packages/wolf_3d_dart/lib/src/rasterizer/sixel_rasterizer.dart b/packages/wolf_3d_dart/lib/src/rasterizer/sixel_rasterizer.dart index c31190d..c29fb9b 100644 --- a/packages/wolf_3d_dart/lib/src/rasterizer/sixel_rasterizer.dart +++ b/packages/wolf_3d_dart/lib/src/rasterizer/sixel_rasterizer.dart @@ -25,6 +25,7 @@ class SixelRasterizer extends CliRasterizer { static const double _defaultCellWidthToHeight = 0.55; static const int _minimumTerminalColumns = 117; static const int _minimumTerminalRows = 34; + static const double _terminalViewportSafety = 0.90; static const int _compactMenuMinWidthPx = 200; static const int _compactMenuMinHeightPx = 130; static const int _maxRenderWidth = 320; @@ -42,6 +43,16 @@ class SixelRasterizer extends CliRasterizer { /// This should be updated by calling [checkTerminalSixelSupport] before the game loop. bool isSixelSupported = true; + @override + bool isTerminalSizeSupported(int columns, int rows) { + return columns >= _minimumTerminalColumns && rows >= _minimumTerminalRows; + } + + @override + String get terminalSizeRequirement => + 'Sixel renderer requires a minimum resolution of ' + '${_minimumTerminalColumns}x$_minimumTerminalRows.'; + // =========================================================================== // TERMINAL AUTODETECT // =========================================================================== @@ -144,46 +155,54 @@ class SixelRasterizer extends CliRasterizer { final int previousOutputWidth = _outputWidth; final int previousOutputHeight = _outputHeight; - // First fit a terminal cell rectangle that respects the minimum usable - // column/row envelope for status text and centered output. - final double fitScale = math.min( - terminalBuffer.width / _minimumTerminalColumns, - terminalBuffer.height / _minimumTerminalRows, - ); - - final int targetColumns = math.max( - 1, - (_minimumTerminalColumns * fitScale).floor(), - ); - final int targetRows = math.max( - 1, - (_minimumTerminalRows * fitScale).floor(), - ); - - _offsetColumns = math.max(0, (terminalBuffer.width - targetColumns) ~/ 2); - _offsetRows = math.max(0, (terminalBuffer.height - targetRows) ~/ 2); + // Fit the sixel output inside the full terminal viewport while preserving + // a 4:3 presentation. + final int terminalColumns = math.max(1, terminalBuffer.width); + final int terminalRows = math.max(1, terminalBuffer.height); + final double cellPixelWidth = + _defaultLineHeightPx * _defaultCellWidthToHeight; final int boundsPixelWidth = math.max( 1, - (targetColumns * _defaultLineHeightPx * _defaultCellWidthToHeight) - .floor(), + (terminalColumns * cellPixelWidth).floor(), ); final int boundsPixelHeight = math.max( 1, - targetRows * _defaultLineHeightPx, + terminalRows * _defaultLineHeightPx, + ); + + // Terminal emulators can report approximate cell metrics, so reserve a + // safety margin to avoid right/bottom clipping and drift. + final int safePixelWidth = math.max( + 1, + (boundsPixelWidth * _terminalViewportSafety).floor(), + ); + final int safePixelHeight = math.max( + 1, + (boundsPixelHeight * _terminalViewportSafety).floor(), ); // Then translate terminal cells into approximate pixels so the Sixel image // lands on a 4:3 surface inside the available bounds. - final double boundsAspect = boundsPixelWidth / boundsPixelHeight; + final double boundsAspect = safePixelWidth / safePixelHeight; if (boundsAspect > _targetAspectRatio) { - _outputHeight = boundsPixelHeight; + _outputHeight = safePixelHeight; _outputWidth = math.max(1, (_outputHeight * _targetAspectRatio).floor()); } else { - _outputWidth = boundsPixelWidth; + _outputWidth = safePixelWidth; _outputHeight = math.max(1, (_outputWidth / _targetAspectRatio).floor()); } + // Horizontal: cell-width estimates vary by terminal/font and cause right-shift + // clipping, so keep the image at column 0. + // Vertical: line-height is reliable enough to center correctly. + final int imageRows = math.max( + 1, + (_outputHeight / _defaultLineHeightPx).ceil(), + ); + _offsetColumns = 0; + _offsetRows = math.max(0, (terminalRows - imageRows) ~/ 2); + if (_offsetColumns != previousOffsetColumns || _offsetRows != previousOffsetRows || _outputWidth != previousOutputWidth || @@ -355,23 +374,24 @@ class SixelRasterizer extends CliRasterizer { scale: 1, ); } + _drawMenuFooterArt(art); _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); return; } if (engine.menuManager.activeMenu == WolfMenuScreen.episodeSelect) { - _fillRect320(12, 18, 296, 168, panelColor); + _fillRect320(12, 20, 296, 158, panelColor); _drawMenuTextCentered( 'WHICH EPISODE TO PLAY?', - 8, + 6, headingIndex, scale: 2, ); final cursor = art.pic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); - const int rowYStart = 24; - const int rowStep = 26; + const int rowYStart = 30; + const int rowStep = 24; for (int i = 0; i < engine.data.episodes.length; i++) { final int y = rowYStart + (i * rowStep); final bool isSelected = i == engine.menuManager.selectedEpisodeIndex; @@ -396,12 +416,13 @@ class SixelRasterizer extends CliRasterizer { _drawMenuText( parts.sublist(1).join(' '), 98, - y + 13, + y + 12, isSelected ? selectedTextIndex : unselectedTextIndex, scale: 1, ); } } + _drawMenuFooterArt(art); _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); return; } @@ -456,9 +477,18 @@ class SixelRasterizer extends CliRasterizer { ); } + _drawMenuFooterArt(art); _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); } + void _drawMenuFooterArt(WolfClassicMenuArt art) { + final bottom = art.pic(15); + if (bottom == null) { + return; + } + _blitVgaImage(bottom, (320 - bottom.width) ~/ 2, 200 - bottom.height - 8); + } + String _gameTitle(GameVersion version) { switch (version) { case GameVersion.shareware: @@ -642,6 +672,7 @@ class SixelRasterizer extends CliRasterizer { String toSixelString() { StringBuffer sb = StringBuffer(); sb.write('\x1bPq'); + sb.write('"1;1;$_outputWidth;$_outputHeight'); double damageIntensity = engine.difficulty == null ? 0.0 diff --git a/packages/wolf_3d_dart/lib/src/rasterizer/software_rasterizer.dart b/packages/wolf_3d_dart/lib/src/rasterizer/software_rasterizer.dart index 2f8bc3c..d21937f 100644 --- a/packages/wolf_3d_dart/lib/src/rasterizer/software_rasterizer.dart +++ b/packages/wolf_3d_dart/lib/src/rasterizer/software_rasterizer.dart @@ -12,6 +12,9 @@ import 'package:wolf_3d_dart/wolf_3d_menu.dart'; /// This is the canonical "modern framebuffer" implementation and serves as a /// visual reference for terminal renderers. class SoftwareRasterizer extends Rasterizer { + static const int _menuFooterY = 184; + static const int _menuFooterHeight = 12; + late FrameBuffer _buffer; @override @@ -170,6 +173,8 @@ class SoftwareRasterizer extends Rasterizer { break; } + _drawCenteredMenuFooter(art); + _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); } @@ -222,18 +227,18 @@ class SoftwareRasterizer extends Rasterizer { int unselectedTextColor, ) { const int panelX = 12; - const int panelY = 18; + const int panelY = 20; const int panelW = 296; - const int panelH = 168; + const int panelH = 158; _fillMenuPanel(panelX, panelY, panelW, panelH, panelColor); - _drawMenuTextCentered('WHICH EPISODE TO PLAY?', 2, headingColor, scale: 2); + _drawMenuTextCentered('WHICH EPISODE TO PLAY?', 6, headingColor, scale: 2); final cursor = art.pic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); - const int rowYStart = 24; - const int rowStep = 26; + const int rowYStart = 30; + const int rowStep = 24; const int imageX = 40; const int textX = 98; @@ -264,13 +269,58 @@ class SoftwareRasterizer extends Rasterizer { _drawMenuText( parts.sublist(1).join(' '), textX, - y + 13, + y + 12, isSelected ? selectedTextColor : unselectedTextColor, ); } } } + void _drawCenteredMenuFooter(WolfClassicMenuArt art) { + final bottom = art.pic(15); + if (bottom != null) { + final int x = ((width - bottom.width) ~/ 2).clamp(0, width - 1); + final int y = (height - bottom.height - 8).clamp(0, height - 1); + _blitVgaImage(bottom, x, y); + return; + } + + final int hintKeyColor = ColorPalette.vga32Bit[12]; + final int hintLabelColor = ColorPalette.vga32Bit[4]; + final int hintBackground = ColorPalette.vga32Bit[0]; + + final List<(String text, int color)> segments = <(String, int)>[ + ('UP/DN', hintKeyColor), + (' MOVE ', hintLabelColor), + ('RET', hintKeyColor), + (' SELECT ', hintLabelColor), + ('ESC', hintKeyColor), + (' BACK', hintLabelColor), + ]; + + int textWidth = 0; + for (final (text, _) in segments) { + textWidth += WolfMenuFont.measureTextWidth(text, 1); + } + + final int panelWidth = (textWidth + 12).clamp(1, width); + final int panelX = ((width - panelWidth) ~/ 2).clamp(0, width - 1); + _fillMenuPanel( + panelX, + _menuFooterY, + panelWidth, + _menuFooterHeight, + hintBackground, + ); + + int cursorX = panelX + 6; + const int textY = _menuFooterY + 2; + for (final (text, color) in segments) { + _drawMenuText(text, cursorX, textY, color, scale: 1); + cursorX += WolfMenuFont.measureTextWidth(text, 1); + } + } + void _drawDifficultyMenu( WolfEngine engine, WolfClassicMenuArt art, diff --git a/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart b/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart index b076300..60018e1 100644 --- a/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart +++ b/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart @@ -99,7 +99,9 @@ class Wolf3d { engineAudio: audio, input: input, onGameWon: onGameWon, - onMenuExit: onGameWon, + // In Flutter we keep the renderer screen active while browsing menus, + // so backing out of the top-level menu should not pop the route. + onMenuExit: () {}, onGameSelected: (game) { _activeGame = game; audio.activeGame = game;