WIP pushing menu to game engine - improving menu text clarity in ASCII mode

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-18 12:45:04 +01:00
parent e39dfd5da0
commit dc1acb7a2f
9 changed files with 284 additions and 463 deletions

View File

@@ -1,412 +0,0 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/src/rasterizer/rasterizer.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
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'],
};
late FrameBuffer _buffer;
late WolfEngine _engine;
// Intercept the base render call to store our references
@override
FrameBuffer render(WolfEngine engine) {
_engine = engine;
_buffer = engine.frameBuffer;
return super.render(engine);
}
@override
void prepareFrame(WolfEngine engine) {
// Top half is ceiling color (25), bottom half is floor color (29)
int ceilingColor = ColorPalette.vga32Bit[25];
int floorColor = ColorPalette.vga32Bit[29];
for (int y = 0; y < viewHeight; y++) {
int color = (y < viewHeight / 2) ? ceilingColor : floorColor;
for (int x = 0; x < width; x++) {
_buffer.pixels[y * width + x] = color;
}
}
}
@override
void drawWallColumn(
int x,
int drawStart,
int drawEnd,
int columnHeight,
Sprite texture,
int texX,
double perpWallDist,
int side,
) {
for (int y = drawStart; y < drawEnd; y++) {
// Calculate which Y pixel of the texture to sample
double relativeY =
(y - (-columnHeight ~/ 2 + viewHeight ~/ 2)) / columnHeight;
int texY = (relativeY * 64).toInt().clamp(0, 63);
int colorByte = texture.pixels[texX * 64 + texY];
int pixelColor = ColorPalette.vga32Bit[colorByte];
// Darken Y-side walls for faux directional lighting
if (side == 1) {
pixelColor = shadeColor(pixelColor);
}
_buffer.pixels[y * width + x] = pixelColor;
}
}
@override
void drawSpriteStripe(
int stripeX,
int drawStartY,
int drawEndY,
int spriteHeight,
Sprite texture,
int texX,
double transformY,
) {
for (
int y = math.max(0, drawStartY);
y < math.min(viewHeight, drawEndY);
y++
) {
double relativeY = (y - drawStartY) / spriteHeight;
int texY = (relativeY * 64).toInt().clamp(0, 63);
int colorByte = texture.pixels[texX * 64 + texY];
// 255 is the "transparent" color index in VGA Wolfenstein
if (colorByte != 255) {
_buffer.pixels[y * width + stripeX] = ColorPalette.vga32Bit[colorByte];
}
}
}
@override
void drawWeapon(WolfEngine engine) {
int spriteIndex = engine.player.currentWeapon.getCurrentSpriteIndex(
engine.data.sprites.length,
);
Sprite weaponSprite = engine.data.sprites[spriteIndex];
int weaponWidth = (width * 0.5).toInt();
int weaponHeight = (viewHeight * 0.8).toInt();
int startX = (width ~/ 2) - (weaponWidth ~/ 2);
int startY =
viewHeight - weaponHeight + (engine.player.weaponAnimOffset ~/ 4);
for (int dy = 0; dy < weaponHeight; dy++) {
for (int dx = 0; dx < weaponWidth; dx++) {
int texX = (dx * 64 ~/ weaponWidth).clamp(0, 63);
int texY = (dy * 64 ~/ weaponHeight).clamp(0, 63);
int colorByte = weaponSprite.pixels[texX * 64 + texY];
if (colorByte != 255) {
int drawX = startX + dx;
int drawY = startY + dy;
if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < viewHeight) {
_buffer.pixels[drawY * width + drawX] =
ColorPalette.vga32Bit[colorByte];
}
}
}
}
}
@override
void drawHud(WolfEngine engine) {
int statusBarIndex = engine.data.vgaImages.indexWhere(
(img) => img.width == 320 && img.height == 40,
);
if (statusBarIndex == -1) return;
// 1. Draw Background
_blitVgaImage(engine.data.vgaImages[statusBarIndex], 0, 160);
// 2. Draw Stats (100% mathematically accurate right-aligned coordinates)
_drawNumber(1, 32, 176, engine.data.vgaImages); // Floor
_drawNumber(engine.player.score, 96, 176, engine.data.vgaImages); // Score
_drawNumber(3, 120, 176, engine.data.vgaImages); // Lives
_drawNumber(
engine.player.health,
192,
176,
engine.data.vgaImages,
); // Health
_drawNumber(engine.player.ammo, 232, 176, engine.data.vgaImages); // Ammo
// 3. Draw BJ's Face & Current Weapon
_drawFace(engine);
_drawWeaponIcon(engine);
}
@override
void drawMenu(WolfEngine engine) {
final int bgColor = ColorPalette.vga32Bit[153];
final int panelColor = ColorPalette.vga32Bit[157];
final int headingColor = ColorPalette.vga32Bit[119];
final int selectedTextColor = ColorPalette.vga32Bit[19];
final int unselectedTextColor = ColorPalette.vga32Bit[23];
for (int i = 0; i < _buffer.pixels.length; i++) {
_buffer.pixels[i] = bgColor;
}
const panelX = 28;
const panelY = 70;
const panelW = 264;
const panelH = 82;
for (int y = panelY; y < panelY + panelH; y++) {
if (y < 0 || y >= height) continue;
final rowStart = y * width;
for (int x = panelX; x < panelX + panelW; x++) {
if (x >= 0 && x < width) {
_buffer.pixels[rowStart + x] = panelColor;
}
}
}
final art = WolfClassicMenuArt(engine.data);
_drawMenuTextCentered('HOW TOUGH ARE YOU?', 48, headingColor, scale: 2);
final bottom = art.pic(15);
if (bottom != null) {
final x = (width - bottom.width) ~/ 2;
final y = height - bottom.height - 8;
_blitVgaImage(bottom, x, y);
}
final face = art.difficultyOption(
Difficulty.values[engine.menuSelectedDifficultyIndex],
);
if (face != null) {
_blitVgaImage(face, panelX + panelW - face.width - 10, panelY + 22);
}
final cursor = art.pic(engine.isMenuCursorAltFrame ? 9 : 8);
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 == engine.menuSelectedDifficultyIndex;
if (isSelected && cursor != null) {
_blitVgaImage(cursor, panelX + 10, y - 2);
}
_drawMenuText(
labels[i],
textX,
y,
isSelected ? selectedTextColor : unselectedTextColor,
);
}
}
void _drawMenuText(
String text,
int startX,
int startY,
int color, {
int scale = 1,
}) {
int x = startX;
for (final rune in text.runes) {
final char = String.fromCharCode(rune).toUpperCase();
final pattern = _menuFont[char] ?? _menuFont[' ']!;
for (int row = 0; row < pattern.length; row++) {
final bits = pattern[row];
for (int col = 0; col < bits.length; col++) {
if (bits[col] != '1') continue;
for (int sy = 0; sy < scale; sy++) {
for (int sx = 0; sx < scale; sx++) {
final drawX = x + (col * scale) + sx;
final drawY = startY + (row * scale) + sy;
if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) {
_buffer.pixels[drawY * width + drawX] = color;
}
}
}
}
}
x += (6 * scale);
}
}
void _drawMenuTextCentered(
String text,
int y,
int color, {
int scale = 1,
}) {
final textWidth = text.length * 6 * scale;
final x = ((width - textWidth) ~/ 2).clamp(0, width - 1);
_drawMenuText(text, x, y, color, scale: scale);
}
@override
FrameBuffer finalizeFrame() {
// If the player took damage, overlay a red tint across the 3D view
if (!_engine.isDifficultySelectionPending &&
_engine.player.damageFlash > 0) {
_applyDamageFlash();
}
return _buffer; // Return the fully painted pixel array
}
// ===========================================================================
// PRIVATE HELPER METHODS
// ===========================================================================
/// Maps the planar VGA image data directly to 32-bit pixels.
/// (Assuming a 1:1 scale, which is standard for the 320x200 software renderer).
void _blitVgaImage(VgaImage image, int startX, int startY) {
int planeWidth = image.width ~/ 4;
int planeSize = planeWidth * image.height;
for (int dy = 0; dy < image.height; dy++) {
for (int dx = 0; dx < image.width; dx++) {
int drawX = startX + dx;
int drawY = startY + dy;
if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) {
int srcX = dx.clamp(0, image.width - 1);
int srcY = dy.clamp(0, image.height - 1);
int plane = srcX % 4;
int sx = srcX ~/ 4;
int index = (plane * planeSize) + (srcY * planeWidth) + sx;
int colorByte = image.pixels[index];
if (colorByte != 255) {
_buffer.pixels[drawY * width + drawX] =
ColorPalette.vga32Bit[colorByte];
}
}
}
}
}
void _drawNumber(
int value,
int rightAlignX,
int startY,
List<VgaImage> vgaImages,
) {
const int zeroIndex = 96;
String numStr = value.toString();
int currentX = rightAlignX - (numStr.length * 8);
for (int i = 0; i < numStr.length; i++) {
int digit = int.parse(numStr[i]);
if (zeroIndex + digit < vgaImages.length) {
_blitVgaImage(vgaImages[zeroIndex + digit], currentX, startY);
}
currentX += 8;
}
}
void _drawFace(WolfEngine engine) {
int health = engine.player.health;
int faceIndex;
if (health <= 0) {
faceIndex = 127; // Dead face
} else {
int healthTier = ((100 - health) ~/ 16).clamp(0, 6);
faceIndex = 106 + (healthTier * 3);
}
if (faceIndex < engine.data.vgaImages.length) {
_blitVgaImage(engine.data.vgaImages[faceIndex], 136, 164);
}
}
void _drawWeaponIcon(WolfEngine engine) {
int weaponIndex = 89; // Default to Pistol
if (engine.player.hasChainGun) {
weaponIndex = 91;
} else if (engine.player.hasMachineGun) {
weaponIndex = 90;
}
if (weaponIndex < engine.data.vgaImages.length) {
_blitVgaImage(engine.data.vgaImages[weaponIndex], 256, 164);
}
}
/// Tints the top 80% of the screen red based on player.damageFlash intensity
void _applyDamageFlash() {
// Grab the intensity (0.0 to 1.0)
double intensity = _engine.player.damageFlash;
// Calculate how much to boost red and drop green/blue
int redBoost = (150 * intensity).toInt();
double colorDrop = 1.0 - (0.5 * intensity);
for (int y = 0; y < viewHeight; y++) {
for (int x = 0; x < width; x++) {
int index = y * width + x;
int color = _buffer.pixels[index];
int r = color & 0xFF;
int g = (color >> 8) & 0xFF;
int b = (color >> 16) & 0xFF;
r = (r + redBoost).clamp(0, 255);
g = (g * colorDrop).toInt();
b = (b * colorDrop).toInt();
_buffer.pixels[index] = (0xFF000000) | (b << 16) | (g << 8) | r;
}
}
}
}

