feat: Implement player locomotion constants and update movement logic in engine

feat: Add key icons to HUD modules and implement key rendering in HUD
test: Add player movement and rotation parity tests to ensure consistency with classic Wolf3D
test: Enhance HUD rendering tests for gold and silver key icons

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-23 12:04:25 +01:00
parent 7941c2902c
commit 604923618a
15 changed files with 434 additions and 28 deletions
@@ -0,0 +1,41 @@
import 'dart:math' as math;
/// Canonical player movement and rotation constants derived directly from the
/// original Wolfenstein 3D source code (WL_PLAY.C / WL_AGENT.C).
///
/// Source constants (keyboard, walking state):
/// BASEMOVE = 35, RUNMOVE = 70
/// MOVESCALE = 150, BACKMOVESCALE = 100
/// BASETURN = 35, RUNTURN = 70, ANGLESCALE = 20
/// TILEGLOBAL = 65536 (fixed-point units per tile), TICRATE = 70
abstract final class PlayerLocomotionConstants {
static const double basemove = 35.0;
static const double runmove = 70.0;
static const double movescale = 150.0;
static const double backmovescale = 100.0;
static const double baseturn = 35.0;
static const double runturn = 70.0;
static const double anglescale = 20.0;
static const double tileglobal = 65536.0;
static const double ticrate = 70.0;
/// Walking forward speed in tiles per second.
///
/// Derived from: `(BASEMOVE * MOVESCALE * TICRATE) / TILEGLOBAL`
static const double forwardTilesPerSecond =
(basemove * movescale * ticrate) / tileglobal;
/// Walking backward speed in tiles per second (intentionally slower than
/// forward in the original game).
///
/// Derived from: `(BASEMOVE * BACKMOVESCALE * TICRATE) / TILEGLOBAL`
static const double backwardTilesPerSecond =
(basemove * backmovescale * ticrate) / tileglobal;
/// Walking turn rate in radians per second.
///
/// Derived from: `(BASETURN / ANGLESCALE) * TICRATE` in degrees/sec,
/// then converted to radians.
static const double turnRadiansPerSecond =
(baseturn / anglescale) * ticrate * (math.pi / 180.0);
}
@@ -877,9 +877,15 @@ class WolfEngine {
// Standardize movement to 60 FPS (16.66ms per frame)
final double timeScale = delta.inMilliseconds / 16.666;
// Apply the timeScale multiplier to ensure consistent speed at any framerate
final double moveSpeed = 0.10 * timeScale;
final double turnSpeed = 0.08 * timeScale;
// Apply the timeScale multiplier to ensure consistent speed at any framerate.
// Rates are derived from the canonical Wolf3D source constants; see
// PlayerLocomotionConstants for the full derivation.
final double forwardMoveSpeed =
PlayerLocomotionConstants.forwardTilesPerSecond / 60.0 * timeScale;
final double backwardMoveSpeed =
PlayerLocomotionConstants.backwardTilesPerSecond / 60.0 * timeScale;
final double turnSpeed =
PlayerLocomotionConstants.turnRadiansPerSecond / 60.0 * timeScale;
Coordinate2D movement = const Coordinate2D(0, 0);
double dAngle = 0.0;
@@ -910,8 +916,8 @@ class WolfEngine {
math.cos(player.angle),
math.sin(player.angle),
);
if (input.isMovingForward) movement += forwardVec * moveSpeed;
if (input.isMovingBackward) movement -= forwardVec * moveSpeed;
if (input.isMovingForward) movement += forwardVec * forwardMoveSpeed;
if (input.isMovingBackward) movement -= forwardVec * backwardMoveSpeed;
if (input.isInteracting) {
int targetX = (player.x + math.cos(player.angle)).toInt();
@@ -48,6 +48,9 @@ class RetailHudModule extends HudModule {
HudKey.pistolIcon: 89,
HudKey.machineGunIcon: 90,
HudKey.chainGunIcon: 91,
HudKey.noKeyIcon: 92,
HudKey.goldKeyIcon: 93,
HudKey.silverKeyIcon: 94,
};
@override
@@ -47,6 +47,9 @@ class SharewareHudModule extends HudModule {
HudKey.pistolIcon: 89,
HudKey.machineGunIcon: 90,
HudKey.chainGunIcon: 91,
HudKey.noKeyIcon: 92,
HudKey.goldKeyIcon: 93,
HudKey.silverKeyIcon: 94,
};
int? _offset;
@@ -65,7 +68,8 @@ class SharewareHudModule extends HudModule {
return 0;
}
int get _effectiveOffset => useOriginalWl1Map ? _originalWl1Offset : (_offset ?? 0);
int get _effectiveOffset =>
useOriginalWl1Map ? _originalWl1Offset : (_offset ?? 0);
@override
HudAssetRef? resolve(HudKey key) {
@@ -40,6 +40,9 @@ class SpearDemoHudModule extends HudModule {
HudKey.pistolIcon: 77, // GUNPIC
HudKey.machineGunIcon: 78, // MACHINEGUNPIC
HudKey.chainGunIcon: 79, // GATLINGGUNPIC
HudKey.noKeyIcon: 80,
HudKey.goldKeyIcon: 81,
HudKey.silverKeyIcon: 82,
};
@override
@@ -28,7 +28,12 @@ enum HudKey {
// --- Weapon icons ---
pistolIcon('pistolIcon'),
machineGunIcon('machineGunIcon'),
chainGunIcon('chainGunIcon')
chainGunIcon('chainGunIcon'),
// --- Key icons ---
noKeyIcon('noKeyIcon'),
goldKeyIcon('goldKeyIcon'),
silverKeyIcon('silverKeyIcon')
;
const HudKey(this.id);
@@ -416,11 +416,15 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
final int mapBgColor = ColorPalette.vga32Bit[0];
final int floorColor = ColorPalette.vga32Bit[8];
final int wallColor = ColorPalette.vga32Bit[7];
final int doorColor = ColorPalette.vga32Bit[14];
final int doorColor = ColorPalette.vga32Bit[9];
final int pushwallColor = ColorPalette.vga32Bit[6];
final int normalExitColor = ColorPalette.vga32Bit[2];
final int secretExitColor = ColorPalette.vga32Bit[10];
final int goldKeyColor = ColorPalette.vga32Bit[11];
final int silverKeyColor = ColorPalette.vga32Bit[16];
final int enemyColor = ColorPalette.vga32Bit[12];
final int playerColor = ColorPalette.vga32Bit[10];
final int facingColor = ColorPalette.vga32Bit[15];
final int playerColor = ColorPalette.vga32Bit[15];
final int facingColor = ColorPalette.vga32Bit[14];
final int viewportY = 0;
final int viewportX = _usesTerminalLayout ? projectionOffsetX : 0;
@@ -471,15 +475,24 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
for (int y = 0; y < 64; y++) {
for (int x = 0; x < 64; x++) {
final int tileId = engine.currentLevel[y][x];
final int wallTile = engine.currentLevel[y][x];
final int objTile = engine.activeLevel.objectGrid[y][x];
final bool isPushwall = engine.pushwallManager.pushwalls.containsKey(
'$x,$y',
);
final int color = isPushwall
? pushwallColor
: (tileId == 0
? floorColor
: (tileId >= 90 ? doorColor : wallColor));
: objTile == MapObject.normalExitTrigger
? normalExitColor
: objTile == MapObject.secretExitTrigger
? secretExitColor
: objTile == MapObject.goldKey
? goldKeyColor
: objTile == MapObject.silverKey
? silverKeyColor
: (wallTile == 0
? floorColor
: (wallTile >= 90 ? doorColor : wallColor));
_fillMapRect(
mapStartX + (x * tileSize),
mapStartY + (y * tileSize),
@@ -184,6 +184,7 @@ abstract class RendererBackend<T>
_drawHudNumber(engine, vgaImages, engine.player.ammo, 232, 176);
_drawHudFace(engine, vgaImages);
_drawHudWeaponIcon(engine, vgaImages);
_drawHudKeys(engine, vgaImages);
}
void _drawHudNumber(
@@ -243,6 +244,41 @@ abstract class RendererBackend<T>
}
}
void _drawHudKeys(WolfEngine engine, List<VgaImage> vgaImages) {
_drawHudKeySlot(
engine,
vgaImages,
startX: 30,
startY: 164,
hasKey: engine.player.hasGoldKey,
presentKey: HudKey.goldKeyIcon,
);
_drawHudKeySlot(
engine,
vgaImages,
startX: 30,
startY: 180,
hasKey: engine.player.hasSilverKey,
presentKey: HudKey.silverKeyIcon,
);
}
void _drawHudKeySlot(
WolfEngine engine,
List<VgaImage> vgaImages, {
required int startX,
required int startY,
required bool hasKey,
required HudKey presentKey,
}) {
final HudKey keyIcon = hasKey ? presentKey : HudKey.noKeyIcon;
final keyRef = engine.data.registry.hud.resolve(keyIcon);
final int keyIndex = keyRef?.vgaIndex ?? -1;
if (keyIndex >= 0 && keyIndex < vgaImages.length) {
blitHudVgaImage(vgaImages[keyIndex], startX, startY);
}
}
/// Calculates depth-based lighting falloff (0.0 to 1.0).
/// While the original Wolf3D didn't use depth fog, this provides a great
/// atmospheric effect for custom backends (like ASCII dithering).
@@ -370,11 +370,15 @@ class SixelRenderer extends CliRendererBackend<String> {
const int mapBgColor = 0;
const int floorColor = 8;
const int wallColor = 7;
const int doorColor = 14;
const int doorColor = 9;
const int pushwallColor = 6;
const int normalExitColor = 2;
const int secretExitColor = 10;
const int goldKeyColor = 11;
const int silverKeyColor = 16;
const int enemyColor = 12;
const int playerColor = 10;
const int facingColor = 15;
const int playerColor = 15;
const int facingColor = 14;
_fillMapRect(0, 0, width, viewHeight, mapBgColor);
@@ -395,15 +399,24 @@ class SixelRenderer extends CliRendererBackend<String> {
for (int y = 0; y < 64; y++) {
for (int x = 0; x < 64; x++) {
final int tileId = engine.currentLevel[y][x];
final int wallTile = engine.currentLevel[y][x];
final int objTile = engine.activeLevel.objectGrid[y][x];
final bool isPushwall = engine.pushwallManager.pushwalls.containsKey(
'$x,$y',
);
final int color = isPushwall
? pushwallColor
: (tileId == 0
? floorColor
: (tileId >= 90 ? doorColor : wallColor));
: objTile == MapObject.normalExitTrigger
? normalExitColor
: objTile == MapObject.secretExitTrigger
? secretExitColor
: objTile == MapObject.goldKey
? goldKeyColor
: objTile == MapObject.silverKey
? silverKeyColor
: (wallTile == 0
? floorColor
: (wallTile >= 90 ? doorColor : wallColor));
_fillMapRect(
mapStartX + (x * tileSize),
mapStartY + (y * tileSize),
@@ -150,11 +150,15 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
final int mapBgColor = ColorPalette.vga32Bit[0];
final int floorColor = ColorPalette.vga32Bit[8];
final int wallColor = ColorPalette.vga32Bit[7];
final int doorColor = ColorPalette.vga32Bit[14];
final int doorColor = ColorPalette.vga32Bit[9];
final int pushwallColor = ColorPalette.vga32Bit[6];
final int normalExitColor = ColorPalette.vga32Bit[2];
final int secretExitColor = ColorPalette.vga32Bit[10];
final int goldKeyColor = ColorPalette.vga32Bit[11];
final int silverKeyColor = ColorPalette.vga32Bit[16];
final int enemyColor = ColorPalette.vga32Bit[12];
final int playerColor = ColorPalette.vga32Bit[10];
final int facingColor = ColorPalette.vga32Bit[15];
final int playerColor = ColorPalette.vga32Bit[15];
final int facingColor = ColorPalette.vga32Bit[14];
_fillMenuPanel(0, 0, width, viewHeight, mapBgColor);
@@ -175,15 +179,20 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
for (int y = 0; y < 64; y++) {
for (int x = 0; x < 64; x++) {
final int tileId = engine.currentLevel[y][x];
final int wallTile = engine.currentLevel[y][x];
final int objTile = engine.activeLevel.objectGrid[y][x];
final bool isPushwall = engine.pushwallManager.pushwalls.containsKey(
'$x,$y',
);
final int color = isPushwall
? pushwallColor
: (tileId == 0
: objTile == MapObject.normalExitTrigger
? normalExitColor
: objTile == MapObject.secretExitTrigger
? secretExitColor
: (wallTile == 0
? floorColor
: (tileId >= 90 ? doorColor : wallColor));
: (wallTile >= 90 ? doorColor : wallColor));
_fillMenuPanel(
mapStartX + (x * tileSize),
mapStartY + (y * tileSize),