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 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<String> {
/// 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<String> {
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<String> {
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<String> {
_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<String> {
);
}
_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> {
String toSixelString() {
StringBuffer sb = StringBuffer();
sb.write('\x1bPq');
sb.write('"1;1;$_outputWidth;$_outputHeight');
double damageIntensity = engine.difficulty == null
? 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
/// visual reference for terminal renderers.
class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
static const int _menuFooterY = 184;
static const int _menuFooterHeight = 12;
late FrameBuffer _buffer;
@override
@@ -170,6 +173,8 @@ class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
break;
}
_drawCenteredMenuFooter(art);
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
}
@@ -222,18 +227,18 @@ class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
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<FrameBuffer> {
_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,

View File

@@ -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;