View File

@@ -45,17 +45,11 @@ class WolfEngine {
/// The active difficulty level, affecting enemy spawning and behavior.
Difficulty? difficulty;
/// Whether the engine is waiting on player difficulty selection.
bool get isDifficultySelectionPending => difficulty == null;
/// Menu state owner for difficulty-selection navigation and edge detection.
final MenuManager menuManager = MenuManager();
/// Cursor index used by renderer-side difficulty menus.
int get menuSelectedDifficultyIndex => menuManager.selectedDifficultyIndex;
/// Cursor blink phase used by renderer-side difficulty menus.
bool get isMenuCursorAltFrame => menuManager.isCursorAltFrame(_timeAliveMs);
/// Elapsed engine lifetime in milliseconds.
int get timeAliveMs => _timeAliveMs;
/// The episode index where the game session begins.
final int startingEpisode;
@@ -119,7 +113,7 @@ class WolfEngine {
menuManager.beginDifficultySelection(initialDifficulty: difficulty);
if (!isDifficultySelectionPending) {
if (difficulty != null) {
_loadLevel();
}
@@ -151,7 +145,7 @@ class WolfEngine {
input.update();
final currentInput = input.currentInput;
if (isDifficultySelectionPending) {
if (difficulty == null) {
_tickDifficultyMenu(currentInput);
return;
}