Refactor SixelRasterizer and SoftwareRasterizer for improved terminal handling and menu rendering

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-18 20:21:35 +01:00
parent 0e143892f0
commit ac6edb030e
3 changed files with 119 additions and 36 deletions

View File

@@ -25,6 +25,7 @@ class SixelRasterizer extends CliRasterizer<String> {
static const double _defaultCellWidthToHeight = 0.55; static const double _defaultCellWidthToHeight = 0.55;
static const int _minimumTerminalColumns = 117; static const int _minimumTerminalColumns = 117;
static const int _minimumTerminalRows = 34; static const int _minimumTerminalRows = 34;
static const double _terminalViewportSafety = 0.90;
static const int _compactMenuMinWidthPx = 200; static const int _compactMenuMinWidthPx = 200;
static const int _compactMenuMinHeightPx = 130; static const int _compactMenuMinHeightPx = 130;
static const int _maxRenderWidth = 320; static const int _maxRenderWidth = 320;
@@ -42,6 +43,16 @@ class SixelRasterizer extends CliRasterizer<String> {
/// This should be updated by calling [checkTerminalSixelSupport] before the game loop. /// This should be updated by calling [checkTerminalSixelSupport] before the game loop.
bool isSixelSupported = true; 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 // TERMINAL AUTODETECT
// =========================================================================== // ===========================================================================
@@ -144,46 +155,54 @@ class SixelRasterizer extends CliRasterizer<String> {
final int previousOutputWidth = _outputWidth; final int previousOutputWidth = _outputWidth;
final int previousOutputHeight = _outputHeight; final int previousOutputHeight = _outputHeight;
// First fit a terminal cell rectangle that respects the minimum usable // Fit the sixel output inside the full terminal viewport while preserving
// column/row envelope for status text and centered output. // a 4:3 presentation.
final double fitScale = math.min( final int terminalColumns = math.max(1, terminalBuffer.width);
terminalBuffer.width / _minimumTerminalColumns, final int terminalRows = math.max(1, terminalBuffer.height);
terminalBuffer.height / _minimumTerminalRows, final double cellPixelWidth =
); _defaultLineHeightPx * _defaultCellWidthToHeight;
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);
final int boundsPixelWidth = math.max( final int boundsPixelWidth = math.max(
1, 1,
(targetColumns * _defaultLineHeightPx * _defaultCellWidthToHeight) (terminalColumns * cellPixelWidth).floor(),
.floor(),
); );
final int boundsPixelHeight = math.max( final int boundsPixelHeight = math.max(
1, 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 // Then translate terminal cells into approximate pixels so the Sixel image
// lands on a 4:3 surface inside the available bounds. // lands on a 4:3 surface inside the available bounds.
final double boundsAspect = boundsPixelWidth / boundsPixelHeight; final double boundsAspect = safePixelWidth / safePixelHeight;
if (boundsAspect > _targetAspectRatio) { if (boundsAspect > _targetAspectRatio) {
_outputHeight = boundsPixelHeight; _outputHeight = safePixelHeight;
_outputWidth = math.max(1, (_outputHeight * _targetAspectRatio).floor()); _outputWidth = math.max(1, (_outputHeight * _targetAspectRatio).floor());
} else { } else {
_outputWidth = boundsPixelWidth; _outputWidth = safePixelWidth;
_outputHeight = math.max(1, (_outputWidth / _targetAspectRatio).floor()); _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 || if (_offsetColumns != previousOffsetColumns ||
_offsetRows != previousOffsetRows || _offsetRows != previousOffsetRows ||
_outputWidth != previousOutputWidth || _outputWidth != previousOutputWidth ||
@@ -355,23 +374,24 @@ class SixelRasterizer extends CliRasterizer<String> {
scale: 1, scale: 1,
); );
} }
_drawMenuFooterArt(art);
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor); _applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return; return;
} }
if (engine.menuManager.activeMenu == WolfMenuScreen.episodeSelect) { if (engine.menuManager.activeMenu == WolfMenuScreen.episodeSelect) {
_fillRect320(12, 18, 296, 168, panelColor); _fillRect320(12, 20, 296, 158, panelColor);
_drawMenuTextCentered( _drawMenuTextCentered(
'WHICH EPISODE TO PLAY?', 'WHICH EPISODE TO PLAY?',
8, 6,
headingIndex, headingIndex,
scale: 2, scale: 2,
); );
final cursor = art.pic( final cursor = art.pic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
); );
const int rowYStart = 24; const int rowYStart = 30;
const int rowStep = 26; const int rowStep = 24;
for (int i = 0; i < engine.data.episodes.length; i++) { for (int i = 0; i < engine.data.episodes.length; i++) {
final int y = rowYStart + (i * rowStep); final int y = rowYStart + (i * rowStep);
final bool isSelected = i == engine.menuManager.selectedEpisodeIndex; final bool isSelected = i == engine.menuManager.selectedEpisodeIndex;
@@ -396,12 +416,13 @@ class SixelRasterizer extends CliRasterizer<String> {
_drawMenuText( _drawMenuText(
parts.sublist(1).join(' '), parts.sublist(1).join(' '),
98, 98,
y + 13, y + 12,
isSelected ? selectedTextIndex : unselectedTextIndex, isSelected ? selectedTextIndex : unselectedTextIndex,
scale: 1, scale: 1,
); );
} }
} }
_drawMenuFooterArt(art);
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor); _applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return; return;
} }
@@ -456,9 +477,18 @@ class SixelRasterizer extends CliRasterizer<String> {
); );
} }
_drawMenuFooterArt(art);
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor); _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) { String _gameTitle(GameVersion version) {
switch (version) { switch (version) {
case GameVersion.shareware: case GameVersion.shareware:
@@ -642,6 +672,7 @@ class SixelRasterizer extends CliRasterizer<String> {
String toSixelString() { String toSixelString() {
StringBuffer sb = StringBuffer(); StringBuffer sb = StringBuffer();
sb.write('\x1bPq'); sb.write('\x1bPq');
sb.write('"1;1;$_outputWidth;$_outputHeight');
double damageIntensity = engine.difficulty == null double damageIntensity = engine.difficulty == null
? 0.0 ? 0.0

View File

@@ -12,6 +12,9 @@ import 'package:wolf_3d_dart/wolf_3d_menu.dart';
/// This is the canonical "modern framebuffer" implementation and serves as a /// This is the canonical "modern framebuffer" implementation and serves as a
/// visual reference for terminal renderers. /// visual reference for terminal renderers.
class SoftwareRasterizer extends Rasterizer<FrameBuffer> { class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
static const int _menuFooterY = 184;
static const int _menuFooterHeight = 12;
late FrameBuffer _buffer; late FrameBuffer _buffer;
@override @override
@@ -170,6 +173,8 @@ class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
break; break;
} }
_drawCenteredMenuFooter(art);
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor); _applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
} }
@@ -222,18 +227,18 @@ class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
int unselectedTextColor, int unselectedTextColor,
) { ) {
const int panelX = 12; const int panelX = 12;
const int panelY = 18; const int panelY = 20;
const int panelW = 296; const int panelW = 296;
const int panelH = 168; const int panelH = 158;
_fillMenuPanel(panelX, panelY, panelW, panelH, panelColor); _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( final cursor = art.pic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
); );
const int rowYStart = 24; const int rowYStart = 30;
const int rowStep = 26; const int rowStep = 24;
const int imageX = 40; const int imageX = 40;
const int textX = 98; const int textX = 98;
@@ -264,13 +269,58 @@ class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
_drawMenuText( _drawMenuText(
parts.sublist(1).join(' '), parts.sublist(1).join(' '),
textX, textX,
y + 13, y + 12,
isSelected ? selectedTextColor : unselectedTextColor, 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( void _drawDifficultyMenu(
WolfEngine engine, WolfEngine engine,
WolfClassicMenuArt art, WolfClassicMenuArt art,

View File

@@ -99,7 +99,9 @@ class Wolf3d {
engineAudio: audio, engineAudio: audio,
input: input, input: input,
onGameWon: onGameWon, 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) { onGameSelected: (game) {
_activeGame = game; _activeGame = game;
audio.activeGame = game; audio.activeGame = game;