WIP fixing menu rendering in CLI ASCII mode
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
@@ -31,15 +31,19 @@ class _GameScreenState extends State<GameScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_engine = widget.wolf3d.launchEngine(
|
||||
onGameWon: () => Navigator.of(context).pop(),
|
||||
onGameWon: () {
|
||||
_engine.difficulty = null;
|
||||
widget.wolf3d.clearActiveDifficulty();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope<Object?>(
|
||||
return PopScope(
|
||||
canPop: _engine.difficulty != null,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop && _engine.difficulty == null) {
|
||||
widget.wolf3d.input.queueBackAction();
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/// Defines the game difficulty levels, matching the original titles.
|
||||
enum Difficulty {
|
||||
baby(0, "Can I play, Daddy?"),
|
||||
easy(0, "Don't hurt me."),
|
||||
medium(1, "Bring em' on!"),
|
||||
hard(2, "I am Death incarnate!"),
|
||||
baby(0, "CAN I PLAY, DADDY?"),
|
||||
easy(0, "DON'T HURT ME."),
|
||||
medium(1, "BRING 'EM ON!"),
|
||||
hard(2, "I AM DEATH INCARNATE!"),
|
||||
;
|
||||
|
||||
/// Shared heading used by classic difficulty menus.
|
||||
static String get menuText => 'HOW TOUGH ARE YOU?';
|
||||
|
||||
/// The friendly string shown in menus.
|
||||
final String title;
|
||||
|
||||
|
||||
@@ -184,6 +184,8 @@ class WolfEngine {
|
||||
void _tickDifficultyMenu(EngineInput input) {
|
||||
final menuResult = menuManager.updateDifficultySelection(input);
|
||||
if (menuResult.goBack) {
|
||||
// Explicitly keep the engine in menu mode when leaving this screen.
|
||||
difficulty = null;
|
||||
onGameWon();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
||||
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
|
||||
|
||||
import 'cli_rasterizer.dart';
|
||||
import 'menu_font.dart';
|
||||
|
||||
class AsciiTheme {
|
||||
final String name;
|
||||
@@ -63,62 +64,19 @@ class ColoredChar {
|
||||
}
|
||||
|
||||
class AsciiRasterizer extends CliRasterizer<dynamic> {
|
||||
static const int _unsetMenuSubPixel = -1;
|
||||
static const Map<int, String> _quadrantGlyphByMask = {
|
||||
0x0: ' ',
|
||||
0x1: '▘',
|
||||
0x2: '▝',
|
||||
0x3: '▀',
|
||||
0x4: '▖',
|
||||
0x5: '▌',
|
||||
0x6: '▞',
|
||||
0x7: '▛',
|
||||
0x8: '▗',
|
||||
0x9: '▚',
|
||||
0xA: '▐',
|
||||
0xB: '▜',
|
||||
0xC: '▄',
|
||||
0xD: '▙',
|
||||
0xE: '▟',
|
||||
0xF: '█',
|
||||
};
|
||||
static const Map<String, List<String>> _menuFont = {
|
||||
'A': ['01110', '10001', '10001', '11111', '10001', '10001', '10001'],
|
||||
'B': ['11110', '10001', '10001', '11110', '10001', '10001', '11110'],
|
||||
'C': ['01110', '10001', '10000', '10000', '10000', '10001', '01110'],
|
||||
'D': ['11110', '10001', '10001', '10001', '10001', '10001', '11110'],
|
||||
'E': ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
|
||||
'F': ['11111', '10000', '10000', '11110', '10000', '10000', '10000'],
|
||||
'G': ['01110', '10001', '10000', '10111', '10001', '10001', '01111'],
|
||||
'H': ['10001', '10001', '10001', '11111', '10001', '10001', '10001'],
|
||||
'I': ['11111', '00100', '00100', '00100', '00100', '00100', '11111'],
|
||||
'K': ['10001', '10010', '10100', '11000', '10100', '10010', '10001'],
|
||||
'L': ['10000', '10000', '10000', '10000', '10000', '10000', '11111'],
|
||||
'M': ['10001', '11011', '10101', '10101', '10001', '10001', '10001'],
|
||||
'N': ['10001', '10001', '11001', '10101', '10011', '10001', '10001'],
|
||||
'O': ['01110', '10001', '10001', '10001', '10001', '10001', '01110'],
|
||||
'P': ['11110', '10001', '10001', '11110', '10000', '10000', '10000'],
|
||||
'R': ['11110', '10001', '10001', '11110', '10100', '10010', '10001'],
|
||||
'S': ['01111', '10000', '10000', '01110', '00001', '00001', '11110'],
|
||||
'T': ['11111', '00100', '00100', '00100', '00100', '00100', '00100'],
|
||||
'U': ['10001', '10001', '10001', '10001', '10001', '10001', '01110'],
|
||||
'W': ['10001', '10001', '10001', '10101', '10101', '11011', '10001'],
|
||||
'Y': ['10001', '10001', '01010', '00100', '00100', '00100', '00100'],
|
||||
'?': ['01110', '10001', '00001', '00010', '00100', '00000', '00100'],
|
||||
'!': ['00100', '00100', '00100', '00100', '00100', '00000', '00100'],
|
||||
',': ['00000', '00000', '00000', '00000', '00110', '00100', '01000'],
|
||||
'.': ['00000', '00000', '00000', '00000', '00000', '00110', '00110'],
|
||||
"'": ['00100', '00100', '00100', '00000', '00000', '00000', '00000'],
|
||||
' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'],
|
||||
};
|
||||
|
||||
static const double _targetAspectRatio = 4 / 3;
|
||||
static const int _terminalBackdropArgb = 0xFF009688;
|
||||
static const int _terminalBackdropPaletteIndex = 153;
|
||||
static const int _minimumTerminalColumns = 80;
|
||||
static const int _minimumTerminalRows = 24;
|
||||
static const int _simpleHudMinWidth = 84;
|
||||
static const int _simpleHudMinRows = 7;
|
||||
|
||||
static const int _menuSelectedTextPaletteIndex = 19;
|
||||
static const int _menuUnselectedTextPaletteIndex = 23;
|
||||
static const int _menuHintKeyPaletteIndex = 12;
|
||||
static const int _menuHintLabelPaletteIndex = 4;
|
||||
static const int _menuHintBackgroundPaletteIndex = 0;
|
||||
|
||||
AsciiRasterizer({
|
||||
this.activeTheme = AsciiThemes.blocks,
|
||||
this.isTerminal = false,
|
||||
@@ -132,7 +90,6 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
||||
late List<List<ColoredChar>> _screen;
|
||||
late List<List<int>> _scenePixels;
|
||||
late WolfEngine _engine;
|
||||
List<List<int>>? _menuTextSubPixels;
|
||||
|
||||
@override
|
||||
final double aspectMultiplier;
|
||||
@@ -170,7 +127,8 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
||||
|
||||
int get _viewportRightX => projectionOffsetX + projectionWidth;
|
||||
|
||||
int get _terminalBackdropColor => _argbToRawColor(_terminalBackdropArgb);
|
||||
int get _terminalBackdropColor =>
|
||||
ColorPalette.vga32Bit[_terminalBackdropPaletteIndex];
|
||||
|
||||
// Intercept the base render call to initialize our text grid
|
||||
@override
|
||||
@@ -251,7 +209,7 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
||||
if (isTerminal) {
|
||||
_scenePixels[y][x] = _scaleColor(pixelColor, brightness);
|
||||
} else {
|
||||
String wallChar = activeTheme.getByBrightness(brightness);
|
||||
final String wallChar = activeTheme.getByBrightness(brightness);
|
||||
_screen[y][x] = ColoredChar(wallChar, pixelColor);
|
||||
}
|
||||
}
|
||||
@@ -295,7 +253,6 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
||||
if (isTerminal) {
|
||||
_scenePixels[y][stripeX] = shadedColor;
|
||||
} else {
|
||||
// Force sprites to be SOLID so they don't vanish into the terminal background
|
||||
_screen[y][stripeX] = ColoredChar(activeTheme.solid, shadedColor);
|
||||
}
|
||||
}
|
||||
@@ -379,33 +336,30 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
||||
void drawMenu(WolfEngine engine) {
|
||||
final int selectedDifficultyIndex =
|
||||
engine.menuManager.selectedDifficultyIndex;
|
||||
final int bgColor = _rgbToRawColor(engine.menuBackgroundRgb);
|
||||
final int panelColor = _rgbToRawColor(engine.menuPanelRgb);
|
||||
final int headingColor = ColorPalette.vga32Bit[119];
|
||||
final int selectedTextColor = ColorPalette.vga32Bit[19];
|
||||
final int unselectedTextColor = ColorPalette.vga32Bit[23];
|
||||
final int bgColor = _rgbToPaletteColor(engine.menuBackgroundRgb);
|
||||
final int panelColor = _rgbToPaletteColor(engine.menuPanelRgb);
|
||||
// Exact title tint requested for menu heading text (#fff700).
|
||||
final int headingColor = _rgbToRawColor(0xFFF700);
|
||||
final int selectedTextColor =
|
||||
ColorPalette.vga32Bit[_menuSelectedTextPaletteIndex];
|
||||
final int unselectedTextColor =
|
||||
ColorPalette.vga32Bit[_menuUnselectedTextPaletteIndex];
|
||||
|
||||
if (isTerminal) {
|
||||
_fillTerminalRect(0, 0, width, _terminalPixelHeight, bgColor);
|
||||
} else {
|
||||
_fillRect(0, 0, width, height, activeTheme.solid, bgColor);
|
||||
_menuTextSubPixels = List.generate(
|
||||
height * 2,
|
||||
(_) => List<int>.filled(width * 2, _unsetMenuSubPixel),
|
||||
);
|
||||
}
|
||||
|
||||
_fillRect320(28, 70, 264, 82, panelColor);
|
||||
|
||||
_drawMenuTextCentered('HOW TOUGH ARE YOU?', 48, headingColor, scale: 2);
|
||||
|
||||
final art = WolfClassicMenuArt(engine.data);
|
||||
|
||||
final face = art.difficultyOption(
|
||||
Difficulty.values[selectedDifficultyIndex],
|
||||
);
|
||||
if (face != null) {
|
||||
_blitVgaImageAscii(face, 28 + 264 - face.width - 10, 92);
|
||||
_blitVgaImageAscii(face, 28 + 264 - face.width - 18, 92);
|
||||
}
|
||||
|
||||
final cursor = art.pic(
|
||||
@@ -414,6 +368,38 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
||||
const rowYStart = 86;
|
||||
const rowStep = 15;
|
||||
|
||||
if (_useMinimalMenuText) {
|
||||
_drawMenuTextCentered(
|
||||
Difficulty.menuText,
|
||||
48,
|
||||
headingColor,
|
||||
scale: 2,
|
||||
);
|
||||
_drawMinimalMenuText(
|
||||
selectedDifficultyIndex,
|
||||
selectedTextColor,
|
||||
unselectedTextColor,
|
||||
panelColor,
|
||||
);
|
||||
if (cursor != null) {
|
||||
_blitVgaImageAscii(
|
||||
cursor,
|
||||
38,
|
||||
(rowYStart + (selectedDifficultyIndex * rowStep)) - 2,
|
||||
);
|
||||
}
|
||||
_drawCenteredMenuFooter();
|
||||
return;
|
||||
}
|
||||
|
||||
final int headingScale = _fullMenuHeadingScale;
|
||||
_drawMenuTextCentered(
|
||||
Difficulty.menuText,
|
||||
48,
|
||||
headingColor,
|
||||
scale: headingScale,
|
||||
);
|
||||
|
||||
for (int i = 0; i < Difficulty.values.length; i++) {
|
||||
final y = rowYStart + (i * rowStep);
|
||||
final isSelected = i == selectedDifficultyIndex;
|
||||
@@ -422,87 +408,14 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
||||
}
|
||||
|
||||
_drawMenuText(
|
||||
_difficultyLabel(Difficulty.values[i]),
|
||||
Difficulty.values[i].title,
|
||||
70,
|
||||
y,
|
||||
isSelected ? selectedTextColor : unselectedTextColor,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isTerminal) {
|
||||
_composeMenuTextSubPixels();
|
||||
_menuTextSubPixels = null;
|
||||
}
|
||||
|
||||
final int hintKeyColor = _rgbToRawColor(0xFF5555);
|
||||
final int hintLabelColor = _rgbToRawColor(0x900303);
|
||||
final int hintBackground = _rgbToRawColor(0x000000);
|
||||
|
||||
_fillRect320(0, 176, 320, 24, hintBackground);
|
||||
|
||||
final hintY = ((186 / 200) * height).toInt().clamp(0, height - 1);
|
||||
int hintX = ((24 / 320) * width).toInt().clamp(0, width - 1);
|
||||
|
||||
_writeString(
|
||||
hintX,
|
||||
hintY,
|
||||
'^/v',
|
||||
hintKeyColor,
|
||||
hintBackground,
|
||||
);
|
||||
hintX += 4;
|
||||
_writeString(
|
||||
hintX,
|
||||
hintY,
|
||||
' MOVE ',
|
||||
hintLabelColor,
|
||||
hintBackground,
|
||||
);
|
||||
hintX += 7;
|
||||
_writeString(
|
||||
hintX,
|
||||
hintY,
|
||||
'RET',
|
||||
hintKeyColor,
|
||||
hintBackground,
|
||||
);
|
||||
hintX += 4;
|
||||
_writeString(
|
||||
hintX,
|
||||
hintY,
|
||||
' SELECT ',
|
||||
hintLabelColor,
|
||||
hintBackground,
|
||||
);
|
||||
hintX += 9;
|
||||
_writeString(
|
||||
hintX,
|
||||
hintY,
|
||||
'ESC',
|
||||
hintKeyColor,
|
||||
hintBackground,
|
||||
);
|
||||
hintX += 4;
|
||||
_writeString(
|
||||
hintX,
|
||||
hintY,
|
||||
' BACK',
|
||||
hintLabelColor,
|
||||
hintBackground,
|
||||
);
|
||||
}
|
||||
|
||||
String _difficultyLabel(Difficulty difficulty) {
|
||||
switch (difficulty) {
|
||||
case Difficulty.baby:
|
||||
return 'CAN I PLAY, DADDY?';
|
||||
case Difficulty.easy:
|
||||
return "DON'T HURT ME.";
|
||||
case Difficulty.medium:
|
||||
return "BRING 'EM ON!";
|
||||
case Difficulty.hard:
|
||||
return 'I AM DEATH INCARNATE!';
|
||||
}
|
||||
_drawCenteredMenuFooter();
|
||||
}
|
||||
|
||||
void _drawMenuText(
|
||||
@@ -515,7 +428,7 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
||||
int x320 = startX320;
|
||||
for (final rune in text.runes) {
|
||||
final String char = String.fromCharCode(rune).toUpperCase();
|
||||
final List<String> pattern = _menuFont[char] ?? _menuFont[' ']!;
|
||||
final List<String> pattern = WolfMenuFont.glyphFor(char);
|
||||
|
||||
for (int row = 0; row < pattern.length; row++) {
|
||||
final String bits = pattern[row];
|
||||
@@ -535,7 +448,7 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
||||
}
|
||||
}
|
||||
|
||||
x320 += _menuGlyphAdvance(char, scale);
|
||||
x320 += WolfMenuFont.glyphAdvance(char, scale);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,46 +458,170 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
||||
int color, {
|
||||
int scale = 1,
|
||||
}) {
|
||||
final int textWidth = _measureMenuTextWidth(text, scale);
|
||||
final int textWidth = WolfMenuFont.measureTextWidth(text, scale);
|
||||
final int x320 = ((320 - textWidth) ~/ 2).clamp(0, 319);
|
||||
_drawMenuText(text, x320, y200, color, scale: scale);
|
||||
}
|
||||
|
||||
int _measureMenuTextWidth(String text, int scale) {
|
||||
int width320 = 0;
|
||||
for (final rune in text.runes) {
|
||||
final char = String.fromCharCode(rune).toUpperCase();
|
||||
width320 += _menuGlyphAdvance(char, scale);
|
||||
int get _fullMenuHeadingScale {
|
||||
if (!isTerminal) {
|
||||
return 2;
|
||||
}
|
||||
return width320;
|
||||
return projectionWidth < 140 ? 1 : 2;
|
||||
}
|
||||
|
||||
int _menuGlyphAdvance(String char, int scale) {
|
||||
switch (char) {
|
||||
case 'I':
|
||||
case '!':
|
||||
case '.':
|
||||
case ',':
|
||||
case "'":
|
||||
return 4 * scale;
|
||||
case ' ':
|
||||
return 5 * scale;
|
||||
default:
|
||||
return 6 * scale;
|
||||
bool get _useMinimalMenuText => _menuGlyphHeightInRows(scale: 1) <= 4;
|
||||
|
||||
int _menuGlyphHeightInRows({required int scale}) {
|
||||
final double scaleY = (isTerminal ? _terminalPixelHeight : height) / 200.0;
|
||||
// Use pre-compose pixel scale so terminal mode can graduate back to
|
||||
// bitmap menu text at practical window sizes.
|
||||
final double rasterHeight = 7.0 * scale * scaleY;
|
||||
return math.max(1, rasterHeight.ceil());
|
||||
}
|
||||
|
||||
int _menuY200ToRow(int y200) {
|
||||
final double scaleY = (isTerminal ? _terminalPixelHeight : height) / 200.0;
|
||||
final int pixelY = (y200 * scaleY).toInt();
|
||||
final int row = isTerminal ? (pixelY / 2).round() : pixelY;
|
||||
return row.clamp(0, height - 1);
|
||||
}
|
||||
|
||||
int _menuX320ToColumn(int x320) {
|
||||
final double scaleX = (isTerminal ? projectionWidth : width) / 320.0;
|
||||
final int offsetX = isTerminal ? projectionOffsetX : 0;
|
||||
final int pixelX = offsetX + (x320 * scaleX).toInt();
|
||||
return pixelX.clamp(0, width - 1);
|
||||
}
|
||||
|
||||
void _drawMinimalMenuText(
|
||||
int selectedDifficultyIndex,
|
||||
int selectedTextColor,
|
||||
int unselectedTextColor,
|
||||
int panelColor,
|
||||
) {
|
||||
const int panelX320 = 28;
|
||||
const int panelW320 = 264;
|
||||
|
||||
final int panelX =
|
||||
projectionOffsetX + ((panelX320 / 320.0) * projectionWidth).toInt();
|
||||
final int panelW = math.max(
|
||||
1,
|
||||
((panelW320 / 320.0) * projectionWidth).toInt(),
|
||||
);
|
||||
final int panelRight = panelX + panelW - 1;
|
||||
final int textLeft = _menuX320ToColumn(70);
|
||||
final int textWidth = math.max(1, panelRight - textLeft);
|
||||
|
||||
const int rowYStart = 86;
|
||||
const int rowStep = 15;
|
||||
for (int i = 0; i < Difficulty.values.length; i++) {
|
||||
final bool isSelected = i == selectedDifficultyIndex;
|
||||
final int baseRowY = _menuY200ToRow(rowYStart + (i * rowStep));
|
||||
final int rowY = i == Difficulty.values.length - 1
|
||||
? (baseRowY + 1).clamp(0, height - 1)
|
||||
: baseRowY;
|
||||
final String rowText = Difficulty.values[i].title;
|
||||
_writeLeftClipped(
|
||||
rowY,
|
||||
rowText,
|
||||
isSelected ? selectedTextColor : unselectedTextColor,
|
||||
panelColor,
|
||||
textWidth,
|
||||
textLeft,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _drawCenteredMenuFooter() {
|
||||
final int hintKeyColor = ColorPalette.vga32Bit[_menuHintKeyPaletteIndex];
|
||||
final int hintLabelColor =
|
||||
ColorPalette.vga32Bit[_menuHintLabelPaletteIndex];
|
||||
final int hintBackground =
|
||||
ColorPalette.vga32Bit[_menuHintBackgroundPaletteIndex];
|
||||
|
||||
final List<(String text, int color)> segments = <(String, int)>[
|
||||
('^/v', hintKeyColor),
|
||||
(' MOVE ', hintLabelColor),
|
||||
('RET', hintKeyColor),
|
||||
(' SELECT ', hintLabelColor),
|
||||
('ESC', hintKeyColor),
|
||||
(' BACK', hintLabelColor),
|
||||
];
|
||||
int textWidth = 0;
|
||||
for (final (text, _) in segments) {
|
||||
textWidth += text.length;
|
||||
}
|
||||
|
||||
final int boxWidth = math.min(width, textWidth + 2);
|
||||
final int boxHeight = 3;
|
||||
final int boxX = ((width - boxWidth) ~/ 2).clamp(0, width - boxWidth);
|
||||
final int boxY = math.max(0, height - boxHeight - 1);
|
||||
|
||||
if (isTerminal) {
|
||||
_fillTerminalRect(
|
||||
boxX,
|
||||
boxY * 2,
|
||||
boxWidth,
|
||||
boxHeight * 2,
|
||||
hintBackground,
|
||||
);
|
||||
} else {
|
||||
_fillRect(boxX, boxY, boxWidth, boxHeight, ' ', hintBackground);
|
||||
}
|
||||
|
||||
final int contentWidth = math.max(0, boxWidth - 2);
|
||||
final int textY = boxY + 1;
|
||||
int textX = boxX + 1;
|
||||
int remaining = contentWidth;
|
||||
for (final (text, color) in segments) {
|
||||
if (remaining <= 0) {
|
||||
break;
|
||||
}
|
||||
final String clipped = text.length <= remaining
|
||||
? text
|
||||
: text.substring(0, remaining);
|
||||
_writeString(textX, textY, clipped, color, hintBackground);
|
||||
textX += clipped.length;
|
||||
remaining -= clipped.length;
|
||||
}
|
||||
}
|
||||
|
||||
void _writeLeftClipped(
|
||||
int y,
|
||||
String text,
|
||||
int color,
|
||||
int backgroundColor,
|
||||
int maxWidth,
|
||||
int left,
|
||||
) {
|
||||
if (y < 0 || y >= height || maxWidth <= 0) {
|
||||
return;
|
||||
}
|
||||
final String clipped = _clipWithEllipsis(text, maxWidth);
|
||||
_writeString(left, y, clipped, color, backgroundColor);
|
||||
}
|
||||
|
||||
String _clipWithEllipsis(String text, int maxWidth) {
|
||||
if (text.length <= maxWidth) {
|
||||
return text;
|
||||
}
|
||||
if (maxWidth <= 3) {
|
||||
return text.substring(0, maxWidth);
|
||||
}
|
||||
return '${text.substring(0, maxWidth - 3)}...';
|
||||
}
|
||||
|
||||
void _plotMenuPixel320(int x320, int y200, int color) {
|
||||
final double scaleX = (isTerminal ? projectionWidth : width) / 320.0;
|
||||
final double scaleY = (isTerminal ? _terminalPixelHeight : height) / 200.0;
|
||||
|
||||
final int offsetX = isTerminal ? projectionOffsetX : 0;
|
||||
final int startX = offsetX + (x320 * scaleX).toInt();
|
||||
final int startY = (y200 * scaleY).toInt();
|
||||
final int pixelW = math.max(1, scaleX.ceil());
|
||||
final int pixelH = math.max(1, scaleY.ceil());
|
||||
if (isTerminal) {
|
||||
final int startY = (y200 * scaleY).toInt();
|
||||
final int pixelH = math.max(1, scaleY.ceil());
|
||||
|
||||
for (int dy = 0; dy < pixelH; dy++) {
|
||||
final int y = startY + dy;
|
||||
if (y < 0 || y >= _terminalPixelHeight) {
|
||||
@@ -601,77 +638,17 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
||||
return;
|
||||
}
|
||||
|
||||
final overlay = _menuTextSubPixels;
|
||||
if (overlay == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final double subScaleX = (width * 2) / 320.0;
|
||||
final double subScaleY = (height * 2) / 200.0;
|
||||
final int startXSub = (x320 * subScaleX).toInt();
|
||||
final int startYSub = (y200 * subScaleY).toInt();
|
||||
final int pixelWSub = math.max(1, subScaleX.ceil());
|
||||
final int pixelHSub = math.max(1, subScaleY.ceil());
|
||||
|
||||
for (int dy = 0; dy < pixelHSub; dy++) {
|
||||
final int y = startYSub + dy;
|
||||
if (y < 0 || y >= overlay.length) {
|
||||
for (int dy = 0; dy < pixelH; dy++) {
|
||||
final int y = startY + dy;
|
||||
if (y < 0 || y >= height) {
|
||||
continue;
|
||||
}
|
||||
for (int dx = 0; dx < pixelWSub; dx++) {
|
||||
final int x = startXSub + dx;
|
||||
if (x < 0 || x >= (width * 2)) {
|
||||
for (int dx = 0; dx < pixelW; dx++) {
|
||||
final int x = startX + dx;
|
||||
if (x < 0 || x >= width) {
|
||||
continue;
|
||||
}
|
||||
overlay[y][x] = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _composeMenuTextSubPixels() {
|
||||
final overlay = _menuTextSubPixels;
|
||||
if (overlay == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (int y = 0; y < height; y++) {
|
||||
final int topY = y * 2;
|
||||
final int bottomY = math.min(topY + 1, overlay.length - 1);
|
||||
|
||||
for (int x = 0; x < width; x++) {
|
||||
final int leftX = x * 2;
|
||||
final int rightX = leftX + 1;
|
||||
|
||||
final int tl = overlay[topY][leftX];
|
||||
final int tr = overlay[topY][rightX];
|
||||
final int bl = overlay[bottomY][leftX];
|
||||
final int br = overlay[bottomY][rightX];
|
||||
|
||||
int mask = 0;
|
||||
if (tl != _unsetMenuSubPixel) mask |= 0x1;
|
||||
if (tr != _unsetMenuSubPixel) mask |= 0x2;
|
||||
if (bl != _unsetMenuSubPixel) mask |= 0x4;
|
||||
if (br != _unsetMenuSubPixel) mask |= 0x8;
|
||||
|
||||
if (mask == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final int baseColor = _screen[y][x].rawColor;
|
||||
final int fgColor = tl != _unsetMenuSubPixel
|
||||
? tl
|
||||
: tr != _unsetMenuSubPixel
|
||||
? tr
|
||||
: bl != _unsetMenuSubPixel
|
||||
? bl
|
||||
: br;
|
||||
final String glyph = _quadrantGlyphByMask[mask] ?? '█';
|
||||
|
||||
if (mask == 0xF) {
|
||||
_screen[y][x] = ColoredChar('█', fgColor);
|
||||
} else {
|
||||
_screen[y][x] = ColoredChar(glyph, fgColor, baseColor);
|
||||
}
|
||||
_screen[y][x] = ColoredChar(activeTheme.solid, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1109,13 +1086,6 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
||||
}
|
||||
}
|
||||
|
||||
int _argbToRawColor(int argb) {
|
||||
int r = (argb >> 16) & 0xFF;
|
||||
int g = (argb >> 8) & 0xFF;
|
||||
int b = argb & 0xFF;
|
||||
return (0xFF000000) | (b << 16) | (g << 8) | r;
|
||||
}
|
||||
|
||||
int _applyDamageFlashToColor(int color) {
|
||||
double intensity = _engine.player.damageFlash;
|
||||
int redBoost = (150 * intensity).toInt();
|
||||
@@ -1211,11 +1181,42 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
int _rgbToPaletteColor(int rgb) {
|
||||
return ColorPalette.vga32Bit[_rgbToPaletteIndex(rgb)];
|
||||
}
|
||||
|
||||
int _rgbToRawColor(int rgb) {
|
||||
final int r = (rgb >> 16) & 0xFF;
|
||||
final int g = (rgb >> 8) & 0xFF;
|
||||
final int b = rgb & 0xFF;
|
||||
// ColoredChar values use the same raw pixel packing as the framebuffer.
|
||||
return (0xFF000000) | (b << 16) | (g << 8) | r;
|
||||
}
|
||||
|
||||
int _rgbToPaletteIndex(int rgb) {
|
||||
final int targetR = (rgb >> 16) & 0xFF;
|
||||
final int targetG = (rgb >> 8) & 0xFF;
|
||||
final int targetB = rgb & 0xFF;
|
||||
|
||||
int bestIndex = 0;
|
||||
int bestDistance = 1 << 30;
|
||||
|
||||
for (int i = 0; i < 256; i++) {
|
||||
final int color = ColorPalette.vga32Bit[i];
|
||||
final int r = color & 0xFF;
|
||||
final int g = (color >> 8) & 0xFF;
|
||||
final int b = (color >> 16) & 0xFF;
|
||||
|
||||
final int dr = targetR - r;
|
||||
final int dg = targetG - g;
|
||||
final int db = targetB - b;
|
||||
final int distance = (dr * dr) + (dg * dg) + (db * db);
|
||||
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
bestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
return bestIndex;
|
||||
}
|
||||
}
|
||||
|
||||
80
packages/wolf_3d_dart/lib/src/rasterizer/menu_font.dart
Normal file
80
packages/wolf_3d_dart/lib/src/rasterizer/menu_font.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
class WolfMenuFont {
|
||||
const WolfMenuFont._();
|
||||
|
||||
static const Map<String, List<String>> glyphs = {
|
||||
'A': ['01110', '10001', '10001', '11111', '10001', '10001', '10001'],
|
||||
'B': ['11110', '10001', '10001', '11110', '10001', '10001', '11110'],
|
||||
'C': ['01110', '10001', '10000', '10000', '10000', '10001', '01110'],
|
||||
'D': ['11110', '10001', '10001', '10001', '10001', '10001', '11110'],
|
||||
'E': ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
|
||||
'F': ['11111', '10000', '10000', '11110', '10000', '10000', '10000'],
|
||||
'G': ['01110', '10001', '10000', '10111', '10001', '10001', '01111'],
|
||||
'H': ['10001', '10001', '10001', '11111', '10001', '10001', '10001'],
|
||||
'I': ['11111', '00100', '00100', '00100', '00100', '00100', '11111'],
|
||||
'J': ['00001', '00001', '00001', '00001', '10001', '10001', '01110'],
|
||||
'K': ['10001', '10010', '10100', '11000', '10100', '10010', '10001'],
|
||||
'L': ['10000', '10000', '10000', '10000', '10000', '10000', '11111'],
|
||||
'M': ['10001', '11011', '10101', '10101', '10001', '10001', '10001'],
|
||||
'N': ['10001', '10001', '11001', '10101', '10011', '10001', '10001'],
|
||||
'O': ['01110', '10001', '10001', '10001', '10001', '10001', '01110'],
|
||||
'P': ['11110', '10001', '10001', '11110', '10000', '10000', '10000'],
|
||||
'Q': ['01110', '10001', '10001', '10001', '10101', '10010', '01101'],
|
||||
'R': ['11110', '10001', '10001', '11110', '10100', '10010', '10001'],
|
||||
'S': ['01111', '10000', '10000', '01110', '00001', '00001', '11110'],
|
||||
'T': ['11111', '00100', '00100', '00100', '00100', '00100', '00100'],
|
||||
'U': ['10001', '10001', '10001', '10001', '10001', '10001', '01110'],
|
||||
'V': ['10001', '10001', '10001', '10001', '10001', '01010', '00100'],
|
||||
'W': ['10001', '10001', '10001', '10101', '10101', '11011', '10001'],
|
||||
'X': ['10001', '10001', '01010', '00100', '01010', '10001', '10001'],
|
||||
'Y': ['10001', '10001', '01010', '00100', '00100', '00100', '00100'],
|
||||
'Z': ['11111', '00001', '00010', '00100', '01000', '10000', '11111'],
|
||||
'0': ['01110', '10001', '10011', '10101', '11001', '10001', '01110'],
|
||||
'1': ['00100', '01100', '00100', '00100', '00100', '00100', '01110'],
|
||||
'2': ['01110', '10001', '00001', '00010', '00100', '01000', '11111'],
|
||||
'3': ['11110', '00001', '00001', '01110', '00001', '00001', '11110'],
|
||||
'4': ['00010', '00110', '01010', '10010', '11111', '00010', '00010'],
|
||||
'5': ['11111', '10000', '10000', '11110', '00001', '00001', '11110'],
|
||||
'6': ['01110', '10000', '10000', '11110', '10001', '10001', '01110'],
|
||||
'7': ['11111', '00001', '00010', '00100', '01000', '10000', '10000'],
|
||||
'8': ['01110', '10001', '10001', '01110', '10001', '10001', '01110'],
|
||||
'9': ['01110', '10001', '10001', '01111', '00001', '00001', '01110'],
|
||||
'?': ['01110', '10001', '00001', '00010', '00100', '00000', '00100'],
|
||||
'!': ['00100', '00100', '00100', '00100', '00100', '00000', '00100'],
|
||||
',': ['00000', '00000', '00000', '00000', '00110', '00100', '01000'],
|
||||
'.': ['00000', '00000', '00000', '00000', '00000', '00110', '00110'],
|
||||
"'": ['00100', '00100', '00100', '00000', '00000', '00000', '00000'],
|
||||
':': ['00000', '00110', '00110', '00000', '00110', '00110', '00000'],
|
||||
'/': ['00001', '00010', '00100', '01000', '10000', '00000', '00000'],
|
||||
'>': ['00000', '10000', '01000', '00100', '01000', '10000', '00000'],
|
||||
'-': ['00000', '00000', '00000', '11111', '00000', '00000', '00000'],
|
||||
' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'],
|
||||
};
|
||||
|
||||
static List<String> glyphFor(String char) {
|
||||
return glyphs[char] ?? glyphs[' ']!;
|
||||
}
|
||||
|
||||
static int glyphAdvance(String char, int scale) {
|
||||
switch (char) {
|
||||
case 'I':
|
||||
case '!':
|
||||
case '.':
|
||||
case ',':
|
||||
case "'":
|
||||
case ':':
|
||||
return 4 * scale;
|
||||
case ' ':
|
||||
return 5 * scale;
|
||||
default:
|
||||
return 6 * scale;
|
||||
}
|
||||
}
|
||||
|
||||
static int measureTextWidth(String text, int scale) {
|
||||
int width = 0;
|
||||
for (final rune in text.runes) {
|
||||
width += glyphAdvance(String.fromCharCode(rune).toUpperCase(), scale);
|
||||
}
|
||||
return width;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
||||
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
|
||||
|
||||
import 'cli_rasterizer.dart';
|
||||
import 'menu_font.dart';
|
||||
|
||||
/// Renders the game into an indexed off-screen buffer and emits Sixel output.
|
||||
///
|
||||
@@ -18,41 +19,13 @@ import 'cli_rasterizer.dart';
|
||||
/// preserving a 4:3 presentation while falling back to size warnings when the
|
||||
/// terminal is too small.
|
||||
class SixelRasterizer extends CliRasterizer<String> {
|
||||
static const Map<String, List<String>> _menuFont = {
|
||||
'A': ['01110', '10001', '10001', '11111', '10001', '10001', '10001'],
|
||||
'B': ['11110', '10001', '10001', '11110', '10001', '10001', '11110'],
|
||||
'C': ['01110', '10001', '10000', '10000', '10000', '10001', '01110'],
|
||||
'D': ['11110', '10001', '10001', '10001', '10001', '10001', '11110'],
|
||||
'E': ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
|
||||
'F': ['11111', '10000', '10000', '11110', '10000', '10000', '10000'],
|
||||
'G': ['01110', '10001', '10000', '10111', '10001', '10001', '01111'],
|
||||
'H': ['10001', '10001', '10001', '11111', '10001', '10001', '10001'],
|
||||
'I': ['11111', '00100', '00100', '00100', '00100', '00100', '11111'],
|
||||
'K': ['10001', '10010', '10100', '11000', '10100', '10010', '10001'],
|
||||
'L': ['10000', '10000', '10000', '10000', '10000', '10000', '11111'],
|
||||
'M': ['10001', '11011', '10101', '10101', '10001', '10001', '10001'],
|
||||
'N': ['10001', '10001', '11001', '10101', '10011', '10001', '10001'],
|
||||
'O': ['01110', '10001', '10001', '10001', '10001', '10001', '01110'],
|
||||
'P': ['11110', '10001', '10001', '11110', '10000', '10000', '10000'],
|
||||
'R': ['11110', '10001', '10001', '11110', '10100', '10010', '10001'],
|
||||
'S': ['01111', '10000', '10000', '01110', '00001', '00001', '11110'],
|
||||
'T': ['11111', '00100', '00100', '00100', '00100', '00100', '00100'],
|
||||
'U': ['10001', '10001', '10001', '10001', '10001', '10001', '01110'],
|
||||
'W': ['10001', '10001', '10001', '10101', '10101', '11011', '10001'],
|
||||
'Y': ['10001', '10001', '01010', '00100', '00100', '00100', '00100'],
|
||||
'?': ['01110', '10001', '00001', '00010', '00100', '00000', '00100'],
|
||||
'!': ['00100', '00100', '00100', '00100', '00100', '00000', '00100'],
|
||||
',': ['00000', '00000', '00000', '00000', '00110', '00100', '01000'],
|
||||
'.': ['00000', '00000', '00000', '00000', '00000', '00110', '00110'],
|
||||
"'": ['00100', '00100', '00100', '00000', '00000', '00000', '00000'],
|
||||
' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'],
|
||||
};
|
||||
|
||||
static const double _targetAspectRatio = 4 / 3;
|
||||
static const int _defaultLineHeightPx = 18;
|
||||
static const double _defaultCellWidthToHeight = 0.55;
|
||||
static const int _minimumTerminalColumns = 117;
|
||||
static const int _minimumTerminalRows = 34;
|
||||
static const int _compactMenuMinWidthPx = 200;
|
||||
static const int _compactMenuMinHeightPx = 130;
|
||||
static const int _maxRenderWidth = 320;
|
||||
static const int _maxRenderHeight = 240;
|
||||
static const String _terminalTealBackground = '\x1b[48;2;0;150;136m';
|
||||
@@ -374,7 +347,17 @@ class SixelRasterizer extends CliRasterizer<String> {
|
||||
_fillRect320(28, 70, 264, 82, panelColor);
|
||||
|
||||
final art = WolfClassicMenuArt(engine.data);
|
||||
_drawMenuTextCentered('HOW TOUGH ARE YOU?', 48, headingIndex, scale: 2);
|
||||
if (_useCompactMenuLayout) {
|
||||
_drawCompactMenu(selectedDifficultyIndex, headingIndex, panelColor);
|
||||
return;
|
||||
}
|
||||
|
||||
_drawMenuTextCentered(
|
||||
Difficulty.menuText,
|
||||
48,
|
||||
headingIndex,
|
||||
scale: _menuHeadingScale,
|
||||
);
|
||||
|
||||
final bottom = art.pic(15);
|
||||
if (bottom != null) {
|
||||
@@ -394,12 +377,6 @@ class SixelRasterizer extends CliRasterizer<String> {
|
||||
const rowYStart = 86;
|
||||
const rowStep = 15;
|
||||
const textX = 70;
|
||||
const labels = [
|
||||
'CAN I PLAY, DADDY?',
|
||||
"DON'T HURT ME.",
|
||||
"BRING 'EM ON!",
|
||||
'I AM DEATH INCARNATE!',
|
||||
];
|
||||
for (int i = 0; i < Difficulty.values.length; i++) {
|
||||
final y = rowYStart + (i * rowStep);
|
||||
final isSelected = i == selectedDifficultyIndex;
|
||||
@@ -409,10 +386,41 @@ class SixelRasterizer extends CliRasterizer<String> {
|
||||
}
|
||||
|
||||
_drawMenuText(
|
||||
labels[i],
|
||||
Difficulty.values[i].title,
|
||||
textX,
|
||||
y,
|
||||
isSelected ? selectedTextIndex : unselectedTextIndex,
|
||||
scale: _menuOptionScale,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool get _useCompactMenuLayout =>
|
||||
width < _compactMenuMinWidthPx || height < _compactMenuMinHeightPx;
|
||||
|
||||
int get _menuHeadingScale => width < 250 ? 1 : 2;
|
||||
|
||||
int get _menuOptionScale => width < 220 ? 1 : 1;
|
||||
|
||||
void _drawCompactMenu(
|
||||
int selectedDifficultyIndex,
|
||||
int headingIndex,
|
||||
int panelColor,
|
||||
) {
|
||||
_fillRect320(16, 52, 288, 112, panelColor);
|
||||
_drawMenuTextCentered(Difficulty.menuText, 60, headingIndex, scale: 1);
|
||||
|
||||
const int rowYStart = 86;
|
||||
const int rowStep = 13;
|
||||
for (int i = 0; i < Difficulty.values.length; i++) {
|
||||
final bool isSelected = i == selectedDifficultyIndex;
|
||||
final String prefix = isSelected ? '> ' : ' ';
|
||||
_drawMenuText(
|
||||
prefix + Difficulty.values[i].title,
|
||||
42,
|
||||
rowYStart + (i * rowStep),
|
||||
isSelected ? 19 : 23,
|
||||
scale: 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -430,7 +438,7 @@ class SixelRasterizer extends CliRasterizer<String> {
|
||||
int x320 = startX;
|
||||
for (final rune in text.runes) {
|
||||
final char = String.fromCharCode(rune).toUpperCase();
|
||||
final pattern = _menuFont[char] ?? _menuFont[' ']!;
|
||||
final pattern = WolfMenuFont.glyphFor(char);
|
||||
|
||||
for (int row = 0; row < pattern.length; row++) {
|
||||
final bits = pattern[row];
|
||||
@@ -452,7 +460,7 @@ class SixelRasterizer extends CliRasterizer<String> {
|
||||
}
|
||||
}
|
||||
|
||||
x320 += (6 * scale);
|
||||
x320 += WolfMenuFont.glyphAdvance(char, scale);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,7 +470,7 @@ class SixelRasterizer extends CliRasterizer<String> {
|
||||
int colorIndex, {
|
||||
int scale = 1,
|
||||
}) {
|
||||
final int textWidth = text.length * 6 * scale;
|
||||
final int textWidth = WolfMenuFont.measureTextWidth(text, scale);
|
||||
final int x = ((320 - textWidth) ~/ 2).clamp(0, 319);
|
||||
_drawMenuText(text, x, y, colorIndex, scale: scale);
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
|
||||
}
|
||||
|
||||
final art = WolfClassicMenuArt(engine.data);
|
||||
_drawMenuTextCentered('HOW TOUGH ARE YOU?', 48, headingColor, scale: 2);
|
||||
_drawMenuTextCentered(Difficulty.menuText, 48, headingColor, scale: 2);
|
||||
|
||||
final bottom = art.pic(15);
|
||||
if (bottom != null) {
|
||||
@@ -228,13 +228,6 @@ class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
|
||||
const rowYStart = panelY + 16;
|
||||
const rowStep = 15;
|
||||
const textX = panelX + 42;
|
||||
const labels = [
|
||||
'CAN I PLAY, DADDY?',
|
||||
"DON'T HURT ME.",
|
||||
"BRING 'EM ON!",
|
||||
'I AM DEATH INCARNATE!',
|
||||
];
|
||||
|
||||
for (int i = 0; i < Difficulty.values.length; i++) {
|
||||
final y = rowYStart + (i * rowStep);
|
||||
final isSelected = i == selectedDifficultyIndex;
|
||||
@@ -244,7 +237,7 @@ class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
|
||||
}
|
||||
|
||||
_drawMenuText(
|
||||
labels[i],
|
||||
Difficulty.values[i].title,
|
||||
textX,
|
||||
y,
|
||||
isSelected ? selectedTextColor : unselectedTextColor,
|
||||
|
||||
@@ -84,26 +84,47 @@ class AsciiFrameWidget extends StatelessWidget {
|
||||
// Merge adjacent cells with the same color to keep the rich
|
||||
// text tree smaller and reduce per-frame layout overhead.
|
||||
Color currentColor = Color(row[0].argb);
|
||||
Color? currentBackground = row[0].rawBackgroundColor == null
|
||||
? null
|
||||
: Color(
|
||||
0xFF000000 | (row[0].rawBackgroundColor! & 0x00FFFFFF),
|
||||
);
|
||||
StringBuffer currentSegment = StringBuffer(row[0].char);
|
||||
|
||||
for (int i = 1; i < row.length; i++) {
|
||||
if (Color(row[i].argb) == currentColor) {
|
||||
final Color nextColor = Color(row[i].argb);
|
||||
final Color? nextBackground =
|
||||
row[i].rawBackgroundColor == null
|
||||
? null
|
||||
: Color(
|
||||
0xFF000000 |
|
||||
(row[i].rawBackgroundColor! & 0x00FFFFFF),
|
||||
);
|
||||
if (nextColor == currentColor &&
|
||||
nextBackground == currentBackground) {
|
||||
currentSegment.write(row[i].char);
|
||||
} else {
|
||||
optimizedSpans.add(
|
||||
TextSpan(
|
||||
text: currentSegment.toString(),
|
||||
style: TextStyle(color: currentColor),
|
||||
style: TextStyle(
|
||||
color: currentColor,
|
||||
backgroundColor: currentBackground,
|
||||
),
|
||||
),
|
||||
);
|
||||
currentColor = Color(row[i].argb);
|
||||
currentColor = nextColor;
|
||||
currentBackground = nextBackground;
|
||||
currentSegment = StringBuffer(row[i].char);
|
||||
}
|
||||
}
|
||||
optimizedSpans.add(
|
||||
TextSpan(
|
||||
text: currentSegment.toString(),
|
||||
style: TextStyle(color: currentColor),
|
||||
style: TextStyle(
|
||||
color: currentColor,
|
||||
backgroundColor: currentBackground,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user