WIP moving difficulty selection to engine
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
@@ -46,7 +46,6 @@ void main() async {
|
|||||||
|
|
||||||
final engine = WolfEngine(
|
final engine = WolfEngine(
|
||||||
data: availableGames.values.first,
|
data: availableGames.values.first,
|
||||||
difficulty: Difficulty.medium,
|
|
||||||
startingEpisode: 0,
|
startingEpisode: 0,
|
||||||
frameBuffer: FrameBuffer(
|
frameBuffer: FrameBuffer(
|
||||||
stdout.terminalColumns,
|
stdout.terminalColumns,
|
||||||
|
|||||||
@@ -106,7 +106,8 @@ class CliGameLoop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleInput(List<int> bytes) {
|
void _handleInput(List<int> bytes) {
|
||||||
if (bytes.contains(113) || bytes.contains(27)) {
|
// Keep q and Ctrl+C as hard exits; ESC is now menu-back input.
|
||||||
|
if (bytes.contains(113) || bytes.contains(3)) {
|
||||||
stop();
|
stop();
|
||||||
onExit(0);
|
onExit(0);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
/// Difficulty picker shown after the player chooses an episode.
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
|
||||||
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
|
|
||||||
import 'package:wolf_3d_gui/screens/game_screen.dart';
|
|
||||||
|
|
||||||
/// Starts a new game session using the active game and episode from [Wolf3d].
|
|
||||||
class DifficultyScreen extends StatefulWidget {
|
|
||||||
/// Shared application facade carrying the active game, input, and audio.
|
|
||||||
final Wolf3d wolf3d;
|
|
||||||
|
|
||||||
/// Creates the difficulty-selection screen for [wolf3d].
|
|
||||||
const DifficultyScreen({
|
|
||||||
super.key,
|
|
||||||
required this.wolf3d,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<DifficultyScreen> createState() => _DifficultyScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DifficultyScreenState extends State<DifficultyScreen> {
|
|
||||||
bool get isShareware =>
|
|
||||||
widget.wolf3d.activeGame.version == GameVersion.shareware;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
widget.wolf3d.audio.stopMusic();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replaces the menu flow with an active [GameScreen] using [difficulty].
|
|
||||||
void _startGame(Difficulty difficulty, {bool showGallery = false}) {
|
|
||||||
widget.wolf3d.audio.stopMusic();
|
|
||||||
|
|
||||||
Navigator.of(context).pushReplacement(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => GameScreen(
|
|
||||||
data: widget.wolf3d.activeGame,
|
|
||||||
difficulty: difficulty,
|
|
||||||
startingEpisode: widget.wolf3d.activeEpisode,
|
|
||||||
audio: widget.wolf3d.audio,
|
|
||||||
input: widget.wolf3d.input,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
floatingActionButton: FloatingActionButton(
|
|
||||||
backgroundColor: Colors.red[900],
|
|
||||||
onPressed: () => _startGame(Difficulty.medium, showGallery: true),
|
|
||||||
child: const Icon(Icons.bug_report, color: Colors.white),
|
|
||||||
),
|
|
||||||
body: Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'HOW TOUGH ARE YOU?',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.red,
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontFamily: 'Courier',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 40),
|
|
||||||
ListView.builder(
|
|
||||||
shrinkWrap: true,
|
|
||||||
itemCount: Difficulty.values.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final Difficulty difficulty = Difficulty.values[index];
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
|
||||||
child: ElevatedButton(
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.blueGrey[900],
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
minimumSize: const Size(300, 50),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onPressed: () => _startGame(difficulty),
|
|
||||||
child: Text(
|
|
||||||
difficulty.title,
|
|
||||||
style: const TextStyle(fontSize: 18),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ library;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||||
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
|
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
|
||||||
import 'package:wolf_3d_gui/screens/difficulty_screen.dart';
|
import 'package:wolf_3d_gui/screens/game_screen.dart';
|
||||||
import 'package:wolf_3d_gui/screens/sprite_gallery.dart';
|
import 'package:wolf_3d_gui/screens/sprite_gallery.dart';
|
||||||
import 'package:wolf_3d_gui/screens/vga_gallery.dart';
|
import 'package:wolf_3d_gui/screens/vga_gallery.dart';
|
||||||
|
|
||||||
@@ -27,12 +27,13 @@ class _EpisodeScreenState extends State<EpisodeScreen> {
|
|||||||
widget.wolf3d.audio.playMenuMusic();
|
widget.wolf3d.audio.playMenuMusic();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persists the chosen episode in [Wolf3d] and advances to difficulty select.
|
/// Persists the chosen episode and lets the engine present difficulty select.
|
||||||
void _selectEpisode(int index) {
|
void _selectEpisode(int index) {
|
||||||
widget.wolf3d.setActiveEpisode(index);
|
widget.wolf3d.setActiveEpisode(index);
|
||||||
|
widget.wolf3d.clearActiveDifficulty();
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => DifficultyScreen(wolf3d: widget.wolf3d),
|
builder: (context) => GameScreen(wolf3d: widget.wolf3d),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,36 +3,19 @@ library;
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.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_engine.dart';
|
||||||
import 'package:wolf_3d_flutter/wolf_3d_input_flutter.dart';
|
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
|
||||||
import 'package:wolf_3d_renderer/wolf_3d_ascii_renderer.dart';
|
import 'package:wolf_3d_renderer/wolf_3d_ascii_renderer.dart';
|
||||||
import 'package:wolf_3d_renderer/wolf_3d_flutter_renderer.dart';
|
import 'package:wolf_3d_renderer/wolf_3d_flutter_renderer.dart';
|
||||||
|
|
||||||
/// Owns a [WolfEngine] instance and exposes renderer/input integrations to Flutter.
|
/// Launches a [WolfEngine] via [Wolf3d] and exposes renderer/input integrations.
|
||||||
class GameScreen extends StatefulWidget {
|
class GameScreen extends StatefulWidget {
|
||||||
/// Fully parsed game data for the selected version.
|
/// Shared application facade owning the engine, audio, and input.
|
||||||
final WolfensteinData data;
|
final Wolf3d wolf3d;
|
||||||
|
|
||||||
/// Difficulty applied when creating the engine session.
|
/// Creates a gameplay screen driven by [wolf3d].
|
||||||
final Difficulty difficulty;
|
|
||||||
|
|
||||||
/// Episode index used as the starting world.
|
|
||||||
final int startingEpisode;
|
|
||||||
|
|
||||||
/// Shared audio backend reused across menu and gameplay screens.
|
|
||||||
final EngineAudio audio;
|
|
||||||
|
|
||||||
/// Flutter input adapter that translates widget events into engine input.
|
|
||||||
final Wolf3dFlutterInput input;
|
|
||||||
|
|
||||||
/// Creates a gameplay screen with the supplied game session configuration.
|
|
||||||
const GameScreen({
|
const GameScreen({
|
||||||
required this.data,
|
required this.wolf3d,
|
||||||
required this.difficulty,
|
|
||||||
required this.startingEpisode,
|
|
||||||
required this.audio,
|
|
||||||
required this.input,
|
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,90 +30,147 @@ class _GameScreenState extends State<GameScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_engine = WolfEngine(
|
_engine = widget.wolf3d.launchEngine(
|
||||||
data: widget.data,
|
|
||||||
difficulty: widget.difficulty,
|
|
||||||
startingEpisode: widget.startingEpisode,
|
|
||||||
frameBuffer: FrameBuffer(320, 200),
|
|
||||||
audio: widget.audio,
|
|
||||||
input: widget.input,
|
|
||||||
onGameWon: () => Navigator.of(context).pop(),
|
onGameWon: () => Navigator.of(context).pop(),
|
||||||
);
|
);
|
||||||
_engine.init();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return WillPopScope(
|
||||||
body: Listener(
|
onWillPop: () async {
|
||||||
onPointerDown: widget.input.onPointerDown,
|
if (_engine.isDifficultySelectionPending) {
|
||||||
onPointerUp: widget.input.onPointerUp,
|
widget.wolf3d.input.queueBackAction();
|
||||||
onPointerMove: widget.input.onPointerMove,
|
return false;
|
||||||
onPointerHover: widget.input.onPointerMove,
|
}
|
||||||
child: Stack(
|
return true;
|
||||||
children: [
|
},
|
||||||
// Keep both renderers behind the same engine so mode switching does
|
child: Scaffold(
|
||||||
// not reset level state or audio playback.
|
body: LayoutBuilder(
|
||||||
_useAsciiMode
|
builder: (context, constraints) {
|
||||||
? WolfAsciiRenderer(engine: _engine)
|
final viewportRect = _menuViewportRect(
|
||||||
: WolfFlutterRenderer(engine: _engine),
|
Size(constraints.maxWidth, constraints.maxHeight),
|
||||||
|
);
|
||||||
|
|
||||||
if (!_engine.isInitialized)
|
return Listener(
|
||||||
Container(
|
onPointerDown: (event) {
|
||||||
color: Colors.black,
|
widget.wolf3d.input.onPointerDown(event);
|
||||||
child: const Center(
|
if (_engine.isDifficultySelectionPending &&
|
||||||
child: Column(
|
viewportRect.width > 0 &&
|
||||||
mainAxisSize: MainAxisSize.min,
|
viewportRect.height > 0 &&
|
||||||
children: [
|
viewportRect.contains(event.localPosition)) {
|
||||||
CircularProgressIndicator(color: Colors.teal),
|
final normalizedX =
|
||||||
SizedBox(height: 20),
|
(event.localPosition.dx - viewportRect.left) /
|
||||||
Text(
|
viewportRect.width;
|
||||||
"GET PSYCHED!",
|
final normalizedY =
|
||||||
style: TextStyle(
|
(event.localPosition.dy - viewportRect.top) /
|
||||||
color: Colors.teal,
|
viewportRect.height;
|
||||||
fontFamily: 'monospace',
|
widget.wolf3d.input.queueMenuTap(
|
||||||
|
x: normalizedX,
|
||||||
|
y: normalizedY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPointerUp: widget.wolf3d.input.onPointerUp,
|
||||||
|
onPointerMove: widget.wolf3d.input.onPointerMove,
|
||||||
|
onPointerHover: widget.wolf3d.input.onPointerMove,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
// Keep both renderers behind the same engine so mode switching does
|
||||||
|
// not reset level state or audio playback.
|
||||||
|
_useAsciiMode
|
||||||
|
? WolfAsciiRenderer(engine: _engine)
|
||||||
|
: WolfFlutterRenderer(engine: _engine),
|
||||||
|
|
||||||
|
if (!_engine.isInitialized)
|
||||||
|
Container(
|
||||||
|
color: Colors.black,
|
||||||
|
child: const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(color: Colors.teal),
|
||||||
|
SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
"GET PSYCHED!",
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.teal,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
|
||||||
|
// Tab toggles the renderer implementation for quick visual debugging.
|
||||||
|
Focus(
|
||||||
|
autofocus: true,
|
||||||
|
onKeyEvent: (node, event) {
|
||||||
|
if (event is KeyDownEvent &&
|
||||||
|
event.logicalKey == LogicalKeyboardKey.tab) {
|
||||||
|
setState(() => _useAsciiMode = !_useAsciiMode);
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
},
|
||||||
|
child: const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Tab toggles the renderer implementation for quick visual debugging.
|
// A second full-screen overlay keeps the presentation simple while
|
||||||
Focus(
|
// the engine is still warming up or decoding the first frame.
|
||||||
autofocus: true,
|
if (!_engine.isInitialized)
|
||||||
onKeyEvent: (node, event) {
|
Container(
|
||||||
if (event is KeyDownEvent &&
|
color: Colors.black,
|
||||||
event.logicalKey == LogicalKeyboardKey.tab) {
|
child: const Center(
|
||||||
setState(() => _useAsciiMode = !_useAsciiMode);
|
child: CircularProgressIndicator(color: Colors.teal),
|
||||||
return KeyEventResult.handled;
|
),
|
||||||
}
|
),
|
||||||
return KeyEventResult.ignored;
|
|
||||||
},
|
|
||||||
child: const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
|
|
||||||
// A second full-screen overlay keeps the presentation simple while
|
Positioned(
|
||||||
// the engine is still warming up or decoding the first frame.
|
top: 16,
|
||||||
if (!_engine.isInitialized)
|
right: 16,
|
||||||
Container(
|
child: Text(
|
||||||
color: Colors.black,
|
'TAB: Swap Renderer',
|
||||||
child: const Center(
|
style: TextStyle(
|
||||||
child: CircularProgressIndicator(color: Colors.teal),
|
color: Colors.white.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
);
|
||||||
Positioned(
|
},
|
||||||
top: 16,
|
|
||||||
right: 16,
|
|
||||||
child: Text(
|
|
||||||
'TAB: Swap Renderer',
|
|
||||||
style: TextStyle(color: Colors.white.withValues(alpha: 0.5)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Rect _menuViewportRect(Size availableSize) {
|
||||||
|
if (availableSize.width <= 0 || availableSize.height <= 0) {
|
||||||
|
return Rect.zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
const double aspect = 4 / 3;
|
||||||
|
final double outerPadding = _useAsciiMode ? 0.0 : 16.0;
|
||||||
|
final double maxWidth = (availableSize.width - (outerPadding * 2)).clamp(
|
||||||
|
1.0,
|
||||||
|
double.infinity,
|
||||||
|
);
|
||||||
|
final double maxHeight = (availableSize.height - (outerPadding * 2)).clamp(
|
||||||
|
1.0,
|
||||||
|
double.infinity,
|
||||||
|
);
|
||||||
|
|
||||||
|
double viewportWidth = maxWidth;
|
||||||
|
double viewportHeight = viewportWidth / aspect;
|
||||||
|
if (viewportHeight > maxHeight) {
|
||||||
|
viewportHeight = maxHeight;
|
||||||
|
viewportWidth = viewportHeight * aspect;
|
||||||
|
}
|
||||||
|
|
||||||
|
final double left = (availableSize.width - viewportWidth) / 2;
|
||||||
|
final double top = (availableSize.height - viewportHeight) / 2;
|
||||||
|
return Rect.fromLTWH(left, top, viewportWidth, viewportHeight);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
95
apps/wolf_3d_gui/lib/screens/wolf_menu_shell.dart
Normal file
95
apps/wolf_3d_gui/lib/screens/wolf_menu_shell.dart
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/// Shared shell for Wolf3D-style menu screens.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||||
|
import 'package:wolf_3d_renderer/wolf_3d_asset_painter.dart';
|
||||||
|
|
||||||
|
/// Provides a common menu layout with panel framing and optional bottom art.
|
||||||
|
class WolfMenuShell extends StatelessWidget {
|
||||||
|
/// Full-screen background color behind the panel.
|
||||||
|
final Color backgroundColor;
|
||||||
|
|
||||||
|
/// Solid panel fill used for the menu content area.
|
||||||
|
final Color panelColor;
|
||||||
|
|
||||||
|
/// Optional heading shown above the panel (text or image).
|
||||||
|
final Widget? header;
|
||||||
|
|
||||||
|
/// Primary menu content rendered inside the panel.
|
||||||
|
final Widget panelChild;
|
||||||
|
|
||||||
|
/// Optional centered VGA image anchored near the bottom of the screen.
|
||||||
|
final VgaImage? bottomSprite;
|
||||||
|
|
||||||
|
/// Width of the menu panel.
|
||||||
|
final double panelWidth;
|
||||||
|
|
||||||
|
/// Padding applied around [panelChild] inside the panel.
|
||||||
|
final EdgeInsets panelPadding;
|
||||||
|
|
||||||
|
/// Scale factor for [bottomSprite].
|
||||||
|
final double bottomSpriteScale;
|
||||||
|
|
||||||
|
/// Distance from the bottom edge for [bottomSprite].
|
||||||
|
final double bottomOffset;
|
||||||
|
|
||||||
|
/// Vertical spacing between [header] and the panel.
|
||||||
|
final double headerSpacing;
|
||||||
|
|
||||||
|
const WolfMenuShell({
|
||||||
|
super.key,
|
||||||
|
required this.backgroundColor,
|
||||||
|
required this.panelColor,
|
||||||
|
required this.panelChild,
|
||||||
|
this.header,
|
||||||
|
this.bottomSprite,
|
||||||
|
this.panelWidth = 520,
|
||||||
|
this.panelPadding = const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 16,
|
||||||
|
),
|
||||||
|
this.bottomSpriteScale = 3,
|
||||||
|
this.bottomOffset = 20,
|
||||||
|
this.headerSpacing = 14,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: ColoredBox(color: backgroundColor),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
?header,
|
||||||
|
if (header != null) SizedBox(height: headerSpacing),
|
||||||
|
Container(
|
||||||
|
width: panelWidth,
|
||||||
|
padding: panelPadding,
|
||||||
|
color: panelColor,
|
||||||
|
child: panelChild,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (bottomSprite != null)
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: bottomOffset,
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: bottomSprite!.width * bottomSpriteScale,
|
||||||
|
height: bottomSprite!.height * bottomSpriteScale,
|
||||||
|
child: WolfAssetPainter.vga(bottomSprite),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ class EngineInput {
|
|||||||
final bool isTurningRight;
|
final bool isTurningRight;
|
||||||
final bool isFiring;
|
final bool isFiring;
|
||||||
final bool isInteracting;
|
final bool isInteracting;
|
||||||
|
final bool isBack;
|
||||||
|
final double? menuTapX;
|
||||||
|
final double? menuTapY;
|
||||||
final WeaponType? requestedWeapon;
|
final WeaponType? requestedWeapon;
|
||||||
|
|
||||||
const EngineInput({
|
const EngineInput({
|
||||||
@@ -17,6 +20,9 @@ class EngineInput {
|
|||||||
this.isTurningRight = false,
|
this.isTurningRight = false,
|
||||||
this.isFiring = false,
|
this.isFiring = false,
|
||||||
this.isInteracting = false,
|
this.isInteracting = false,
|
||||||
|
this.isBack = false,
|
||||||
|
this.menuTapX,
|
||||||
|
this.menuTapY,
|
||||||
this.requestedWeapon,
|
this.requestedWeapon,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,837 +0,0 @@
|
|||||||
import 'dart:math' as math;
|
|
||||||
|
|
||||||
import 'package:arcane_helper_utils/arcane_helper_utils.dart';
|
|
||||||
import 'package:wolf_3d_dart/src/rasterizer/cli_rasterizer.dart';
|
|
||||||
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
|
||||||
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
|
||||||
|
|
||||||
class AsciiTheme {
|
|
||||||
final String name;
|
|
||||||
|
|
||||||
/// The character ramp, ordered from most dense (index 0) to least dense (last index).
|
|
||||||
final String ramp;
|
|
||||||
|
|
||||||
const AsciiTheme(this.name, this.ramp);
|
|
||||||
|
|
||||||
/// Always returns the densest character (e.g., for walls, UI, floors)
|
|
||||||
String get solid => ramp[0];
|
|
||||||
|
|
||||||
/// Always returns the completely empty character (e.g., for pitch black darkness)
|
|
||||||
String get empty => ramp[ramp.length - 1];
|
|
||||||
|
|
||||||
/// Returns a character based on a 0.0 to 1.0 brightness scale.
|
|
||||||
/// 1.0 returns the [solid] character, 0.0 returns the [empty] character.
|
|
||||||
String getByBrightness(double brightness) {
|
|
||||||
double b = brightness.clamp(0.0, 1.0);
|
|
||||||
int index = ((1.0 - b) * (ramp.length - 1)).round();
|
|
||||||
return ramp[index];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A collection of pre-defined character sets
|
|
||||||
abstract class AsciiThemes {
|
|
||||||
static const AsciiTheme blocks = AsciiTheme('Blocks', "█▓▒░ ");
|
|
||||||
static const AsciiTheme classic = AsciiTheme('Classic', "@%#*+=-:. ");
|
|
||||||
|
|
||||||
static const List<AsciiTheme> values = [blocks, classic];
|
|
||||||
|
|
||||||
static AsciiTheme nextOf(AsciiTheme current) {
|
|
||||||
final int currentIndex = values.indexOf(current);
|
|
||||||
final int nextIndex = currentIndex == -1
|
|
||||||
? 0
|
|
||||||
: (currentIndex + 1) % values.length;
|
|
||||||
return values[nextIndex];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ColoredChar {
|
|
||||||
final String char;
|
|
||||||
final int rawColor; // Stores the AABBGGRR integer from the palette
|
|
||||||
final int? rawBackgroundColor;
|
|
||||||
|
|
||||||
ColoredChar(this.char, this.rawColor, [this.rawBackgroundColor]);
|
|
||||||
|
|
||||||
// Safely extract the exact RGB channels regardless of framework
|
|
||||||
int get r => rawColor & 0xFF;
|
|
||||||
int get g => (rawColor >> 8) & 0xFF;
|
|
||||||
int get b => (rawColor >> 16) & 0xFF;
|
|
||||||
|
|
||||||
// Outputs standard AARRGGBB for Flutter's Color(int) constructor
|
|
||||||
int get argb => (0xFF000000) | (r << 16) | (g << 8) | b;
|
|
||||||
}
|
|
||||||
|
|
||||||
class AsciiRasterizer extends CliRasterizer<dynamic> {
|
|
||||||
static const double _targetAspectRatio = 4 / 3;
|
|
||||||
static const int _terminalBackdropArgb = 0xFF009688;
|
|
||||||
static const int _minimumTerminalColumns = 80;
|
|
||||||
static const int _minimumTerminalRows = 24;
|
|
||||||
static const int _simpleHudMinWidth = 84;
|
|
||||||
static const int _simpleHudMinRows = 7;
|
|
||||||
|
|
||||||
AsciiRasterizer({
|
|
||||||
this.activeTheme = AsciiThemes.blocks,
|
|
||||||
this.isTerminal = false,
|
|
||||||
this.aspectMultiplier = 1.0,
|
|
||||||
this.verticalStretch = 1.0,
|
|
||||||
});
|
|
||||||
|
|
||||||
AsciiTheme activeTheme = AsciiThemes.blocks;
|
|
||||||
final bool isTerminal;
|
|
||||||
|
|
||||||
late List<List<ColoredChar>> _screen;
|
|
||||||
late List<List<int>> _scenePixels;
|
|
||||||
late WolfEngine _engine;
|
|
||||||
|
|
||||||
@override
|
|
||||||
final double aspectMultiplier;
|
|
||||||
@override
|
|
||||||
final double verticalStretch;
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get projectionWidth => isTerminal
|
|
||||||
? math.max(
|
|
||||||
1,
|
|
||||||
math.min(width, (_terminalPixelHeight * _targetAspectRatio).floor()),
|
|
||||||
)
|
|
||||||
: width;
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get projectionOffsetX => isTerminal ? (width - projectionWidth) ~/ 2 : 0;
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get projectionViewHeight => isTerminal ? viewHeight * 2 : viewHeight;
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool isTerminalSizeSupported(int columns, int rows) {
|
|
||||||
if (!isTerminal) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return columns >= _minimumTerminalColumns && rows >= _minimumTerminalRows;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get terminalSizeRequirement =>
|
|
||||||
'ASCII renderer requires a minimum resolution of '
|
|
||||||
'${_minimumTerminalColumns}x$_minimumTerminalRows.';
|
|
||||||
|
|
||||||
int get _terminalPixelHeight => isTerminal ? height * 2 : height;
|
|
||||||
|
|
||||||
int get _viewportRightX => projectionOffsetX + projectionWidth;
|
|
||||||
|
|
||||||
int get _terminalBackdropColor => _argbToRawColor(_terminalBackdropArgb);
|
|
||||||
|
|
||||||
// Intercept the base render call to initialize our text grid
|
|
||||||
@override
|
|
||||||
dynamic render(WolfEngine engine) {
|
|
||||||
_engine = engine;
|
|
||||||
_screen = List.generate(
|
|
||||||
engine.frameBuffer.height,
|
|
||||||
(_) => List.filled(
|
|
||||||
engine.frameBuffer.width,
|
|
||||||
ColoredChar(' ', ColorPalette.vga32Bit[0]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return super.render(engine);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void prepareFrame(WolfEngine engine) {
|
|
||||||
// Just grab the raw ints!
|
|
||||||
final int ceilingColor = ColorPalette.vga32Bit[25];
|
|
||||||
final int floorColor = ColorPalette.vga32Bit[29];
|
|
||||||
final int backdropColor = isTerminal
|
|
||||||
? _terminalBackdropColor
|
|
||||||
: ColorPalette.vga32Bit[0];
|
|
||||||
|
|
||||||
_scenePixels = List.generate(
|
|
||||||
_terminalPixelHeight,
|
|
||||||
(_) => List.filled(width, backdropColor),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (int y = 0; y < projectionViewHeight; y++) {
|
|
||||||
final int color = y < projectionViewHeight / 2
|
|
||||||
? ceilingColor
|
|
||||||
: floorColor;
|
|
||||||
for (int x = projectionOffsetX; x < _viewportRightX; x++) {
|
|
||||||
_scenePixels[y][x] = color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isTerminal) {
|
|
||||||
for (int y = 0; y < height; y++) {
|
|
||||||
for (int x = 0; x < width; x++) {
|
|
||||||
if (y < viewHeight / 2) {
|
|
||||||
_screen[y][x] = ColoredChar(activeTheme.solid, ceilingColor);
|
|
||||||
} else if (y < viewHeight) {
|
|
||||||
_screen[y][x] = ColoredChar(activeTheme.solid, floorColor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void drawWallColumn(
|
|
||||||
int x,
|
|
||||||
int drawStart,
|
|
||||||
int drawEnd,
|
|
||||||
int columnHeight,
|
|
||||||
Sprite texture,
|
|
||||||
int texX,
|
|
||||||
double perpWallDist,
|
|
||||||
int side,
|
|
||||||
) {
|
|
||||||
double brightness = calculateDepthBrightness(perpWallDist);
|
|
||||||
|
|
||||||
for (int y = drawStart; y < drawEnd; y++) {
|
|
||||||
double relativeY =
|
|
||||||
(y - (-columnHeight ~/ 2 + projectionViewHeight ~/ 2)) / columnHeight;
|
|
||||||
int texY = (relativeY * 64).toInt().clamp(0, 63);
|
|
||||||
|
|
||||||
int colorByte = texture.pixels[texX * 64 + texY];
|
|
||||||
int pixelColor = ColorPalette.vga32Bit[colorByte]; // Raw int
|
|
||||||
|
|
||||||
// Faux directional lighting using your new base class method
|
|
||||||
if (side == 1) {
|
|
||||||
pixelColor = shadeColor(pixelColor);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTerminal) {
|
|
||||||
_scenePixels[y][x] = _scaleColor(pixelColor, brightness);
|
|
||||||
} else {
|
|
||||||
String wallChar = activeTheme.getByBrightness(brightness);
|
|
||||||
_screen[y][x] = ColoredChar(wallChar, pixelColor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void drawSpriteStripe(
|
|
||||||
int stripeX,
|
|
||||||
int drawStartY,
|
|
||||||
int drawEndY,
|
|
||||||
int spriteHeight,
|
|
||||||
Sprite texture,
|
|
||||||
int texX,
|
|
||||||
double transformY,
|
|
||||||
) {
|
|
||||||
double brightness = calculateDepthBrightness(transformY);
|
|
||||||
|
|
||||||
for (
|
|
||||||
int y = math.max(0, drawStartY);
|
|
||||||
y < math.min(projectionViewHeight, drawEndY);
|
|
||||||
y++
|
|
||||||
) {
|
|
||||||
double relativeY = (y - drawStartY) / spriteHeight;
|
|
||||||
int texY = (relativeY * 64).toInt().clamp(0, 63);
|
|
||||||
|
|
||||||
int colorByte = texture.pixels[texX * 64 + texY];
|
|
||||||
if (colorByte != 255) {
|
|
||||||
int rawColor = ColorPalette.vga32Bit[colorByte];
|
|
||||||
|
|
||||||
// Shade the sprite's actual RGB color based on distance
|
|
||||||
int r = (rawColor & 0xFF);
|
|
||||||
int g = ((rawColor >> 8) & 0xFF);
|
|
||||||
int b = ((rawColor >> 16) & 0xFF);
|
|
||||||
|
|
||||||
r = (r * brightness).toInt();
|
|
||||||
g = (g * brightness).toInt();
|
|
||||||
b = (b * brightness).toInt();
|
|
||||||
|
|
||||||
int shadedColor = (0xFF000000) | (b << 16) | (g << 8) | r;
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void drawWeapon(WolfEngine engine) {
|
|
||||||
int spriteIndex = engine.player.currentWeapon.getCurrentSpriteIndex(
|
|
||||||
engine.data.sprites.length,
|
|
||||||
);
|
|
||||||
Sprite weaponSprite = engine.data.sprites[spriteIndex];
|
|
||||||
|
|
||||||
int weaponWidth = (projectionWidth * 0.5).toInt();
|
|
||||||
int weaponHeight = ((projectionViewHeight * 0.8)).toInt();
|
|
||||||
|
|
||||||
int startX =
|
|
||||||
projectionOffsetX + (projectionWidth ~/ 2) - (weaponWidth ~/ 2);
|
|
||||||
int startY =
|
|
||||||
projectionViewHeight -
|
|
||||||
weaponHeight +
|
|
||||||
(engine.player.weaponAnimOffset * (isTerminal ? 2 : 1) ~/ 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 sceneX = startX + dx;
|
|
||||||
int drawY = startY + dy;
|
|
||||||
if (sceneX >= projectionOffsetX &&
|
|
||||||
sceneX < _viewportRightX &&
|
|
||||||
drawY >= 0) {
|
|
||||||
if (isTerminal && drawY < projectionViewHeight) {
|
|
||||||
_scenePixels[drawY][sceneX] = ColorPalette.vga32Bit[colorByte];
|
|
||||||
} else if (!isTerminal && drawY < viewHeight) {
|
|
||||||
_screen[drawY][sceneX] = ColoredChar(
|
|
||||||
activeTheme.solid,
|
|
||||||
ColorPalette.vga32Bit[colorByte],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- PRIVATE HUD DRAWING HELPER ---
|
|
||||||
|
|
||||||
/// Injects a pure text string directly into the rasterizer grid
|
|
||||||
void _writeString(
|
|
||||||
int startX,
|
|
||||||
int y,
|
|
||||||
String text,
|
|
||||||
int color, [
|
|
||||||
int? backgroundColor,
|
|
||||||
]) {
|
|
||||||
for (int i = 0; i < text.length; i++) {
|
|
||||||
int x = startX + i;
|
|
||||||
if (x >= 0 && x < width && y >= 0 && y < height) {
|
|
||||||
_screen[y][x] = ColoredChar(text[i], color, backgroundColor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void drawHud(WolfEngine engine) {
|
|
||||||
// If the terminal is at least 160 columns wide and 50 rows tall,
|
|
||||||
// there are enough "pixels" to downscale the VGA image clearly.
|
|
||||||
int hudWidth = isTerminal ? projectionWidth : width;
|
|
||||||
if (hudWidth >= 160 && height >= 50) {
|
|
||||||
_drawFullVgaHud(engine);
|
|
||||||
} else {
|
|
||||||
_drawSimpleHud(engine);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _drawSimpleHud(WolfEngine engine) {
|
|
||||||
final int hudWidth = isTerminal ? projectionWidth : width;
|
|
||||||
final int hudRows = height - viewHeight;
|
|
||||||
if (hudWidth < _simpleHudMinWidth || hudRows < _simpleHudMinRows) {
|
|
||||||
_drawMinimalHud(engine);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Pull Retro Colors
|
|
||||||
final int vgaStatusBarBlue = ColorPalette.vga32Bit[153];
|
|
||||||
final int vgaPanelDark = ColorPalette.vga32Bit[0];
|
|
||||||
final int white = ColorPalette.vga32Bit[15];
|
|
||||||
final int yellow = ColorPalette.vga32Bit[11];
|
|
||||||
final int red = ColorPalette.vga32Bit[4];
|
|
||||||
|
|
||||||
// Compact full simple HUD layout.
|
|
||||||
const int floorW = 10;
|
|
||||||
const int scoreW = 14;
|
|
||||||
const int livesW = 9;
|
|
||||||
const int faceW = 10;
|
|
||||||
const int healthW = 12;
|
|
||||||
const int ammoW = 10;
|
|
||||||
const int weaponW = 13;
|
|
||||||
const int gap = 1;
|
|
||||||
const int hudContentWidth =
|
|
||||||
floorW +
|
|
||||||
scoreW +
|
|
||||||
livesW +
|
|
||||||
faceW +
|
|
||||||
healthW +
|
|
||||||
ammoW +
|
|
||||||
weaponW +
|
|
||||||
(gap * 6);
|
|
||||||
|
|
||||||
final int offsetX =
|
|
||||||
projectionOffsetX +
|
|
||||||
((projectionWidth - hudContentWidth) ~/ 2).clamp(0, projectionWidth);
|
|
||||||
final int baseY = viewHeight + 1;
|
|
||||||
|
|
||||||
// 3. Clear HUD Base
|
|
||||||
if (isTerminal) {
|
|
||||||
_fillTerminalRect(
|
|
||||||
projectionOffsetX,
|
|
||||||
viewHeight * 2,
|
|
||||||
projectionWidth,
|
|
||||||
hudRows * 2,
|
|
||||||
vgaStatusBarBlue,
|
|
||||||
);
|
|
||||||
_fillTerminalRect(
|
|
||||||
projectionOffsetX,
|
|
||||||
viewHeight * 2,
|
|
||||||
projectionWidth,
|
|
||||||
1,
|
|
||||||
white,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
_fillRect(
|
|
||||||
0,
|
|
||||||
viewHeight,
|
|
||||||
width,
|
|
||||||
height - viewHeight,
|
|
||||||
' ',
|
|
||||||
vgaStatusBarBlue,
|
|
||||||
);
|
|
||||||
_writeString(0, viewHeight, "═" * width, white);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Panel Drawing Helper
|
|
||||||
void drawBorderedPanel(int startX, int startY, int w, int h) {
|
|
||||||
if (isTerminal) {
|
|
||||||
_fillTerminalRect(startX, startY * 2, w, h * 2, vgaPanelDark);
|
|
||||||
_fillTerminalRect(startX, startY * 2, w, 1, white);
|
|
||||||
_fillTerminalRect(startX, (startY + h) * 2 - 1, w, 1, white);
|
|
||||||
_fillTerminalRect(startX, startY * 2, 1, h * 2, white);
|
|
||||||
_fillTerminalRect(startX + w - 1, startY * 2, 1, h * 2, white);
|
|
||||||
} else {
|
|
||||||
_fillRect(startX, startY, w, h, ' ', vgaPanelDark);
|
|
||||||
// Horizontal lines
|
|
||||||
_writeString(startX, startY, "┌${"─" * (w - 2)}┐", white);
|
|
||||||
_writeString(startX, startY + h - 1, "└${"─" * (w - 2)}┘", white);
|
|
||||||
// Vertical sides
|
|
||||||
for (int i = 1; i < h - 1; i++) {
|
|
||||||
_writeString(startX, startY + i, "│", white);
|
|
||||||
_writeString(startX + w - 1, startY + i, "│", white);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Draw compact panels.
|
|
||||||
int cursorX = offsetX;
|
|
||||||
|
|
||||||
drawBorderedPanel(cursorX, baseY + 1, floorW, 4);
|
|
||||||
_writeString(cursorX + 2, baseY + 2, "FLR", white, vgaPanelDark);
|
|
||||||
String floorLabel = engine.activeLevel.name.split(' ').last;
|
|
||||||
if (floorLabel.length > 4) {
|
|
||||||
floorLabel = floorLabel.substring(floorLabel.length - 4);
|
|
||||||
}
|
|
||||||
_writeString(cursorX + 2, baseY + 3, floorLabel, white, vgaPanelDark);
|
|
||||||
cursorX += floorW + gap;
|
|
||||||
|
|
||||||
drawBorderedPanel(cursorX, baseY + 1, scoreW, 4);
|
|
||||||
_writeString(cursorX + 4, baseY + 2, "SCORE", white, vgaPanelDark);
|
|
||||||
_writeString(
|
|
||||||
cursorX + 4,
|
|
||||||
baseY + 3,
|
|
||||||
engine.player.score.toString().padLeft(6, '0'),
|
|
||||||
white,
|
|
||||||
vgaPanelDark,
|
|
||||||
);
|
|
||||||
cursorX += scoreW + gap;
|
|
||||||
|
|
||||||
drawBorderedPanel(cursorX, baseY + 1, livesW, 4);
|
|
||||||
_writeString(cursorX + 2, baseY + 2, "LIV", white, vgaPanelDark);
|
|
||||||
_writeString(cursorX + 3, baseY + 3, "3", white, vgaPanelDark);
|
|
||||||
cursorX += livesW + gap;
|
|
||||||
|
|
||||||
drawBorderedPanel(cursorX, baseY, faceW, 5);
|
|
||||||
String face = "ಠ⌣ಠ";
|
|
||||||
if (engine.player.health <= 0) {
|
|
||||||
face = "x⸑x";
|
|
||||||
} else if (engine.player.damageFlash > 0.1) {
|
|
||||||
face = "ಠoಠ";
|
|
||||||
} else if (engine.player.health <= 25) {
|
|
||||||
face = "ಥ_ಥ";
|
|
||||||
} else if (engine.player.health <= 60) {
|
|
||||||
face = "ಠ~ಠ";
|
|
||||||
}
|
|
||||||
_writeString(cursorX + 3, baseY + 2, face, yellow, vgaPanelDark);
|
|
||||||
cursorX += faceW + gap;
|
|
||||||
|
|
||||||
int healthColor = engine.player.health > 25 ? white : red;
|
|
||||||
drawBorderedPanel(cursorX, baseY + 1, healthW, 4);
|
|
||||||
_writeString(cursorX + 2, baseY + 2, "HEALTH", white, vgaPanelDark);
|
|
||||||
_writeString(
|
|
||||||
cursorX + 3,
|
|
||||||
baseY + 3,
|
|
||||||
"${engine.player.health}%",
|
|
||||||
healthColor,
|
|
||||||
vgaPanelDark,
|
|
||||||
);
|
|
||||||
cursorX += healthW + gap;
|
|
||||||
|
|
||||||
drawBorderedPanel(cursorX, baseY + 1, ammoW, 4);
|
|
||||||
_writeString(cursorX + 2, baseY + 2, "AMMO", white, vgaPanelDark);
|
|
||||||
_writeString(
|
|
||||||
cursorX + 2,
|
|
||||||
baseY + 3,
|
|
||||||
"${engine.player.ammo}",
|
|
||||||
white,
|
|
||||||
vgaPanelDark,
|
|
||||||
);
|
|
||||||
cursorX += ammoW + gap;
|
|
||||||
|
|
||||||
drawBorderedPanel(cursorX, baseY + 1, weaponW, 4);
|
|
||||||
String weapon = engine.player.currentWeapon.type.name.spacePascalCase!
|
|
||||||
.toUpperCase();
|
|
||||||
if (weapon.length > weaponW - 2) {
|
|
||||||
weapon = weapon.substring(0, weaponW - 2);
|
|
||||||
}
|
|
||||||
_writeString(cursorX + 1, baseY + 3, weapon, white, vgaPanelDark);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _drawMinimalHud(WolfEngine engine) {
|
|
||||||
final int vgaStatusBarBlue = ColorPalette.vga32Bit[153];
|
|
||||||
final int white = ColorPalette.vga32Bit[15];
|
|
||||||
final int red = ColorPalette.vga32Bit[4];
|
|
||||||
|
|
||||||
final int hudRows = height - viewHeight;
|
|
||||||
if (isTerminal) {
|
|
||||||
_fillTerminalRect(
|
|
||||||
projectionOffsetX,
|
|
||||||
viewHeight * 2,
|
|
||||||
projectionWidth,
|
|
||||||
hudRows * 2,
|
|
||||||
vgaStatusBarBlue,
|
|
||||||
);
|
|
||||||
_fillTerminalRect(
|
|
||||||
projectionOffsetX,
|
|
||||||
viewHeight * 2,
|
|
||||||
projectionWidth,
|
|
||||||
1,
|
|
||||||
white,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
_fillRect(0, viewHeight, width, hudRows, ' ', vgaStatusBarBlue);
|
|
||||||
_writeString(0, viewHeight, "═" * width, white);
|
|
||||||
}
|
|
||||||
|
|
||||||
final int healthColor = engine.player.health > 25 ? white : red;
|
|
||||||
String weapon = engine.player.currentWeapon.type.name.spacePascalCase!
|
|
||||||
.toUpperCase();
|
|
||||||
if (weapon.length > 8) {
|
|
||||||
weapon = weapon.substring(0, 8);
|
|
||||||
}
|
|
||||||
final String hudText =
|
|
||||||
'H:${engine.player.health}% A:${engine.player.ammo} S:${engine.player.score} W:$weapon';
|
|
||||||
|
|
||||||
final int lineY = viewHeight + 1;
|
|
||||||
if (lineY >= height) return;
|
|
||||||
|
|
||||||
final int drawStartX = isTerminal ? projectionOffsetX : 0;
|
|
||||||
final int drawWidth = isTerminal ? projectionWidth : width;
|
|
||||||
final int maxTextLen = math.max(0, drawWidth - 2);
|
|
||||||
String clipped = hudText;
|
|
||||||
if (clipped.length > maxTextLen) {
|
|
||||||
clipped = clipped.substring(0, maxTextLen);
|
|
||||||
}
|
|
||||||
|
|
||||||
final int startX = drawStartX + ((drawWidth - clipped.length) ~/ 2);
|
|
||||||
_writeString(startX, lineY, clipped, healthColor, vgaStatusBarBlue);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _drawFullVgaHud(WolfEngine engine) {
|
|
||||||
int statusBarIndex = engine.data.vgaImages.indexWhere(
|
|
||||||
(img) => img.width == 320 && img.height == 40,
|
|
||||||
);
|
|
||||||
if (statusBarIndex == -1) return;
|
|
||||||
|
|
||||||
// 1. Draw Background
|
|
||||||
_blitVgaImageAscii(engine.data.vgaImages[statusBarIndex], 0, 160);
|
|
||||||
|
|
||||||
// 2. Draw Stats
|
|
||||||
_drawNumberAscii(1, 32, 176, engine.data.vgaImages); // Floor
|
|
||||||
_drawNumberAscii(
|
|
||||||
engine.player.score,
|
|
||||||
96,
|
|
||||||
176,
|
|
||||||
engine.data.vgaImages,
|
|
||||||
); // Score
|
|
||||||
_drawNumberAscii(3, 120, 176, engine.data.vgaImages); // Lives
|
|
||||||
_drawNumberAscii(
|
|
||||||
engine.player.health,
|
|
||||||
192,
|
|
||||||
176,
|
|
||||||
engine.data.vgaImages,
|
|
||||||
); // Health
|
|
||||||
_drawNumberAscii(
|
|
||||||
engine.player.ammo,
|
|
||||||
232,
|
|
||||||
176,
|
|
||||||
engine.data.vgaImages,
|
|
||||||
); // Ammo
|
|
||||||
|
|
||||||
// 3. Draw BJ's Face & Current Weapon
|
|
||||||
_drawFaceAscii(engine);
|
|
||||||
_drawWeaponIconAscii(engine);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _drawNumberAscii(
|
|
||||||
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) {
|
|
||||||
_blitVgaImageAscii(vgaImages[zeroIndex + digit], currentX, startY);
|
|
||||||
}
|
|
||||||
currentX += 8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _drawFaceAscii(WolfEngine engine) {
|
|
||||||
int health = engine.player.health;
|
|
||||||
int faceIndex;
|
|
||||||
|
|
||||||
if (health <= 0) {
|
|
||||||
faceIndex = 127;
|
|
||||||
} else {
|
|
||||||
int healthTier = ((100 - health) ~/ 16).clamp(0, 6);
|
|
||||||
faceIndex = 106 + (healthTier * 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (faceIndex < engine.data.vgaImages.length) {
|
|
||||||
_blitVgaImageAscii(engine.data.vgaImages[faceIndex], 136, 164);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _drawWeaponIconAscii(WolfEngine engine) {
|
|
||||||
int weaponIndex = 89;
|
|
||||||
if (engine.player.hasChainGun) {
|
|
||||||
weaponIndex = 91;
|
|
||||||
} else if (engine.player.hasMachineGun) {
|
|
||||||
weaponIndex = 90;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (weaponIndex < engine.data.vgaImages.length) {
|
|
||||||
_blitVgaImageAscii(engine.data.vgaImages[weaponIndex], 256, 164);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper to fill a rectangular area with a specific char and background color
|
|
||||||
void _fillRect(int startX, int startY, int w, int h, String char, int color) {
|
|
||||||
for (int dy = 0; dy < h; dy++) {
|
|
||||||
for (int dx = 0; dx < w; dx++) {
|
|
||||||
int x = startX + dx;
|
|
||||||
int y = startY + dy;
|
|
||||||
if (x >= 0 && x < width && y >= 0 && y < height) {
|
|
||||||
_screen[y][x] = ColoredChar(char, color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
dynamic finalizeFrame() {
|
|
||||||
if (_engine.player.damageFlash > 0.0) {
|
|
||||||
if (isTerminal) {
|
|
||||||
_applyDamageFlashToScene();
|
|
||||||
} else {
|
|
||||||
_applyDamageFlash();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isTerminal) {
|
|
||||||
_composeTerminalScene();
|
|
||||||
return toAnsiString();
|
|
||||||
}
|
|
||||||
return _screen;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- PRIVATE HUD DRAWING HELPERS ---
|
|
||||||
|
|
||||||
void _blitVgaImageAscii(VgaImage image, int startX_320, int startY_200) {
|
|
||||||
int planeWidth = image.width ~/ 4;
|
|
||||||
int planeSize = planeWidth * image.height;
|
|
||||||
int maxDrawHeight = isTerminal ? _terminalPixelHeight : height;
|
|
||||||
int maxDrawWidth = isTerminal ? _viewportRightX : width;
|
|
||||||
|
|
||||||
double scaleX = (isTerminal ? projectionWidth : width) / 320.0;
|
|
||||||
double scaleY = (isTerminal ? _terminalPixelHeight : height) / 200.0;
|
|
||||||
|
|
||||||
int destStartX =
|
|
||||||
(isTerminal ? projectionOffsetX : 0) + (startX_320 * scaleX).toInt();
|
|
||||||
int destStartY = (startY_200 * scaleY).toInt();
|
|
||||||
int destWidth = (image.width * scaleX).toInt();
|
|
||||||
int destHeight = (image.height * scaleY).toInt();
|
|
||||||
|
|
||||||
for (int dy = 0; dy < destHeight; dy++) {
|
|
||||||
for (int dx = 0; dx < destWidth; dx++) {
|
|
||||||
int drawX = destStartX + dx;
|
|
||||||
int drawY = destStartY + dy;
|
|
||||||
|
|
||||||
if (drawX >= 0 &&
|
|
||||||
drawX < maxDrawWidth &&
|
|
||||||
drawY >= 0 &&
|
|
||||||
drawY < maxDrawHeight) {
|
|
||||||
int srcX = (dx / scaleX).toInt().clamp(0, image.width - 1);
|
|
||||||
int srcY = (dy / scaleY).toInt().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) {
|
|
||||||
if (isTerminal) {
|
|
||||||
_scenePixels[drawY][drawX] = ColorPalette.vga32Bit[colorByte];
|
|
||||||
} else {
|
|
||||||
_screen[drawY][drawX] = ColoredChar(
|
|
||||||
activeTheme.solid,
|
|
||||||
ColorPalette.vga32Bit[colorByte],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _fillTerminalRect(int startX, int startY, int w, int h, int color) {
|
|
||||||
for (int dy = 0; dy < h; dy++) {
|
|
||||||
for (int dx = 0; dx < w; dx++) {
|
|
||||||
int x = startX + dx;
|
|
||||||
int y = startY + dy;
|
|
||||||
if (x >= 0 && x < width && y >= 0 && y < _terminalPixelHeight) {
|
|
||||||
_scenePixels[y][x] = color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- DAMAGE FLASH ---
|
|
||||||
void _applyDamageFlash() {
|
|
||||||
for (int y = 0; y < viewHeight; y++) {
|
|
||||||
for (int x = 0; x < width; x++) {
|
|
||||||
ColoredChar cell = _screen[y][x];
|
|
||||||
_screen[y][x] = ColoredChar(
|
|
||||||
cell.char,
|
|
||||||
_applyDamageFlashToColor(cell.rawColor),
|
|
||||||
cell.rawBackgroundColor == null
|
|
||||||
? null
|
|
||||||
: _applyDamageFlashToColor(cell.rawBackgroundColor!),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _applyDamageFlashToScene() {
|
|
||||||
for (int y = 0; y < _terminalPixelHeight; y++) {
|
|
||||||
for (int x = projectionOffsetX; x < _viewportRightX; x++) {
|
|
||||||
_scenePixels[y][x] = _applyDamageFlashToColor(_scenePixels[y][x]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
double colorDrop = 1.0 - (0.5 * intensity);
|
|
||||||
|
|
||||||
int r = color & 0xFF;
|
|
||||||
int g = (color >> 8) & 0xFF;
|
|
||||||
int b = (color >> 16) & 0xFF;
|
|
||||||
|
|
||||||
r = (r + redBoost).clamp(0, 255);
|
|
||||||
g = (g * colorDrop).toInt().clamp(0, 255);
|
|
||||||
b = (b * colorDrop).toInt().clamp(0, 255);
|
|
||||||
|
|
||||||
return (0xFF000000) | (b << 16) | (g << 8) | r;
|
|
||||||
}
|
|
||||||
|
|
||||||
int _scaleColor(int color, double brightness) {
|
|
||||||
int r = ((color & 0xFF) * brightness).toInt().clamp(0, 255);
|
|
||||||
int g = (((color >> 8) & 0xFF) * brightness).toInt().clamp(0, 255);
|
|
||||||
int b = (((color >> 16) & 0xFF) * brightness).toInt().clamp(0, 255);
|
|
||||||
return (0xFF000000) | (b << 16) | (g << 8) | r;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _composeTerminalScene() {
|
|
||||||
for (int y = 0; y < height; y++) {
|
|
||||||
int topY = y * 2;
|
|
||||||
int bottomY = math.min(topY + 1, _terminalPixelHeight - 1);
|
|
||||||
for (int x = 0; x < width; x++) {
|
|
||||||
int topColor = _scenePixels[topY][x];
|
|
||||||
int bottomColor = _scenePixels[bottomY][x];
|
|
||||||
|
|
||||||
ColoredChar overlay = _screen[y][x];
|
|
||||||
if (overlay.char != ' ') {
|
|
||||||
if (overlay.rawBackgroundColor == null) {
|
|
||||||
_screen[y][x] = ColoredChar(
|
|
||||||
overlay.char,
|
|
||||||
overlay.rawColor,
|
|
||||||
bottomColor,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
_screen[y][x] = topColor == bottomColor
|
|
||||||
? ColoredChar('█', topColor)
|
|
||||||
: ColoredChar('▀', topColor, bottomColor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Converts the current frame to a single printable ANSI string
|
|
||||||
StringBuffer toAnsiString() {
|
|
||||||
StringBuffer buffer = StringBuffer();
|
|
||||||
|
|
||||||
int? lastForeground;
|
|
||||||
int? lastBackground;
|
|
||||||
|
|
||||||
for (int y = 0; y < _screen.length; y++) {
|
|
||||||
List<ColoredChar> row = _screen[y];
|
|
||||||
for (ColoredChar cell in row) {
|
|
||||||
if (cell.rawColor != lastForeground) {
|
|
||||||
buffer.write('\x1b[38;2;${cell.r};${cell.g};${cell.b}m');
|
|
||||||
lastForeground = cell.rawColor;
|
|
||||||
}
|
|
||||||
if (cell.rawBackgroundColor != lastBackground) {
|
|
||||||
if (cell.rawBackgroundColor == null) {
|
|
||||||
buffer.write('\x1b[49m');
|
|
||||||
} else {
|
|
||||||
int background = cell.rawBackgroundColor!;
|
|
||||||
int bgR = background & 0xFF;
|
|
||||||
int bgG = (background >> 8) & 0xFF;
|
|
||||||
int bgB = (background >> 16) & 0xFF;
|
|
||||||
buffer.write(
|
|
||||||
'\x1b[48;2;$bgR;$bgG;$bgB'
|
|
||||||
'm',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
lastBackground = cell.rawBackgroundColor;
|
|
||||||
}
|
|
||||||
buffer.write(cell.char);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only print a newline if we are NOT on the very last row.
|
|
||||||
// This stops the terminal from scrolling down!
|
|
||||||
if (y < _screen.length - 1) {
|
|
||||||
buffer.write('\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset the terminal color at the very end
|
|
||||||
buffer.write('\x1b[0m');
|
|
||||||
|
|
||||||
return buffer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
import 'dart:math' as math;
|
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:wolf_3d_dart/src/rasterizer/cli_rasterizer.dart';
|
|
||||||
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
|
||||||
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
|
||||||
|
|
||||||
class SixelRasterizer extends CliRasterizer<String> {
|
|
||||||
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 _maxRenderWidth = 320;
|
|
||||||
static const int _maxRenderHeight = 240;
|
|
||||||
static const String _terminalTealBackground = '\x1b[48;2;0;150;136m';
|
|
||||||
|
|
||||||
late Uint8List _screen;
|
|
||||||
late WolfEngine _engine;
|
|
||||||
int _offsetColumns = 0;
|
|
||||||
int _offsetRows = 0;
|
|
||||||
int _outputWidth = 1;
|
|
||||||
int _outputHeight = 1;
|
|
||||||
bool _needsBackgroundClear = true;
|
|
||||||
|
|
||||||
FrameBuffer _createScaledBuffer(FrameBuffer terminalBuffer) {
|
|
||||||
final int previousOffsetColumns = _offsetColumns;
|
|
||||||
final int previousOffsetRows = _offsetRows;
|
|
||||||
final int previousOutputWidth = _outputWidth;
|
|
||||||
final int previousOutputHeight = _outputHeight;
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
final int boundsPixelWidth = math.max(
|
|
||||||
1,
|
|
||||||
(targetColumns * _defaultLineHeightPx * _defaultCellWidthToHeight)
|
|
||||||
.floor(),
|
|
||||||
);
|
|
||||||
final int boundsPixelHeight = math.max(
|
|
||||||
1,
|
|
||||||
targetRows * _defaultLineHeightPx,
|
|
||||||
);
|
|
||||||
|
|
||||||
final double boundsAspect = boundsPixelWidth / boundsPixelHeight;
|
|
||||||
if (boundsAspect > _targetAspectRatio) {
|
|
||||||
_outputHeight = boundsPixelHeight;
|
|
||||||
_outputWidth = math.max(1, (_outputHeight * _targetAspectRatio).floor());
|
|
||||||
} else {
|
|
||||||
_outputWidth = boundsPixelWidth;
|
|
||||||
_outputHeight = math.max(1, (_outputWidth / _targetAspectRatio).floor());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_offsetColumns != previousOffsetColumns ||
|
|
||||||
_offsetRows != previousOffsetRows ||
|
|
||||||
_outputWidth != previousOutputWidth ||
|
|
||||||
_outputHeight != previousOutputHeight) {
|
|
||||||
_needsBackgroundClear = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
final double renderScale = math.min(
|
|
||||||
1.0,
|
|
||||||
math.min(
|
|
||||||
_maxRenderWidth / _outputWidth,
|
|
||||||
_maxRenderHeight / _outputHeight,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final int renderWidth = math.max(1, (_outputWidth * renderScale).floor());
|
|
||||||
final int renderHeight = math.max(1, (_outputHeight * renderScale).floor());
|
|
||||||
|
|
||||||
return FrameBuffer(renderWidth, renderHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String render(WolfEngine engine) {
|
|
||||||
_engine = engine;
|
|
||||||
final FrameBuffer originalBuffer = engine.frameBuffer;
|
|
||||||
final FrameBuffer scaledBuffer = _createScaledBuffer(originalBuffer);
|
|
||||||
// We only need 8-bit indices for the 256 VGA colors
|
|
||||||
_screen = Uint8List(scaledBuffer.width * scaledBuffer.height);
|
|
||||||
engine.frameBuffer = scaledBuffer;
|
|
||||||
try {
|
|
||||||
return super.render(engine);
|
|
||||||
} finally {
|
|
||||||
engine.frameBuffer = originalBuffer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void prepareFrame(WolfEngine engine) {
|
|
||||||
// Top half is ceiling color index (25), bottom half is floor color index (29)
|
|
||||||
for (int y = 0; y < viewHeight; y++) {
|
|
||||||
int colorIndex = (y < viewHeight / 2) ? 25 : 29;
|
|
||||||
for (int x = 0; x < width; x++) {
|
|
||||||
_screen[y * width + x] = colorIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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++) {
|
|
||||||
double relativeY =
|
|
||||||
(y - (-columnHeight ~/ 2 + viewHeight ~/ 2)) / columnHeight;
|
|
||||||
int texY = (relativeY * 64).toInt().clamp(0, 63);
|
|
||||||
|
|
||||||
int colorByte = texture.pixels[texX * 64 + texY];
|
|
||||||
|
|
||||||
// Note: Directional shading is omitted here to preserve strict VGA palette indices.
|
|
||||||
// Sixel uses a fixed 256-color palette, so real-time shading requires a lookup table.
|
|
||||||
_screen[y * width + x] = colorByte;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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
|
|
||||||
if (colorByte != 255) {
|
|
||||||
_screen[y * width + stripeX] = 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) {
|
|
||||||
_screen[drawY * width + drawX] = colorByte;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void drawHud(WolfEngine engine) {
|
|
||||||
int statusBarIndex = engine.data.vgaImages.indexWhere(
|
|
||||||
(img) => img.width == 320 && img.height == 40,
|
|
||||||
);
|
|
||||||
if (statusBarIndex == -1) return;
|
|
||||||
|
|
||||||
_blitVgaImage(engine.data.vgaImages[statusBarIndex], 0, 160);
|
|
||||||
|
|
||||||
_drawNumber(1, 32, 176, engine.data.vgaImages);
|
|
||||||
_drawNumber(engine.player.score, 96, 176, engine.data.vgaImages);
|
|
||||||
_drawNumber(3, 120, 176, engine.data.vgaImages);
|
|
||||||
_drawNumber(engine.player.health, 192, 176, engine.data.vgaImages);
|
|
||||||
_drawNumber(engine.player.ammo, 232, 176, engine.data.vgaImages);
|
|
||||||
|
|
||||||
_drawFace(engine);
|
|
||||||
_drawWeaponIcon(engine);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String finalizeFrame() {
|
|
||||||
final String clearPrefix = _needsBackgroundClear
|
|
||||||
? '$_terminalTealBackground\x1b[2J\x1b[0m'
|
|
||||||
: '';
|
|
||||||
_needsBackgroundClear = false;
|
|
||||||
return '$clearPrefix\x1b[${_offsetRows + 1};${_offsetColumns + 1}H${toSixelString()}';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// SIXEL ENCODER
|
|
||||||
// ===========================================================================
|
|
||||||
|
|
||||||
/// Converts the 8-bit index buffer into a standard Sixel sequence
|
|
||||||
String toSixelString() {
|
|
||||||
StringBuffer sb = StringBuffer();
|
|
||||||
|
|
||||||
// Start Sixel sequence (q = Sixel format)
|
|
||||||
sb.write('\x1bPq');
|
|
||||||
|
|
||||||
// 1. Define the Palette (and apply damage flash directly to the palette!)
|
|
||||||
double damageIntensity = _engine.player.damageFlash;
|
|
||||||
int redBoost = (150 * damageIntensity).toInt();
|
|
||||||
double colorDrop = 1.0 - (0.5 * damageIntensity);
|
|
||||||
|
|
||||||
for (int i = 0; i < 256; i++) {
|
|
||||||
int color = ColorPalette.vga32Bit[i];
|
|
||||||
int r = color & 0xFF;
|
|
||||||
int g = (color >> 8) & 0xFF;
|
|
||||||
int b = (color >> 16) & 0xFF;
|
|
||||||
|
|
||||||
if (damageIntensity > 0) {
|
|
||||||
r = (r + redBoost).clamp(0, 255);
|
|
||||||
g = (g * colorDrop).toInt().clamp(0, 255);
|
|
||||||
b = (b * colorDrop).toInt().clamp(0, 255);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sixel RGB ranges from 0 to 100
|
|
||||||
int sixelR = (r * 100) ~/ 255;
|
|
||||||
int sixelG = (g * 100) ~/ 255;
|
|
||||||
int sixelB = (b * 100) ~/ 255;
|
|
||||||
|
|
||||||
sb.write('#$i;2;$sixelR;$sixelG;$sixelB');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Encode scaled image in 6-pixel vertical bands.
|
|
||||||
for (int band = 0; band < _outputHeight; band += 6) {
|
|
||||||
Map<int, Uint8List> colorMap = {};
|
|
||||||
|
|
||||||
// Map out which pixels use which color in this 6px high band
|
|
||||||
for (int x = 0; x < _outputWidth; x++) {
|
|
||||||
for (int yOffset = 0; yOffset < 6; yOffset++) {
|
|
||||||
int y = band + yOffset;
|
|
||||||
if (y >= _outputHeight) break;
|
|
||||||
|
|
||||||
int colorIdx = _sampleScaledPixel(x, y);
|
|
||||||
if (!colorMap.containsKey(colorIdx)) {
|
|
||||||
colorMap[colorIdx] = Uint8List(_outputWidth);
|
|
||||||
}
|
|
||||||
// Set the bit corresponding to the vertical position (0-5)
|
|
||||||
colorMap[colorIdx]![x] |= (1 << yOffset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write the encoded Sixel characters for each color present in the band
|
|
||||||
bool firstColor = true;
|
|
||||||
for (var entry in colorMap.entries) {
|
|
||||||
if (!firstColor) {
|
|
||||||
// Carriage return to overlay colors on the same band
|
|
||||||
sb.write('\$');
|
|
||||||
}
|
|
||||||
firstColor = false;
|
|
||||||
|
|
||||||
// Select color index
|
|
||||||
sb.write('#${entry.key}');
|
|
||||||
|
|
||||||
Uint8List cols = entry.value;
|
|
||||||
int currentVal = -1;
|
|
||||||
int runLength = 0;
|
|
||||||
|
|
||||||
// Run-Length Encoding (RLE) loop
|
|
||||||
for (int x = 0; x < _outputWidth; x++) {
|
|
||||||
int val = cols[x];
|
|
||||||
if (val == currentVal) {
|
|
||||||
runLength++;
|
|
||||||
} else {
|
|
||||||
if (runLength > 0) _writeSixelRle(sb, currentVal, runLength);
|
|
||||||
currentVal = val;
|
|
||||||
runLength = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (runLength > 0) _writeSixelRle(sb, currentVal, runLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (band + 6 < _outputHeight) {
|
|
||||||
sb.write('-');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// End Sixel sequence
|
|
||||||
sb.write('\x1b\\');
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
int _sampleScaledPixel(int outX, int outY) {
|
|
||||||
final int srcX = ((((outX + 0.5) * width) / _outputWidth) - 0.5)
|
|
||||||
.round()
|
|
||||||
.clamp(
|
|
||||||
0,
|
|
||||||
width - 1,
|
|
||||||
);
|
|
||||||
final int srcY = ((((outY + 0.5) * height) / _outputHeight) - 0.5)
|
|
||||||
.round()
|
|
||||||
.clamp(
|
|
||||||
0,
|
|
||||||
height - 1,
|
|
||||||
);
|
|
||||||
return _screen[srcY * width + srcX];
|
|
||||||
}
|
|
||||||
|
|
||||||
void _writeSixelRle(StringBuffer sb, int value, int runLength) {
|
|
||||||
String char = String.fromCharCode(value + 63);
|
|
||||||
// Sixel RLE format: !<count><char> (only worth it if count > 3)
|
|
||||||
if (runLength > 3) {
|
|
||||||
sb.write('!$runLength$char');
|
|
||||||
} else {
|
|
||||||
sb.write(char * runLength);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// PRIVATE HUD HELPERS (Adapted for 8-bit index buffer)
|
|
||||||
// ===========================================================================
|
|
||||||
|
|
||||||
void _blitVgaImage(VgaImage image, int startX, int startY) {
|
|
||||||
int planeWidth = image.width ~/ 4;
|
|
||||||
int planeSize = planeWidth * image.height;
|
|
||||||
final double scaleX = width / 320.0;
|
|
||||||
final double scaleY = height / 200.0;
|
|
||||||
|
|
||||||
final int destStartX = (startX * scaleX).toInt();
|
|
||||||
final int destStartY = (startY * scaleY).toInt();
|
|
||||||
final int destWidth = math.max(1, (image.width * scaleX).toInt());
|
|
||||||
final int destHeight = math.max(1, (image.height * scaleY).toInt());
|
|
||||||
|
|
||||||
for (int dy = 0; dy < destHeight; dy++) {
|
|
||||||
for (int dx = 0; dx < destWidth; dx++) {
|
|
||||||
int drawX = destStartX + dx;
|
|
||||||
int drawY = destStartY + dy;
|
|
||||||
|
|
||||||
if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) {
|
|
||||||
int srcX = (dx / scaleX).toInt().clamp(0, image.width - 1);
|
|
||||||
int srcY = (dy / scaleY).toInt().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) {
|
|
||||||
_screen[drawY * width + drawX] = 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 = (health <= 0)
|
|
||||||
? 127
|
|
||||||
: 106 + (((100 - health) ~/ 16).clamp(0, 6) * 3);
|
|
||||||
if (faceIndex < engine.data.vgaImages.length) {
|
|
||||||
_blitVgaImage(engine.data.vgaImages[faceIndex], 136, 164);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _drawWeaponIcon(WolfEngine engine) {
|
|
||||||
int weaponIndex = 89;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,39 @@ import 'dart:math' as math;
|
|||||||
import 'package:wolf_3d_dart/src/rasterizer/rasterizer.dart';
|
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_data_types.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
|
||||||
|
|
||||||
class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
|
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 FrameBuffer _buffer;
|
||||||
late WolfEngine _engine;
|
late WolfEngine _engine;
|
||||||
|
|
||||||
@@ -145,10 +176,126 @@ class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
|
|||||||
_drawWeaponIcon(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
|
@override
|
||||||
FrameBuffer finalizeFrame() {
|
FrameBuffer finalizeFrame() {
|
||||||
// If the player took damage, overlay a red tint across the 3D view
|
// If the player took damage, overlay a red tint across the 3D view
|
||||||
if (_engine.player.damageFlash > 0) {
|
if (!_engine.isDifficultySelectionPending &&
|
||||||
|
_engine.player.damageFlash > 0) {
|
||||||
_applyDamageFlash();
|
_applyDamageFlash();
|
||||||
}
|
}
|
||||||
return _buffer; // Return the fully painted pixel array
|
return _buffer; // Return the fully painted pixel array
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:wolf_3d_dart/src/menu/menu_manager.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_data_types.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_engine.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
|
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
|
||||||
@@ -13,11 +14,13 @@ import 'package:wolf_3d_dart/wolf_3d_input.dart';
|
|||||||
class WolfEngine {
|
class WolfEngine {
|
||||||
WolfEngine({
|
WolfEngine({
|
||||||
required this.data,
|
required this.data,
|
||||||
required this.difficulty,
|
|
||||||
required this.startingEpisode,
|
required this.startingEpisode,
|
||||||
required this.onGameWon,
|
required this.onGameWon,
|
||||||
required this.input,
|
required this.input,
|
||||||
required this.frameBuffer,
|
required this.frameBuffer,
|
||||||
|
this.difficulty,
|
||||||
|
this.menuBackgroundRgb = 0x890000,
|
||||||
|
this.menuPanelRgb = 0x590002,
|
||||||
EngineAudio? audio,
|
EngineAudio? audio,
|
||||||
}) : audio = audio ?? CliSilentAudio(),
|
}) : audio = audio ?? CliSilentAudio(),
|
||||||
doorManager = DoorManager(
|
doorManager = DoorManager(
|
||||||
@@ -33,8 +36,26 @@ class WolfEngine {
|
|||||||
/// The static game data (textures, sounds, maps) parsed from original files.
|
/// The static game data (textures, sounds, maps) parsed from original files.
|
||||||
final WolfensteinData data;
|
final WolfensteinData data;
|
||||||
|
|
||||||
|
/// Desired menu background color in 24-bit RGB.
|
||||||
|
final int menuBackgroundRgb;
|
||||||
|
|
||||||
|
/// Desired menu panel color in 24-bit RGB.
|
||||||
|
final int menuPanelRgb;
|
||||||
|
|
||||||
/// The active difficulty level, affecting enemy spawning and behavior.
|
/// The active difficulty level, affecting enemy spawning and behavior.
|
||||||
final Difficulty difficulty;
|
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);
|
||||||
|
|
||||||
/// The episode index where the game session begins.
|
/// The episode index where the game session begins.
|
||||||
final int startingEpisode;
|
final int startingEpisode;
|
||||||
@@ -62,7 +83,11 @@ class WolfEngine {
|
|||||||
// --- World State ---
|
// --- World State ---
|
||||||
|
|
||||||
/// The player's current position, stats, and inventory.
|
/// The player's current position, stats, and inventory.
|
||||||
late Player player;
|
///
|
||||||
|
/// This starts with a safe placeholder so menu-mode rendering/input can
|
||||||
|
/// access player fields (for example damage flash state) before a map is
|
||||||
|
/// loaded. `_loadLevel()` replaces it with the true map spawn.
|
||||||
|
Player player = Player(x: 1.5, y: 1.5, angle: 0.0);
|
||||||
|
|
||||||
/// The mutable 64x64 grid representing the current world.
|
/// The mutable 64x64 grid representing the current world.
|
||||||
/// This grid is modified in real-time by doors and pushwalls.
|
/// This grid is modified in real-time by doors and pushwalls.
|
||||||
@@ -91,7 +116,13 @@ class WolfEngine {
|
|||||||
audio.activeGame = data;
|
audio.activeGame = data;
|
||||||
_currentEpisodeIndex = startingEpisode;
|
_currentEpisodeIndex = startingEpisode;
|
||||||
_currentLevelIndex = 0;
|
_currentLevelIndex = 0;
|
||||||
_loadLevel();
|
|
||||||
|
menuManager.beginDifficultySelection(initialDifficulty: difficulty);
|
||||||
|
|
||||||
|
if (!isDifficultySelectionPending) {
|
||||||
|
_loadLevel();
|
||||||
|
}
|
||||||
|
|
||||||
isInitialized = true;
|
isInitialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +150,12 @@ class WolfEngine {
|
|||||||
// 1. Process User Input
|
// 1. Process User Input
|
||||||
input.update();
|
input.update();
|
||||||
final currentInput = input.currentInput;
|
final currentInput = input.currentInput;
|
||||||
|
|
||||||
|
if (isDifficultySelectionPending) {
|
||||||
|
_tickDifficultyMenu(currentInput);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final inputResult = _processInputs(delta, currentInput);
|
final inputResult = _processInputs(delta, currentInput);
|
||||||
|
|
||||||
// 2. Update Environment
|
// 2. Update Environment
|
||||||
@@ -150,6 +187,19 @@ class WolfEngine {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _tickDifficultyMenu(EngineInput input) {
|
||||||
|
final menuResult = menuManager.updateDifficultySelection(input);
|
||||||
|
if (menuResult.goBack) {
|
||||||
|
onGameWon();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (menuResult.selected != null) {
|
||||||
|
difficulty = menuResult.selected;
|
||||||
|
_loadLevel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Wipes the current world state and builds a new floor from map data.
|
/// Wipes the current world state and builds a new floor from map data.
|
||||||
void _loadLevel() {
|
void _loadLevel() {
|
||||||
entities.clear();
|
entities.clear();
|
||||||
@@ -182,7 +232,7 @@ class WolfEngine {
|
|||||||
objId,
|
objId,
|
||||||
x + 0.5,
|
x + 0.5,
|
||||||
y + 0.5,
|
y + 0.5,
|
||||||
difficulty,
|
difficulty!,
|
||||||
data.sprites.length,
|
data.sprites.length,
|
||||||
isSharewareMode: data.version == GameVersion.shareware,
|
isSharewareMode: data.version == GameVersion.shareware,
|
||||||
);
|
);
|
||||||
@@ -401,7 +451,7 @@ class WolfEngine {
|
|||||||
MapObject.ammoClip,
|
MapObject.ammoClip,
|
||||||
entity.x,
|
entity.x,
|
||||||
entity.y,
|
entity.y,
|
||||||
difficulty,
|
difficulty!,
|
||||||
data.sprites.length,
|
data.sprites.length,
|
||||||
);
|
);
|
||||||
if (droppedAmmo != null) itemsToAdd.add(droppedAmmo);
|
if (droppedAmmo != null) itemsToAdd.add(droppedAmmo);
|
||||||
|
|||||||
@@ -14,10 +14,32 @@ class CliInput extends Wolf3dInput {
|
|||||||
bool _pRight = false;
|
bool _pRight = false;
|
||||||
bool _pFire = false;
|
bool _pFire = false;
|
||||||
bool _pInteract = false;
|
bool _pInteract = false;
|
||||||
|
bool _pBack = false;
|
||||||
WeaponType? _pWeapon;
|
WeaponType? _pWeapon;
|
||||||
|
|
||||||
/// Queues a raw terminal key sequence for the next engine frame.
|
/// Queues a raw terminal key sequence for the next engine frame.
|
||||||
void handleKey(List<int> bytes) {
|
void handleKey(List<int> bytes) {
|
||||||
|
// Escape sequences for arrow keys (CSI A/B/C/D) in raw terminal mode.
|
||||||
|
if (bytes.length >= 3 && bytes[0] == 27 && bytes[1] == 91) {
|
||||||
|
if (bytes[2] == 65) _pForward = true; // Up
|
||||||
|
if (bytes[2] == 66) _pBackward = true; // Down
|
||||||
|
if (bytes[2] == 67) _pRight = true; // Right
|
||||||
|
if (bytes[2] == 68) _pLeft = true; // Left
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bare Escape key is a menu back action.
|
||||||
|
if (bytes.length == 1 && bytes[0] == 27) {
|
||||||
|
_pBack = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter maps to menu select/confirm.
|
||||||
|
if (bytes.length == 1 && (bytes[0] == 13 || bytes[0] == 10)) {
|
||||||
|
_pInteract = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
String char = String.fromCharCodes(bytes).toLowerCase();
|
String char = String.fromCharCodes(bytes).toLowerCase();
|
||||||
|
|
||||||
if (char == 'w') _pForward = true;
|
if (char == 'w') _pForward = true;
|
||||||
@@ -45,10 +67,12 @@ class CliInput extends Wolf3dInput {
|
|||||||
isTurningRight = _pRight;
|
isTurningRight = _pRight;
|
||||||
isFiring = _pFire;
|
isFiring = _pFire;
|
||||||
isInteracting = _pInteract;
|
isInteracting = _pInteract;
|
||||||
|
isBack = _pBack;
|
||||||
requestedWeapon = _pWeapon;
|
requestedWeapon = _pWeapon;
|
||||||
|
|
||||||
// Reset the pending buffer so each keypress behaves like a frame impulse.
|
// Reset the pending buffer so each keypress behaves like a frame impulse.
|
||||||
_pForward = _pBackward = _pLeft = _pRight = _pFire = _pInteract = false;
|
_pForward = _pBackward = _pLeft = _pRight = _pFire = _pInteract = false;
|
||||||
|
_pBack = false;
|
||||||
_pWeapon = null;
|
_pWeapon = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ abstract class Wolf3dInput {
|
|||||||
bool isTurningLeft = false;
|
bool isTurningLeft = false;
|
||||||
bool isTurningRight = false;
|
bool isTurningRight = false;
|
||||||
bool isInteracting = false;
|
bool isInteracting = false;
|
||||||
|
bool isBack = false;
|
||||||
|
double? menuTapX;
|
||||||
|
double? menuTapY;
|
||||||
bool isFiring = false;
|
bool isFiring = false;
|
||||||
WeaponType? requestedWeapon;
|
WeaponType? requestedWeapon;
|
||||||
|
|
||||||
@@ -22,6 +25,9 @@ abstract class Wolf3dInput {
|
|||||||
isTurningRight: isTurningRight,
|
isTurningRight: isTurningRight,
|
||||||
isFiring: isFiring,
|
isFiring: isFiring,
|
||||||
isInteracting: isInteracting,
|
isInteracting: isInteracting,
|
||||||
|
isBack: isBack,
|
||||||
|
menuTapX: menuTapX,
|
||||||
|
menuTapY: menuTapY,
|
||||||
requestedWeapon: requestedWeapon,
|
requestedWeapon: requestedWeapon,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -33,6 +39,7 @@ enum WolfInputAction {
|
|||||||
turnRight,
|
turnRight,
|
||||||
fire,
|
fire,
|
||||||
interact,
|
interact,
|
||||||
|
back,
|
||||||
weapon1,
|
weapon1,
|
||||||
weapon2,
|
weapon2,
|
||||||
weapon3,
|
weapon3,
|
||||||
|
|||||||
94
packages/wolf_3d_dart/lib/src/menu/menu_manager.dart
Normal file
94
packages/wolf_3d_dart/lib/src/menu/menu_manager.dart
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
||||||
|
|
||||||
|
/// Handles menu-only input state such as selection movement and edge triggers.
|
||||||
|
class MenuManager {
|
||||||
|
int _selectedDifficultyIndex = 0;
|
||||||
|
|
||||||
|
bool _prevUp = false;
|
||||||
|
bool _prevDown = false;
|
||||||
|
bool _prevConfirm = false;
|
||||||
|
bool _prevBack = false;
|
||||||
|
|
||||||
|
/// Current selected difficulty row index.
|
||||||
|
int get selectedDifficultyIndex => _selectedDifficultyIndex;
|
||||||
|
|
||||||
|
/// Resets menu navigation state for a new difficulty selection flow.
|
||||||
|
void beginDifficultySelection({Difficulty? initialDifficulty}) {
|
||||||
|
_selectedDifficultyIndex = initialDifficulty == null
|
||||||
|
? 0
|
||||||
|
: Difficulty.values
|
||||||
|
.indexOf(initialDifficulty)
|
||||||
|
.clamp(
|
||||||
|
0,
|
||||||
|
Difficulty.values.length - 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
_prevUp = false;
|
||||||
|
_prevDown = false;
|
||||||
|
_prevConfirm = false;
|
||||||
|
_prevBack = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a menu action snapshot for this frame.
|
||||||
|
({Difficulty? selected, bool goBack}) updateDifficultySelection(
|
||||||
|
EngineInput input,
|
||||||
|
) {
|
||||||
|
final upNow = input.isMovingForward;
|
||||||
|
final downNow = input.isMovingBackward;
|
||||||
|
final confirmNow = input.isInteracting || input.isFiring;
|
||||||
|
final backNow = input.isBack;
|
||||||
|
|
||||||
|
if (upNow && !_prevUp) {
|
||||||
|
_selectedDifficultyIndex =
|
||||||
|
(_selectedDifficultyIndex - 1 + Difficulty.values.length) %
|
||||||
|
Difficulty.values.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (downNow && !_prevDown) {
|
||||||
|
_selectedDifficultyIndex =
|
||||||
|
(_selectedDifficultyIndex + 1) % Difficulty.values.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pointer/touch selection for hosts that provide menu tap coordinates.
|
||||||
|
if (input.menuTapX != null && input.menuTapY != null) {
|
||||||
|
final x320 = (input.menuTapX!.clamp(0.0, 1.0) * 320).toDouble();
|
||||||
|
final y200 = (input.menuTapY!.clamp(0.0, 1.0) * 200).toDouble();
|
||||||
|
|
||||||
|
const panelX = 28.0;
|
||||||
|
const panelY = 70.0;
|
||||||
|
const panelW = 264.0;
|
||||||
|
const panelH = 82.0;
|
||||||
|
const rowYStart = 86.0;
|
||||||
|
const rowStep = 15.0;
|
||||||
|
|
||||||
|
if (x320 >= panelX &&
|
||||||
|
x320 <= panelX + panelW &&
|
||||||
|
y200 >= panelY &&
|
||||||
|
y200 <= panelY + panelH) {
|
||||||
|
final index = ((y200 - rowYStart + (rowStep / 2)) / rowStep).floor();
|
||||||
|
if (index >= 0 && index < Difficulty.values.length) {
|
||||||
|
_selectedDifficultyIndex = index;
|
||||||
|
return (selected: Difficulty.values[index], goBack: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Difficulty? selected;
|
||||||
|
if (confirmNow && !_prevConfirm) {
|
||||||
|
selected = Difficulty.values[_selectedDifficultyIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
final bool goBack = backNow && !_prevBack;
|
||||||
|
|
||||||
|
_prevUp = upNow;
|
||||||
|
_prevDown = downNow;
|
||||||
|
_prevConfirm = confirmNow;
|
||||||
|
_prevBack = backNow;
|
||||||
|
|
||||||
|
return (selected: selected, goBack: goBack);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether to show the alternate cursor frame at [elapsedMs].
|
||||||
|
bool isCursorAltFrame(int elapsedMs) => ((elapsedMs ~/ 220) % 2) == 1;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'dart:math' as math;
|
|||||||
import 'package:arcane_helper_utils/arcane_helper_utils.dart';
|
import 'package:arcane_helper_utils/arcane_helper_utils.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_data_types.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_engine.dart';
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
|
||||||
|
|
||||||
import 'cli_rasterizer.dart';
|
import 'cli_rasterizer.dart';
|
||||||
|
|
||||||
@@ -324,6 +325,123 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void drawMenu(WolfEngine engine) {
|
||||||
|
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];
|
||||||
|
|
||||||
|
if (isTerminal) {
|
||||||
|
_fillTerminalRect(0, 0, width, _terminalPixelHeight, bgColor);
|
||||||
|
} else {
|
||||||
|
_fillRect(0, 0, width, height, activeTheme.solid, bgColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
_fillRect320(28, 70, 264, 82, panelColor);
|
||||||
|
|
||||||
|
const heading = 'HOW TOUGH ARE YOU?';
|
||||||
|
final headingY = ((48 / 200) * height).toInt().clamp(0, height - 1);
|
||||||
|
final headingX = ((width - heading.length) ~/ 2).clamp(0, width - 1);
|
||||||
|
_writeString(headingX, headingY, heading, headingColor, bgColor);
|
||||||
|
|
||||||
|
final art = WolfClassicMenuArt(engine.data);
|
||||||
|
|
||||||
|
final face = art.difficultyOption(
|
||||||
|
Difficulty.values[engine.menuSelectedDifficultyIndex],
|
||||||
|
);
|
||||||
|
if (face != null) {
|
||||||
|
_blitVgaImageAscii(face, 28 + 264 - face.width - 10, 92);
|
||||||
|
}
|
||||||
|
|
||||||
|
final cursor = art.pic(engine.isMenuCursorAltFrame ? 9 : 8);
|
||||||
|
const rowYStart = 86;
|
||||||
|
const rowStep = 15;
|
||||||
|
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) {
|
||||||
|
_blitVgaImageAscii(cursor, 38, y - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
final textY = ((y / 200) * height).toInt().clamp(0, height - 1);
|
||||||
|
final textX = ((70 / 320) * width).toInt().clamp(0, width - 1);
|
||||||
|
_writeString(
|
||||||
|
textX,
|
||||||
|
textY,
|
||||||
|
labels[i],
|
||||||
|
isSelected ? selectedTextColor : unselectedTextColor,
|
||||||
|
panelColor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _drawSimpleHud(WolfEngine engine) {
|
void _drawSimpleHud(WolfEngine engine) {
|
||||||
final int hudWidth = isTerminal ? projectionWidth : width;
|
final int hudWidth = isTerminal ? projectionWidth : width;
|
||||||
final int hudRows = height - viewHeight;
|
final int hudRows = height - viewHeight;
|
||||||
@@ -635,7 +753,8 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
dynamic finalizeFrame() {
|
dynamic finalizeFrame() {
|
||||||
if (_engine.player.damageFlash > 0.0) {
|
if (!_engine.isDifficultySelectionPending &&
|
||||||
|
_engine.player.damageFlash > 0.0) {
|
||||||
if (isTerminal) {
|
if (isTerminal) {
|
||||||
_applyDamageFlashToScene();
|
_applyDamageFlashToScene();
|
||||||
} else {
|
} else {
|
||||||
@@ -710,6 +829,29 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _fillRect320(
|
||||||
|
int startX320,
|
||||||
|
int startY200,
|
||||||
|
int w320,
|
||||||
|
int h200,
|
||||||
|
int color,
|
||||||
|
) {
|
||||||
|
final double scaleX = (isTerminal ? projectionWidth : width) / 320.0;
|
||||||
|
final double scaleY = (isTerminal ? _terminalPixelHeight : height) / 200.0;
|
||||||
|
|
||||||
|
final int startX =
|
||||||
|
(isTerminal ? projectionOffsetX : 0) + (startX320 * scaleX).toInt();
|
||||||
|
final int startY = (startY200 * scaleY).toInt();
|
||||||
|
final int w = math.max(1, (w320 * scaleX).toInt());
|
||||||
|
final int h = math.max(1, (h200 * scaleY).toInt());
|
||||||
|
|
||||||
|
if (isTerminal) {
|
||||||
|
_fillTerminalRect(startX, startY, w, h, color);
|
||||||
|
} else {
|
||||||
|
_fillRect(startX, startY, w, h, activeTheme.solid, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- DAMAGE FLASH ---
|
// --- DAMAGE FLASH ---
|
||||||
void _applyDamageFlash() {
|
void _applyDamageFlash() {
|
||||||
for (int y = 0; y < viewHeight; y++) {
|
for (int y = 0; y < viewHeight; y++) {
|
||||||
@@ -835,4 +977,12 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
|||||||
|
|
||||||
return buffer;
|
return buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ abstract class Rasterizer<T> {
|
|||||||
// 1. Setup the frame (clear screen, draw floor/ceiling)
|
// 1. Setup the frame (clear screen, draw floor/ceiling)
|
||||||
prepareFrame(engine);
|
prepareFrame(engine);
|
||||||
|
|
||||||
|
if (engine.isDifficultySelectionPending) {
|
||||||
|
drawMenu(engine);
|
||||||
|
return finalizeFrame();
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Do the heavy math for Raycasting Walls
|
// 2. Do the heavy math for Raycasting Walls
|
||||||
_castWalls(engine);
|
_castWalls(engine);
|
||||||
|
|
||||||
@@ -103,6 +108,11 @@ abstract class Rasterizer<T> {
|
|||||||
/// Return the finished frame (e.g., the FrameBuffer itself, or an ASCII list).
|
/// Return the finished frame (e.g., the FrameBuffer itself, or an ASCII list).
|
||||||
T finalizeFrame();
|
T finalizeFrame();
|
||||||
|
|
||||||
|
/// Draws a non-world menu frame when the engine is awaiting configuration.
|
||||||
|
///
|
||||||
|
/// Default implementation is a no-op for renderers that don't support menus.
|
||||||
|
void drawMenu(WolfEngine engine) {}
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// SHARED LIGHTING MATH
|
// SHARED LIGHTING MATH
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import 'package:wolf_3d_dart/src/rasterizer/rasterizer.dart';
|
|
||||||
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
|
||||||
|
|
||||||
/// Shared terminal orchestration for CLI rasterizers.
|
|
||||||
abstract class CliRasterizer<T> extends Rasterizer<T> {
|
|
||||||
/// Resolves the framebuffer dimensions required by this renderer.
|
|
||||||
///
|
|
||||||
/// The default uses the full terminal size.
|
|
||||||
({int width, int height}) terminalFrameBufferSize(int columns, int rows) {
|
|
||||||
return (width: columns, height: rows);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Applies terminal-size policy and updates the engine framebuffer.
|
|
||||||
///
|
|
||||||
/// Returns `false` when the terminal is too small for this renderer.
|
|
||||||
bool prepareTerminalFrame(
|
|
||||||
WolfEngine engine, {
|
|
||||||
required int columns,
|
|
||||||
required int rows,
|
|
||||||
}) {
|
|
||||||
if (!isTerminalSizeSupported(columns, rows)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
final size = terminalFrameBufferSize(columns, rows);
|
|
||||||
engine.setFrameBuffer(size.width, size.height);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds the standard terminal size warning shown by the CLI host.
|
|
||||||
String buildTerminalSizeWarning({required int columns, required int rows}) {
|
|
||||||
return '\x1b[31m[ ERROR ] TERMINAL TOO SMALL\x1b[0m\n\n'
|
|
||||||
'$terminalSizeRequirement\n'
|
|
||||||
'Current size: \x1b[33m${columns}x$rows\x1b[0m\n\n'
|
|
||||||
'Please resize your window to resume the game...';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
import 'dart:math' as math;
|
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:wolf_3d_dart/src/rasterizer/cli_rasterizer.dart';
|
|
||||||
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
|
||||||
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
|
||||||
|
|
||||||
class SixelRasterizer extends CliRasterizer<String> {
|
|
||||||
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 _maxRenderWidth = 320;
|
|
||||||
static const int _maxRenderHeight = 240;
|
|
||||||
static const String _terminalTealBackground = '\x1b[48;2;0;150;136m';
|
|
||||||
|
|
||||||
late Uint8List _screen;
|
|
||||||
late WolfEngine _engine;
|
|
||||||
int _offsetColumns = 0;
|
|
||||||
int _offsetRows = 0;
|
|
||||||
int _outputWidth = 1;
|
|
||||||
int _outputHeight = 1;
|
|
||||||
bool _needsBackgroundClear = true;
|
|
||||||
|
|
||||||
FrameBuffer _createScaledBuffer(FrameBuffer terminalBuffer) {
|
|
||||||
final int previousOffsetColumns = _offsetColumns;
|
|
||||||
final int previousOffsetRows = _offsetRows;
|
|
||||||
final int previousOutputWidth = _outputWidth;
|
|
||||||
final int previousOutputHeight = _outputHeight;
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
final int boundsPixelWidth = math.max(
|
|
||||||
1,
|
|
||||||
(targetColumns * _defaultLineHeightPx * _defaultCellWidthToHeight)
|
|
||||||
.floor(),
|
|
||||||
);
|
|
||||||
final int boundsPixelHeight = math.max(
|
|
||||||
1,
|
|
||||||
targetRows * _defaultLineHeightPx,
|
|
||||||
);
|
|
||||||
|
|
||||||
final double boundsAspect = boundsPixelWidth / boundsPixelHeight;
|
|
||||||
if (boundsAspect > _targetAspectRatio) {
|
|
||||||
_outputHeight = boundsPixelHeight;
|
|
||||||
_outputWidth = math.max(1, (_outputHeight * _targetAspectRatio).floor());
|
|
||||||
} else {
|
|
||||||
_outputWidth = boundsPixelWidth;
|
|
||||||
_outputHeight = math.max(1, (_outputWidth / _targetAspectRatio).floor());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_offsetColumns != previousOffsetColumns ||
|
|
||||||
_offsetRows != previousOffsetRows ||
|
|
||||||
_outputWidth != previousOutputWidth ||
|
|
||||||
_outputHeight != previousOutputHeight) {
|
|
||||||
_needsBackgroundClear = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
final double renderScale = math.min(
|
|
||||||
1.0,
|
|
||||||
math.min(
|
|
||||||
_maxRenderWidth / _outputWidth,
|
|
||||||
_maxRenderHeight / _outputHeight,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final int renderWidth = math.max(1, (_outputWidth * renderScale).floor());
|
|
||||||
final int renderHeight = math.max(1, (_outputHeight * renderScale).floor());
|
|
||||||
|
|
||||||
return FrameBuffer(renderWidth, renderHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String render(WolfEngine engine) {
|
|
||||||
_engine = engine;
|
|
||||||
final FrameBuffer originalBuffer = engine.frameBuffer;
|
|
||||||
final FrameBuffer scaledBuffer = _createScaledBuffer(originalBuffer);
|
|
||||||
// We only need 8-bit indices for the 256 VGA colors
|
|
||||||
_screen = Uint8List(scaledBuffer.width * scaledBuffer.height);
|
|
||||||
engine.frameBuffer = scaledBuffer;
|
|
||||||
try {
|
|
||||||
return super.render(engine);
|
|
||||||
} finally {
|
|
||||||
engine.frameBuffer = originalBuffer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void prepareFrame(WolfEngine engine) {
|
|
||||||
// Top half is ceiling color index (25), bottom half is floor color index (29)
|
|
||||||
for (int y = 0; y < viewHeight; y++) {
|
|
||||||
int colorIndex = (y < viewHeight / 2) ? 25 : 29;
|
|
||||||
for (int x = 0; x < width; x++) {
|
|
||||||
_screen[y * width + x] = colorIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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++) {
|
|
||||||
double relativeY =
|
|
||||||
(y - (-columnHeight ~/ 2 + viewHeight ~/ 2)) / columnHeight;
|
|
||||||
int texY = (relativeY * 64).toInt().clamp(0, 63);
|
|
||||||
|
|
||||||
int colorByte = texture.pixels[texX * 64 + texY];
|
|
||||||
|
|
||||||
// Note: Directional shading is omitted here to preserve strict VGA palette indices.
|
|
||||||
// Sixel uses a fixed 256-color palette, so real-time shading requires a lookup table.
|
|
||||||
_screen[y * width + x] = colorByte;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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
|
|
||||||
if (colorByte != 255) {
|
|
||||||
_screen[y * width + stripeX] = 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) {
|
|
||||||
_screen[drawY * width + drawX] = colorByte;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void drawHud(WolfEngine engine) {
|
|
||||||
int statusBarIndex = engine.data.vgaImages.indexWhere(
|
|
||||||
(img) => img.width == 320 && img.height == 40,
|
|
||||||
);
|
|
||||||
if (statusBarIndex == -1) return;
|
|
||||||
|
|
||||||
_blitVgaImage(engine.data.vgaImages[statusBarIndex], 0, 160);
|
|
||||||
|
|
||||||
_drawNumber(1, 32, 176, engine.data.vgaImages);
|
|
||||||
_drawNumber(engine.player.score, 96, 176, engine.data.vgaImages);
|
|
||||||
_drawNumber(3, 120, 176, engine.data.vgaImages);
|
|
||||||
_drawNumber(engine.player.health, 192, 176, engine.data.vgaImages);
|
|
||||||
_drawNumber(engine.player.ammo, 232, 176, engine.data.vgaImages);
|
|
||||||
|
|
||||||
_drawFace(engine);
|
|
||||||
_drawWeaponIcon(engine);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String finalizeFrame() {
|
|
||||||
final String clearPrefix = _needsBackgroundClear
|
|
||||||
? '$_terminalTealBackground\x1b[2J\x1b[0m'
|
|
||||||
: '';
|
|
||||||
_needsBackgroundClear = false;
|
|
||||||
return '$clearPrefix\x1b[${_offsetRows + 1};${_offsetColumns + 1}H${toSixelString()}';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// SIXEL ENCODER
|
|
||||||
// ===========================================================================
|
|
||||||
|
|
||||||
/// Converts the 8-bit index buffer into a standard Sixel sequence
|
|
||||||
String toSixelString() {
|
|
||||||
StringBuffer sb = StringBuffer();
|
|
||||||
|
|
||||||
// Start Sixel sequence (q = Sixel format)
|
|
||||||
sb.write('\x1bPq');
|
|
||||||
|
|
||||||
// 1. Define the Palette (and apply damage flash directly to the palette!)
|
|
||||||
double damageIntensity = _engine.player.damageFlash;
|
|
||||||
int redBoost = (150 * damageIntensity).toInt();
|
|
||||||
double colorDrop = 1.0 - (0.5 * damageIntensity);
|
|
||||||
|
|
||||||
for (int i = 0; i < 256; i++) {
|
|
||||||
int color = ColorPalette.vga32Bit[i];
|
|
||||||
int r = color & 0xFF;
|
|
||||||
int g = (color >> 8) & 0xFF;
|
|
||||||
int b = (color >> 16) & 0xFF;
|
|
||||||
|
|
||||||
if (damageIntensity > 0) {
|
|
||||||
r = (r + redBoost).clamp(0, 255);
|
|
||||||
g = (g * colorDrop).toInt().clamp(0, 255);
|
|
||||||
b = (b * colorDrop).toInt().clamp(0, 255);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sixel RGB ranges from 0 to 100
|
|
||||||
int sixelR = (r * 100) ~/ 255;
|
|
||||||
int sixelG = (g * 100) ~/ 255;
|
|
||||||
int sixelB = (b * 100) ~/ 255;
|
|
||||||
|
|
||||||
sb.write('#$i;2;$sixelR;$sixelG;$sixelB');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Encode scaled image in 6-pixel vertical bands.
|
|
||||||
for (int band = 0; band < _outputHeight; band += 6) {
|
|
||||||
Map<int, Uint8List> colorMap = {};
|
|
||||||
|
|
||||||
// Map out which pixels use which color in this 6px high band
|
|
||||||
for (int x = 0; x < _outputWidth; x++) {
|
|
||||||
for (int yOffset = 0; yOffset < 6; yOffset++) {
|
|
||||||
int y = band + yOffset;
|
|
||||||
if (y >= _outputHeight) break;
|
|
||||||
|
|
||||||
int colorIdx = _sampleScaledPixel(x, y);
|
|
||||||
if (!colorMap.containsKey(colorIdx)) {
|
|
||||||
colorMap[colorIdx] = Uint8List(_outputWidth);
|
|
||||||
}
|
|
||||||
// Set the bit corresponding to the vertical position (0-5)
|
|
||||||
colorMap[colorIdx]![x] |= (1 << yOffset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write the encoded Sixel characters for each color present in the band
|
|
||||||
bool firstColor = true;
|
|
||||||
for (var entry in colorMap.entries) {
|
|
||||||
if (!firstColor) {
|
|
||||||
// Carriage return to overlay colors on the same band
|
|
||||||
sb.write('\$');
|
|
||||||
}
|
|
||||||
firstColor = false;
|
|
||||||
|
|
||||||
// Select color index
|
|
||||||
sb.write('#${entry.key}');
|
|
||||||
|
|
||||||
Uint8List cols = entry.value;
|
|
||||||
int currentVal = -1;
|
|
||||||
int runLength = 0;
|
|
||||||
|
|
||||||
// Run-Length Encoding (RLE) loop
|
|
||||||
for (int x = 0; x < _outputWidth; x++) {
|
|
||||||
int val = cols[x];
|
|
||||||
if (val == currentVal) {
|
|
||||||
runLength++;
|
|
||||||
} else {
|
|
||||||
if (runLength > 0) _writeSixelRle(sb, currentVal, runLength);
|
|
||||||
currentVal = val;
|
|
||||||
runLength = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (runLength > 0) _writeSixelRle(sb, currentVal, runLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (band + 6 < _outputHeight) {
|
|
||||||
sb.write('-');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// End Sixel sequence
|
|
||||||
sb.write('\x1b\\');
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
int _sampleScaledPixel(int outX, int outY) {
|
|
||||||
final int srcX = ((((outX + 0.5) * width) / _outputWidth) - 0.5)
|
|
||||||
.round()
|
|
||||||
.clamp(
|
|
||||||
0,
|
|
||||||
width - 1,
|
|
||||||
);
|
|
||||||
final int srcY = ((((outY + 0.5) * height) / _outputHeight) - 0.5)
|
|
||||||
.round()
|
|
||||||
.clamp(
|
|
||||||
0,
|
|
||||||
height - 1,
|
|
||||||
);
|
|
||||||
return _screen[srcY * width + srcX];
|
|
||||||
}
|
|
||||||
|
|
||||||
void _writeSixelRle(StringBuffer sb, int value, int runLength) {
|
|
||||||
String char = String.fromCharCode(value + 63);
|
|
||||||
// Sixel RLE format: !<count><char> (only worth it if count > 3)
|
|
||||||
if (runLength > 3) {
|
|
||||||
sb.write('!$runLength$char');
|
|
||||||
} else {
|
|
||||||
sb.write(char * runLength);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// PRIVATE HUD HELPERS (Adapted for 8-bit index buffer)
|
|
||||||
// ===========================================================================
|
|
||||||
|
|
||||||
void _blitVgaImage(VgaImage image, int startX, int startY) {
|
|
||||||
int planeWidth = image.width ~/ 4;
|
|
||||||
int planeSize = planeWidth * image.height;
|
|
||||||
final double scaleX = width / 320.0;
|
|
||||||
final double scaleY = height / 200.0;
|
|
||||||
|
|
||||||
final int destStartX = (startX * scaleX).toInt();
|
|
||||||
final int destStartY = (startY * scaleY).toInt();
|
|
||||||
final int destWidth = math.max(1, (image.width * scaleX).toInt());
|
|
||||||
final int destHeight = math.max(1, (image.height * scaleY).toInt());
|
|
||||||
|
|
||||||
for (int dy = 0; dy < destHeight; dy++) {
|
|
||||||
for (int dx = 0; dx < destWidth; dx++) {
|
|
||||||
int drawX = destStartX + dx;
|
|
||||||
int drawY = destStartY + dy;
|
|
||||||
|
|
||||||
if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) {
|
|
||||||
int srcX = (dx / scaleX).toInt().clamp(0, image.width - 1);
|
|
||||||
int srcY = (dy / scaleY).toInt().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) {
|
|
||||||
_screen[drawY * width + drawX] = 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 = (health <= 0)
|
|
||||||
? 127
|
|
||||||
: 106 + (((100 - health) ~/ 16).clamp(0, 6) * 3);
|
|
||||||
if (faceIndex < engine.data.vgaImages.length) {
|
|
||||||
_blitVgaImage(engine.data.vgaImages[faceIndex], 136, 164);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _drawWeaponIcon(WolfEngine engine) {
|
|
||||||
int weaponIndex = 89;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,265 +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';
|
|
||||||
|
|
||||||
class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
|
|
||||||
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
|
|
||||||
FrameBuffer finalizeFrame() {
|
|
||||||
// If the player took damage, overlay a red tint across the 3D view
|
|
||||||
if (_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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,7 @@ import 'dart:typed_data';
|
|||||||
|
|
||||||
import 'package:wolf_3d_dart/wolf_3d_data_types.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_engine.dart';
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
|
||||||
|
|
||||||
import 'cli_rasterizer.dart';
|
import 'cli_rasterizer.dart';
|
||||||
|
|
||||||
@@ -17,6 +18,36 @@ import 'cli_rasterizer.dart';
|
|||||||
/// preserving a 4:3 presentation while falling back to size warnings when the
|
/// preserving a 4:3 presentation while falling back to size warnings when the
|
||||||
/// terminal is too small.
|
/// terminal is too small.
|
||||||
class SixelRasterizer extends CliRasterizer<String> {
|
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 double _targetAspectRatio = 4 / 3;
|
||||||
static const int _defaultLineHeightPx = 18;
|
static const int _defaultLineHeightPx = 18;
|
||||||
static const double _defaultCellWidthToHeight = 0.55;
|
static const double _defaultCellWidthToHeight = 0.55;
|
||||||
@@ -326,6 +357,112 @@ class SixelRasterizer extends CliRasterizer<String> {
|
|||||||
_drawWeaponIcon(engine);
|
_drawWeaponIcon(engine);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void drawMenu(WolfEngine engine) {
|
||||||
|
final int bgColor = _rgbToPaletteIndex(engine.menuBackgroundRgb);
|
||||||
|
final int panelColor = _rgbToPaletteIndex(engine.menuPanelRgb);
|
||||||
|
const int headingIndex = 119;
|
||||||
|
const int selectedTextIndex = 19;
|
||||||
|
const int unselectedTextIndex = 23;
|
||||||
|
|
||||||
|
for (int i = 0; i < _screen.length; i++) {
|
||||||
|
_screen[i] = bgColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
_fillRect320(28, 70, 264, 82, panelColor);
|
||||||
|
|
||||||
|
final art = WolfClassicMenuArt(engine.data);
|
||||||
|
_drawMenuTextCentered('HOW TOUGH ARE YOU?', 48, headingIndex, scale: 2);
|
||||||
|
|
||||||
|
final bottom = art.pic(15);
|
||||||
|
if (bottom != null) {
|
||||||
|
_blitVgaImage(bottom, (320 - bottom.width) ~/ 2, 200 - bottom.height - 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
final face = art.difficultyOption(
|
||||||
|
Difficulty.values[engine.menuSelectedDifficultyIndex],
|
||||||
|
);
|
||||||
|
if (face != null) {
|
||||||
|
_blitVgaImage(face, 28 + 264 - face.width - 10, 92);
|
||||||
|
}
|
||||||
|
|
||||||
|
final cursor = art.pic(engine.isMenuCursorAltFrame ? 9 : 8);
|
||||||
|
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 == engine.menuSelectedDifficultyIndex;
|
||||||
|
|
||||||
|
if (isSelected && cursor != null) {
|
||||||
|
_blitVgaImage(cursor, 38, y - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawMenuText(
|
||||||
|
labels[i],
|
||||||
|
textX,
|
||||||
|
y,
|
||||||
|
isSelected ? selectedTextIndex : unselectedTextIndex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawMenuText(
|
||||||
|
String text,
|
||||||
|
int startX,
|
||||||
|
int startY,
|
||||||
|
int colorIndex, {
|
||||||
|
int scale = 1,
|
||||||
|
}) {
|
||||||
|
final double scaleX = width / 320.0;
|
||||||
|
final double scaleY = height / 200.0;
|
||||||
|
|
||||||
|
int x320 = 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 int px320 = x320 + (col * scale) + sx;
|
||||||
|
final int py200 = startY + (row * scale) + sy;
|
||||||
|
|
||||||
|
final int drawX = (px320 * scaleX).toInt();
|
||||||
|
final int drawY = (py200 * scaleY).toInt();
|
||||||
|
if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) {
|
||||||
|
_screen[drawY * width + drawX] = colorIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
x320 += (6 * scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawMenuTextCentered(
|
||||||
|
String text,
|
||||||
|
int y,
|
||||||
|
int colorIndex, {
|
||||||
|
int scale = 1,
|
||||||
|
}) {
|
||||||
|
final int textWidth = text.length * 6 * scale;
|
||||||
|
final int x = ((320 - textWidth) ~/ 2).clamp(0, 319);
|
||||||
|
_drawMenuText(text, x, y, colorIndex, scale: scale);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String finalizeFrame() {
|
String finalizeFrame() {
|
||||||
if (!isSixelSupported) {
|
if (!isSixelSupported) {
|
||||||
@@ -395,7 +532,9 @@ class SixelRasterizer extends CliRasterizer<String> {
|
|||||||
StringBuffer sb = StringBuffer();
|
StringBuffer sb = StringBuffer();
|
||||||
sb.write('\x1bPq');
|
sb.write('\x1bPq');
|
||||||
|
|
||||||
double damageIntensity = _engine.player.damageFlash;
|
double damageIntensity = _engine.isDifficultySelectionPending
|
||||||
|
? 0.0
|
||||||
|
: _engine.player.damageFlash;
|
||||||
int redBoost = (150 * damageIntensity).toInt();
|
int redBoost = (150 * damageIntensity).toInt();
|
||||||
double colorDrop = 1.0 - (0.5 * damageIntensity);
|
double colorDrop = 1.0 - (0.5 * damageIntensity);
|
||||||
|
|
||||||
@@ -530,6 +669,26 @@ class SixelRasterizer extends CliRasterizer<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _fillRect320(int startX, int startY, int w, int h, int colorIndex) {
|
||||||
|
final double scaleX = width / 320.0;
|
||||||
|
final double scaleY = height / 200.0;
|
||||||
|
|
||||||
|
final int destStartX = (startX * scaleX).toInt();
|
||||||
|
final int destStartY = (startY * scaleY).toInt();
|
||||||
|
final int destWidth = math.max(1, (w * scaleX).toInt());
|
||||||
|
final int destHeight = math.max(1, (h * scaleY).toInt());
|
||||||
|
|
||||||
|
for (int dy = 0; dy < destHeight; dy++) {
|
||||||
|
for (int dx = 0; dx < destWidth; dx++) {
|
||||||
|
final int drawX = destStartX + dx;
|
||||||
|
final int drawY = destStartY + dy;
|
||||||
|
if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) {
|
||||||
|
_screen[drawY * width + drawX] = colorIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _drawNumber(
|
void _drawNumber(
|
||||||
int value,
|
int value,
|
||||||
int rightAlignX,
|
int rightAlignX,
|
||||||
@@ -571,4 +730,32 @@ class SixelRasterizer extends CliRasterizer<String> {
|
|||||||
_blitVgaImage(engine.data.vgaImages[weaponIndex], 256, 164);
|
_blitVgaImage(engine.data.vgaImages[weaponIndex], 256, 164);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 dist = (dr * dr) + (dg * dg) + (db * db);
|
||||||
|
|
||||||
|
if (dist < bestDistance) {
|
||||||
|
bestDistance = dist;
|
||||||
|
bestIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestIndex;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,39 @@ import 'dart:math' as math;
|
|||||||
import 'package:wolf_3d_dart/src/rasterizer/rasterizer.dart';
|
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_data_types.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
|
||||||
|
|
||||||
class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
|
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 FrameBuffer _buffer;
|
||||||
late WolfEngine _engine;
|
late WolfEngine _engine;
|
||||||
|
|
||||||
@@ -145,10 +176,135 @@ class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
|
|||||||
_drawWeaponIcon(engine);
|
_drawWeaponIcon(engine);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void drawMenu(WolfEngine engine) {
|
||||||
|
final int bgColor = _rgbToFrameColor(engine.menuBackgroundRgb);
|
||||||
|
final int panelColor = _rgbToFrameColor(engine.menuPanelRgb);
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int _rgbToFrameColor(int rgb) {
|
||||||
|
final int r = (rgb >> 16) & 0xFF;
|
||||||
|
final int g = (rgb >> 8) & 0xFF;
|
||||||
|
final int b = rgb & 0xFF;
|
||||||
|
// Framebuffer expects bytes in RGBA order; this packed int produces that
|
||||||
|
// layout on little-endian platforms.
|
||||||
|
return (0xFF000000) | (b << 16) | (g << 8) | r;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
@override
|
||||||
FrameBuffer finalizeFrame() {
|
FrameBuffer finalizeFrame() {
|
||||||
// If the player took damage, overlay a red tint across the 3D view
|
// If the player took damage, overlay a red tint across the 3D view
|
||||||
if (_engine.player.damageFlash > 0) {
|
if (!_engine.isDifficultySelectionPending &&
|
||||||
|
_engine.player.damageFlash > 0) {
|
||||||
_applyDamageFlash();
|
_applyDamageFlash();
|
||||||
}
|
}
|
||||||
return _buffer; // Return the fully painted pixel array
|
return _buffer; // Return the fully painted pixel array
|
||||||
|
|||||||
@@ -1,473 +0,0 @@
|
|||||||
import 'dart:math' as math;
|
|
||||||
|
|
||||||
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_entities.dart';
|
|
||||||
|
|
||||||
abstract class Rasterizer<T> {
|
|
||||||
late List<double> zBuffer;
|
|
||||||
late int width;
|
|
||||||
late int height;
|
|
||||||
late int viewHeight;
|
|
||||||
|
|
||||||
/// A multiplier to adjust the width of sprites.
|
|
||||||
/// Pixel renderers usually keep this at 1.0.
|
|
||||||
/// ASCII renderers can override this (e.g., 0.6) to account for tall characters.
|
|
||||||
double get aspectMultiplier => 1.0;
|
|
||||||
|
|
||||||
/// A multiplier to counteract tall pixel formats (like 1:2 terminal fonts).
|
|
||||||
/// Defaults to 1.0 (no squish) for standard pixel rendering.
|
|
||||||
double get verticalStretch => 1.0;
|
|
||||||
|
|
||||||
/// The logical width of the projection area used for raycasting and sprites.
|
|
||||||
/// Most renderers use the full buffer width.
|
|
||||||
int get projectionWidth => width;
|
|
||||||
|
|
||||||
/// Horizontal offset of the projection area within the output buffer.
|
|
||||||
int get projectionOffsetX => 0;
|
|
||||||
|
|
||||||
/// The logical height of the 3D projection before a renderer maps rows to output pixels.
|
|
||||||
/// Most renderers use the visible view height. Terminal ASCII can override this to render
|
|
||||||
/// more vertical detail and collapse it into half-block glyphs.
|
|
||||||
int get projectionViewHeight => viewHeight;
|
|
||||||
|
|
||||||
/// Whether the current terminal dimensions are supported by this renderer.
|
|
||||||
/// Default renderers accept all sizes.
|
|
||||||
bool isTerminalSizeSupported(int columns, int rows) => true;
|
|
||||||
|
|
||||||
/// Human-readable requirement text used by the host app when size checks fail.
|
|
||||||
String get terminalSizeRequirement => 'Please resize your terminal window.';
|
|
||||||
|
|
||||||
/// The main entry point called by the game loop.
|
|
||||||
/// Orchestrates the mathematical rendering pipeline.
|
|
||||||
T render(WolfEngine engine) {
|
|
||||||
width = engine.frameBuffer.width;
|
|
||||||
height = engine.frameBuffer.height;
|
|
||||||
// The 3D view typically takes up the top 80% of the screen
|
|
||||||
viewHeight = (height * 0.8).toInt();
|
|
||||||
zBuffer = List.filled(projectionWidth, 0.0);
|
|
||||||
|
|
||||||
// 1. Setup the frame (clear screen, draw floor/ceiling)
|
|
||||||
prepareFrame(engine);
|
|
||||||
|
|
||||||
// 2. Do the heavy math for Raycasting Walls
|
|
||||||
_castWalls(engine);
|
|
||||||
|
|
||||||
// 3. Do the heavy math for Projecting Sprites
|
|
||||||
_castSprites(engine);
|
|
||||||
|
|
||||||
// 4. Draw 2D Overlays
|
|
||||||
drawWeapon(engine);
|
|
||||||
drawHud(engine);
|
|
||||||
|
|
||||||
// 5. Finalize and return the frame data (Buffer or String/List)
|
|
||||||
return finalizeFrame();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// ABSTRACT METHODS (Implemented by the child renderers)
|
|
||||||
// ===========================================================================
|
|
||||||
|
|
||||||
/// Initialize buffers, clear the screen, and draw the floor/ceiling.
|
|
||||||
void prepareFrame(WolfEngine engine);
|
|
||||||
|
|
||||||
/// Draw a single vertical column of a wall.
|
|
||||||
void drawWallColumn(
|
|
||||||
int x,
|
|
||||||
int drawStart,
|
|
||||||
int drawEnd,
|
|
||||||
int columnHeight,
|
|
||||||
Sprite texture,
|
|
||||||
int texX,
|
|
||||||
double perpWallDist,
|
|
||||||
int side,
|
|
||||||
);
|
|
||||||
|
|
||||||
/// Draw a single vertical stripe of a sprite (enemy/item).
|
|
||||||
void drawSpriteStripe(
|
|
||||||
int stripeX,
|
|
||||||
int drawStartY,
|
|
||||||
int drawEndY,
|
|
||||||
int spriteHeight,
|
|
||||||
Sprite texture,
|
|
||||||
int texX,
|
|
||||||
double transformY,
|
|
||||||
);
|
|
||||||
|
|
||||||
/// Draw the player's weapon overlay at the bottom of the 3D view.
|
|
||||||
void drawWeapon(WolfEngine engine);
|
|
||||||
|
|
||||||
/// Draw the 2D status bar at the bottom 20% of the screen.
|
|
||||||
void drawHud(WolfEngine engine);
|
|
||||||
|
|
||||||
/// Return the finished frame (e.g., the FrameBuffer itself, or an ASCII list).
|
|
||||||
T finalizeFrame();
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// SHARED LIGHTING MATH
|
|
||||||
// ===========================================================================
|
|
||||||
|
|
||||||
/// 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 renderers (like ASCII dithering).
|
|
||||||
double calculateDepthBrightness(double distance) {
|
|
||||||
return (10.0 / (distance + 2.0)).clamp(0.0, 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
({double distance, int side, int hitWallId, double wallX})?
|
|
||||||
_intersectActivePushwall(
|
|
||||||
Player player,
|
|
||||||
Coordinate2D rayDir,
|
|
||||||
Pushwall activePushwall,
|
|
||||||
) {
|
|
||||||
double minX = activePushwall.x.toDouble();
|
|
||||||
double maxX = activePushwall.x + 1.0;
|
|
||||||
double minY = activePushwall.y.toDouble();
|
|
||||||
double maxY = activePushwall.y + 1.0;
|
|
||||||
|
|
||||||
if (activePushwall.dirX != 0) {
|
|
||||||
final double delta = activePushwall.dirX * activePushwall.offset;
|
|
||||||
minX += delta;
|
|
||||||
maxX += delta;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activePushwall.dirY != 0) {
|
|
||||||
final double delta = activePushwall.dirY * activePushwall.offset;
|
|
||||||
minY += delta;
|
|
||||||
maxY += delta;
|
|
||||||
}
|
|
||||||
|
|
||||||
const double epsilon = 1e-9;
|
|
||||||
|
|
||||||
double tMinX = double.negativeInfinity;
|
|
||||||
double tMaxX = double.infinity;
|
|
||||||
if (rayDir.x.abs() < epsilon) {
|
|
||||||
if (player.x < minX || player.x > maxX) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
final double tx1 = (minX - player.x) / rayDir.x;
|
|
||||||
final double tx2 = (maxX - player.x) / rayDir.x;
|
|
||||||
tMinX = math.min(tx1, tx2);
|
|
||||||
tMaxX = math.max(tx1, tx2);
|
|
||||||
}
|
|
||||||
|
|
||||||
double tMinY = double.negativeInfinity;
|
|
||||||
double tMaxY = double.infinity;
|
|
||||||
if (rayDir.y.abs() < epsilon) {
|
|
||||||
if (player.y < minY || player.y > maxY) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
final double ty1 = (minY - player.y) / rayDir.y;
|
|
||||||
final double ty2 = (maxY - player.y) / rayDir.y;
|
|
||||||
tMinY = math.min(ty1, ty2);
|
|
||||||
tMaxY = math.max(ty1, ty2);
|
|
||||||
}
|
|
||||||
|
|
||||||
final double entryDistance = math.max(tMinX, tMinY);
|
|
||||||
final double exitDistance = math.min(tMaxX, tMaxY);
|
|
||||||
|
|
||||||
if (exitDistance < 0 || entryDistance > exitDistance) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final double hitDistance = entryDistance >= 0
|
|
||||||
? entryDistance
|
|
||||||
: exitDistance;
|
|
||||||
if (hitDistance < 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final int side = tMinX > tMinY ? 0 : 1;
|
|
||||||
final double wallCoord = side == 0
|
|
||||||
? player.y + hitDistance * rayDir.y
|
|
||||||
: player.x + hitDistance * rayDir.x;
|
|
||||||
|
|
||||||
return (
|
|
||||||
distance: hitDistance,
|
|
||||||
side: side,
|
|
||||||
hitWallId: activePushwall.mapId,
|
|
||||||
wallX: wallCoord - wallCoord.floor(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// CORE ENGINE MATH (Shared across all renderers)
|
|
||||||
// ===========================================================================
|
|
||||||
|
|
||||||
void _castWalls(WolfEngine engine) {
|
|
||||||
final Player player = engine.player;
|
|
||||||
final SpriteMap map = engine.currentLevel;
|
|
||||||
final List<Sprite> wallTextures = engine.data.walls;
|
|
||||||
final int sceneWidth = projectionWidth;
|
|
||||||
final int sceneHeight = projectionViewHeight;
|
|
||||||
|
|
||||||
final Map<String, double> doorOffsets = engine.doorManager
|
|
||||||
.getOffsetsForRenderer();
|
|
||||||
final Pushwall? activePushwall = engine.pushwallManager.activePushwall;
|
|
||||||
|
|
||||||
final double fov = math.pi / 3;
|
|
||||||
Coordinate2D dir = Coordinate2D(
|
|
||||||
math.cos(player.angle),
|
|
||||||
math.sin(player.angle),
|
|
||||||
);
|
|
||||||
Coordinate2D plane = Coordinate2D(-dir.y, dir.x) * math.tan(fov / 2);
|
|
||||||
|
|
||||||
for (int x = 0; x < sceneWidth; x++) {
|
|
||||||
double cameraX = 2 * x / sceneWidth - 1.0;
|
|
||||||
Coordinate2D rayDir = dir + (plane * cameraX);
|
|
||||||
final pushwallHit = activePushwall == null
|
|
||||||
? null
|
|
||||||
: _intersectActivePushwall(player, rayDir, activePushwall);
|
|
||||||
|
|
||||||
int mapX = player.x.toInt();
|
|
||||||
int mapY = player.y.toInt();
|
|
||||||
|
|
||||||
double deltaDistX = (rayDir.x == 0) ? 1e30 : (1.0 / rayDir.x).abs();
|
|
||||||
double deltaDistY = (rayDir.y == 0) ? 1e30 : (1.0 / rayDir.y).abs();
|
|
||||||
|
|
||||||
double sideDistX, sideDistY, perpWallDist = 0.0;
|
|
||||||
int stepX, stepY, side = 0, hitWallId = 0;
|
|
||||||
bool hit = false, hitOutOfBounds = false, customDistCalculated = false;
|
|
||||||
double textureOffset = 0.0;
|
|
||||||
double? wallXOverride;
|
|
||||||
Set<String> ignoredDoors = {};
|
|
||||||
|
|
||||||
if (rayDir.x < 0) {
|
|
||||||
stepX = -1;
|
|
||||||
sideDistX = (player.x - mapX) * deltaDistX;
|
|
||||||
} else {
|
|
||||||
stepX = 1;
|
|
||||||
sideDistX = (mapX + 1.0 - player.x) * deltaDistX;
|
|
||||||
}
|
|
||||||
if (rayDir.y < 0) {
|
|
||||||
stepY = -1;
|
|
||||||
sideDistY = (player.y - mapY) * deltaDistY;
|
|
||||||
} else {
|
|
||||||
stepY = 1;
|
|
||||||
sideDistY = (mapY + 1.0 - player.y) * deltaDistY;
|
|
||||||
}
|
|
||||||
|
|
||||||
// DDA Loop
|
|
||||||
while (!hit) {
|
|
||||||
if (sideDistX < sideDistY) {
|
|
||||||
sideDistX += deltaDistX;
|
|
||||||
mapX += stepX;
|
|
||||||
side = 0;
|
|
||||||
} else {
|
|
||||||
sideDistY += deltaDistY;
|
|
||||||
mapY += stepY;
|
|
||||||
side = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mapY < 0 ||
|
|
||||||
mapY >= map.length ||
|
|
||||||
mapX < 0 ||
|
|
||||||
mapX >= map[0].length) {
|
|
||||||
hit = true;
|
|
||||||
hitOutOfBounds = true;
|
|
||||||
} else if (map[mapY][mapX] > 0) {
|
|
||||||
if (activePushwall != null &&
|
|
||||||
mapX == activePushwall.x &&
|
|
||||||
mapY == activePushwall.y) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
String mapKey = '$mapX,$mapY';
|
|
||||||
|
|
||||||
// DOOR LOGIC
|
|
||||||
if (map[mapY][mapX] >= 90 && !ignoredDoors.contains(mapKey)) {
|
|
||||||
double currentOffset = doorOffsets[mapKey] ?? 0.0;
|
|
||||||
if (currentOffset > 0.0) {
|
|
||||||
double perpWallDistTemp = (side == 0)
|
|
||||||
? (sideDistX - deltaDistX)
|
|
||||||
: (sideDistY - deltaDistY);
|
|
||||||
double wallXTemp = (side == 0)
|
|
||||||
? player.y + perpWallDistTemp * rayDir.y
|
|
||||||
: player.x + perpWallDistTemp * rayDir.x;
|
|
||||||
wallXTemp -= wallXTemp.floor();
|
|
||||||
if (wallXTemp < currentOffset) {
|
|
||||||
ignoredDoors.add(mapKey);
|
|
||||||
continue; // Ray passes through the open part of the door
|
|
||||||
}
|
|
||||||
}
|
|
||||||
hit = true;
|
|
||||||
hitWallId = map[mapY][mapX];
|
|
||||||
textureOffset = currentOffset;
|
|
||||||
} else {
|
|
||||||
hit = true;
|
|
||||||
hitWallId = map[mapY][mapX];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hitOutOfBounds || !hit) {
|
|
||||||
if (pushwallHit == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
customDistCalculated = true;
|
|
||||||
perpWallDist = pushwallHit.distance;
|
|
||||||
side = pushwallHit.side;
|
|
||||||
hitWallId = pushwallHit.hitWallId;
|
|
||||||
wallXOverride = pushwallHit.wallX;
|
|
||||||
textureOffset = 0.0;
|
|
||||||
hit = true;
|
|
||||||
hitOutOfBounds = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!customDistCalculated) {
|
|
||||||
perpWallDist = (side == 0)
|
|
||||||
? (sideDistX - deltaDistX)
|
|
||||||
: (sideDistY - deltaDistY);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pushwallHit != null && pushwallHit.distance < perpWallDist) {
|
|
||||||
customDistCalculated = true;
|
|
||||||
perpWallDist = pushwallHit.distance;
|
|
||||||
side = pushwallHit.side;
|
|
||||||
hitWallId = pushwallHit.hitWallId;
|
|
||||||
wallXOverride = pushwallHit.wallX;
|
|
||||||
textureOffset = 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (perpWallDist < 0.1) perpWallDist = 0.1;
|
|
||||||
|
|
||||||
// Save for sprite depth checks
|
|
||||||
zBuffer[x] = perpWallDist;
|
|
||||||
|
|
||||||
// Calculate Texture X Coordinate
|
|
||||||
double wallX =
|
|
||||||
wallXOverride ??
|
|
||||||
((side == 0)
|
|
||||||
? player.y + perpWallDist * rayDir.y
|
|
||||||
: player.x + perpWallDist * rayDir.x);
|
|
||||||
wallX -= wallX.floor();
|
|
||||||
|
|
||||||
int texNum;
|
|
||||||
if (hitWallId >= 90) {
|
|
||||||
texNum = 98.clamp(0, wallTextures.length - 1);
|
|
||||||
} else {
|
|
||||||
texNum = ((hitWallId - 1) * 2).clamp(0, wallTextures.length - 2);
|
|
||||||
if (side == 1) texNum += 1;
|
|
||||||
}
|
|
||||||
Sprite texture = wallTextures[texNum];
|
|
||||||
|
|
||||||
// Texture flipping for specific orientations
|
|
||||||
int texX = (((wallX - textureOffset) % 1.0) * 64).toInt().clamp(0, 63);
|
|
||||||
if (side == 0 && math.cos(player.angle) > 0) texX = 63 - texX;
|
|
||||||
if (side == 1 && math.sin(player.angle) < 0) texX = 63 - texX;
|
|
||||||
|
|
||||||
// Calculate drawing dimensions
|
|
||||||
int columnHeight = ((sceneHeight / perpWallDist) * verticalStretch)
|
|
||||||
.toInt();
|
|
||||||
int drawStart = (-columnHeight ~/ 2 + sceneHeight ~/ 2).clamp(
|
|
||||||
0,
|
|
||||||
sceneHeight,
|
|
||||||
);
|
|
||||||
int drawEnd = (columnHeight ~/ 2 + sceneHeight ~/ 2).clamp(
|
|
||||||
0,
|
|
||||||
sceneHeight,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Tell the implementation to draw this column
|
|
||||||
drawWallColumn(
|
|
||||||
projectionOffsetX + x,
|
|
||||||
drawStart,
|
|
||||||
drawEnd,
|
|
||||||
columnHeight,
|
|
||||||
texture,
|
|
||||||
texX,
|
|
||||||
perpWallDist,
|
|
||||||
side,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _castSprites(WolfEngine engine) {
|
|
||||||
final Player player = engine.player;
|
|
||||||
final List<Entity> activeSprites = List.from(engine.entities);
|
|
||||||
final int sceneWidth = projectionWidth;
|
|
||||||
final int sceneHeight = projectionViewHeight;
|
|
||||||
|
|
||||||
// Sort from furthest to closest (Painter's Algorithm)
|
|
||||||
activeSprites.sort((a, b) {
|
|
||||||
double distA = player.position.distanceTo(a.position);
|
|
||||||
double distB = player.position.distanceTo(b.position);
|
|
||||||
return distB.compareTo(distA);
|
|
||||||
});
|
|
||||||
|
|
||||||
Coordinate2D dir = Coordinate2D(
|
|
||||||
math.cos(player.angle),
|
|
||||||
math.sin(player.angle),
|
|
||||||
);
|
|
||||||
Coordinate2D plane =
|
|
||||||
Coordinate2D(-dir.y, dir.x) * math.tan((math.pi / 3) / 2);
|
|
||||||
|
|
||||||
for (Entity entity in activeSprites) {
|
|
||||||
Coordinate2D spritePos = entity.position - player.position;
|
|
||||||
|
|
||||||
double invDet = 1.0 / (plane.x * dir.y - dir.x * plane.y);
|
|
||||||
double transformX = invDet * (dir.y * spritePos.x - dir.x * spritePos.y);
|
|
||||||
double transformY =
|
|
||||||
invDet * (-plane.y * spritePos.x + plane.x * spritePos.y);
|
|
||||||
|
|
||||||
// Only process if the sprite is in front of the camera
|
|
||||||
if (transformY > 0) {
|
|
||||||
int spriteScreenX = ((sceneWidth / 2) * (1 + transformX / transformY))
|
|
||||||
.toInt();
|
|
||||||
int spriteHeight = ((sceneHeight / transformY).abs() * verticalStretch)
|
|
||||||
.toInt();
|
|
||||||
int displayedSpriteHeight =
|
|
||||||
((viewHeight / transformY).abs() * verticalStretch).toInt();
|
|
||||||
|
|
||||||
// Scale width based on the aspectMultiplier (useful for ASCII)
|
|
||||||
int spriteWidth =
|
|
||||||
(displayedSpriteHeight * aspectMultiplier / verticalStretch)
|
|
||||||
.toInt();
|
|
||||||
|
|
||||||
int drawStartY = -spriteHeight ~/ 2 + sceneHeight ~/ 2;
|
|
||||||
int drawEndY = spriteHeight ~/ 2 + sceneHeight ~/ 2;
|
|
||||||
int drawStartX = -spriteWidth ~/ 2 + spriteScreenX;
|
|
||||||
int drawEndX = spriteWidth ~/ 2 + spriteScreenX;
|
|
||||||
|
|
||||||
int clipStartX = math.max(0, drawStartX);
|
|
||||||
int clipEndX = math.min(sceneWidth, drawEndX);
|
|
||||||
|
|
||||||
int safeIndex = entity.spriteIndex.clamp(
|
|
||||||
0,
|
|
||||||
engine.data.sprites.length - 1,
|
|
||||||
);
|
|
||||||
Sprite texture = engine.data.sprites[safeIndex];
|
|
||||||
|
|
||||||
// Loop through the visible vertical stripes
|
|
||||||
for (int stripe = clipStartX; stripe < clipEndX; stripe++) {
|
|
||||||
// Check the Z-Buffer to see if a wall is in front of this stripe
|
|
||||||
if (transformY < zBuffer[stripe]) {
|
|
||||||
int texX = ((stripe - drawStartX) * 64 ~/ spriteWidth).clamp(0, 63);
|
|
||||||
|
|
||||||
// Tell the implementation to draw this stripe
|
|
||||||
drawSpriteStripe(
|
|
||||||
projectionOffsetX + stripe,
|
|
||||||
drawStartY,
|
|
||||||
drawEndY,
|
|
||||||
spriteHeight,
|
|
||||||
texture,
|
|
||||||
texX,
|
|
||||||
transformY,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Darkens a 32-bit 0xAABBGGRR color by roughly 30% without touching Alpha
|
|
||||||
int shadeColor(int color) {
|
|
||||||
int r = (color & 0xFF) * 7 ~/ 10;
|
|
||||||
int g = ((color >> 8) & 0xFF) * 7 ~/ 10;
|
|
||||||
int b = ((color >> 16) & 0xFF) * 7 ~/ 10;
|
|
||||||
return (0xFF000000) | (b << 16) | (g << 8) | r;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
import 'dart:math' as math;
|
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:wolf_3d_dart/src/rasterizer/cli_rasterizer.dart';
|
|
||||||
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
|
||||||
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
|
||||||
|
|
||||||
class SixelRasterizer extends CliRasterizer<String> {
|
|
||||||
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 _maxRenderWidth = 320;
|
|
||||||
static const int _maxRenderHeight = 240;
|
|
||||||
static const String _terminalTealBackground = '\x1b[48;2;0;150;136m';
|
|
||||||
|
|
||||||
late Uint8List _screen;
|
|
||||||
late WolfEngine _engine;
|
|
||||||
int _offsetColumns = 0;
|
|
||||||
int _offsetRows = 0;
|
|
||||||
int _outputWidth = 1;
|
|
||||||
int _outputHeight = 1;
|
|
||||||
bool _needsBackgroundClear = true;
|
|
||||||
|
|
||||||
FrameBuffer _createScaledBuffer(FrameBuffer terminalBuffer) {
|
|
||||||
final int previousOffsetColumns = _offsetColumns;
|
|
||||||
final int previousOffsetRows = _offsetRows;
|
|
||||||
final int previousOutputWidth = _outputWidth;
|
|
||||||
final int previousOutputHeight = _outputHeight;
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
final int boundsPixelWidth = math.max(
|
|
||||||
1,
|
|
||||||
(targetColumns * _defaultLineHeightPx * _defaultCellWidthToHeight)
|
|
||||||
.floor(),
|
|
||||||
);
|
|
||||||
final int boundsPixelHeight = math.max(
|
|
||||||
1,
|
|
||||||
targetRows * _defaultLineHeightPx,
|
|
||||||
);
|
|
||||||
|
|
||||||
final double boundsAspect = boundsPixelWidth / boundsPixelHeight;
|
|
||||||
if (boundsAspect > _targetAspectRatio) {
|
|
||||||
_outputHeight = boundsPixelHeight;
|
|
||||||
_outputWidth = math.max(1, (_outputHeight * _targetAspectRatio).floor());
|
|
||||||
} else {
|
|
||||||
_outputWidth = boundsPixelWidth;
|
|
||||||
_outputHeight = math.max(1, (_outputWidth / _targetAspectRatio).floor());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_offsetColumns != previousOffsetColumns ||
|
|
||||||
_offsetRows != previousOffsetRows ||
|
|
||||||
_outputWidth != previousOutputWidth ||
|
|
||||||
_outputHeight != previousOutputHeight) {
|
|
||||||
_needsBackgroundClear = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
final double renderScale = math.min(
|
|
||||||
1.0,
|
|
||||||
math.min(
|
|
||||||
_maxRenderWidth / _outputWidth,
|
|
||||||
_maxRenderHeight / _outputHeight,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final int renderWidth = math.max(1, (_outputWidth * renderScale).floor());
|
|
||||||
final int renderHeight = math.max(1, (_outputHeight * renderScale).floor());
|
|
||||||
|
|
||||||
return FrameBuffer(renderWidth, renderHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String render(WolfEngine engine) {
|
|
||||||
_engine = engine;
|
|
||||||
final FrameBuffer originalBuffer = engine.frameBuffer;
|
|
||||||
final FrameBuffer scaledBuffer = _createScaledBuffer(originalBuffer);
|
|
||||||
// We only need 8-bit indices for the 256 VGA colors
|
|
||||||
_screen = Uint8List(scaledBuffer.width * scaledBuffer.height);
|
|
||||||
engine.frameBuffer = scaledBuffer;
|
|
||||||
try {
|
|
||||||
return super.render(engine);
|
|
||||||
} finally {
|
|
||||||
engine.frameBuffer = originalBuffer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void prepareFrame(WolfEngine engine) {
|
|
||||||
// Top half is ceiling color index (25), bottom half is floor color index (29)
|
|
||||||
for (int y = 0; y < viewHeight; y++) {
|
|
||||||
int colorIndex = (y < viewHeight / 2) ? 25 : 29;
|
|
||||||
for (int x = 0; x < width; x++) {
|
|
||||||
_screen[y * width + x] = colorIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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++) {
|
|
||||||
double relativeY =
|
|
||||||
(y - (-columnHeight ~/ 2 + viewHeight ~/ 2)) / columnHeight;
|
|
||||||
int texY = (relativeY * 64).toInt().clamp(0, 63);
|
|
||||||
|
|
||||||
int colorByte = texture.pixels[texX * 64 + texY];
|
|
||||||
|
|
||||||
// Note: Directional shading is omitted here to preserve strict VGA palette indices.
|
|
||||||
// Sixel uses a fixed 256-color palette, so real-time shading requires a lookup table.
|
|
||||||
_screen[y * width + x] = colorByte;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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
|
|
||||||
if (colorByte != 255) {
|
|
||||||
_screen[y * width + stripeX] = 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) {
|
|
||||||
_screen[drawY * width + drawX] = colorByte;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void drawHud(WolfEngine engine) {
|
|
||||||
int statusBarIndex = engine.data.vgaImages.indexWhere(
|
|
||||||
(img) => img.width == 320 && img.height == 40,
|
|
||||||
);
|
|
||||||
if (statusBarIndex == -1) return;
|
|
||||||
|
|
||||||
_blitVgaImage(engine.data.vgaImages[statusBarIndex], 0, 160);
|
|
||||||
|
|
||||||
_drawNumber(1, 32, 176, engine.data.vgaImages);
|
|
||||||
_drawNumber(engine.player.score, 96, 176, engine.data.vgaImages);
|
|
||||||
_drawNumber(3, 120, 176, engine.data.vgaImages);
|
|
||||||
_drawNumber(engine.player.health, 192, 176, engine.data.vgaImages);
|
|
||||||
_drawNumber(engine.player.ammo, 232, 176, engine.data.vgaImages);
|
|
||||||
|
|
||||||
_drawFace(engine);
|
|
||||||
_drawWeaponIcon(engine);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String finalizeFrame() {
|
|
||||||
final String clearPrefix = _needsBackgroundClear
|
|
||||||
? '$_terminalTealBackground\x1b[2J\x1b[0m'
|
|
||||||
: '';
|
|
||||||
_needsBackgroundClear = false;
|
|
||||||
return '$clearPrefix\x1b[${_offsetRows + 1};${_offsetColumns + 1}H${toSixelString()}';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// SIXEL ENCODER
|
|
||||||
// ===========================================================================
|
|
||||||
|
|
||||||
/// Converts the 8-bit index buffer into a standard Sixel sequence
|
|
||||||
String toSixelString() {
|
|
||||||
StringBuffer sb = StringBuffer();
|
|
||||||
|
|
||||||
// Start Sixel sequence (q = Sixel format)
|
|
||||||
sb.write('\x1bPq');
|
|
||||||
|
|
||||||
// 1. Define the Palette (and apply damage flash directly to the palette!)
|
|
||||||
double damageIntensity = _engine.player.damageFlash;
|
|
||||||
int redBoost = (150 * damageIntensity).toInt();
|
|
||||||
double colorDrop = 1.0 - (0.5 * damageIntensity);
|
|
||||||
|
|
||||||
for (int i = 0; i < 256; i++) {
|
|
||||||
int color = ColorPalette.vga32Bit[i];
|
|
||||||
int r = color & 0xFF;
|
|
||||||
int g = (color >> 8) & 0xFF;
|
|
||||||
int b = (color >> 16) & 0xFF;
|
|
||||||
|
|
||||||
if (damageIntensity > 0) {
|
|
||||||
r = (r + redBoost).clamp(0, 255);
|
|
||||||
g = (g * colorDrop).toInt().clamp(0, 255);
|
|
||||||
b = (b * colorDrop).toInt().clamp(0, 255);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sixel RGB ranges from 0 to 100
|
|
||||||
int sixelR = (r * 100) ~/ 255;
|
|
||||||
int sixelG = (g * 100) ~/ 255;
|
|
||||||
int sixelB = (b * 100) ~/ 255;
|
|
||||||
|
|
||||||
sb.write('#$i;2;$sixelR;$sixelG;$sixelB');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Encode scaled image in 6-pixel vertical bands.
|
|
||||||
for (int band = 0; band < _outputHeight; band += 6) {
|
|
||||||
Map<int, Uint8List> colorMap = {};
|
|
||||||
|
|
||||||
// Map out which pixels use which color in this 6px high band
|
|
||||||
for (int x = 0; x < _outputWidth; x++) {
|
|
||||||
for (int yOffset = 0; yOffset < 6; yOffset++) {
|
|
||||||
int y = band + yOffset;
|
|
||||||
if (y >= _outputHeight) break;
|
|
||||||
|
|
||||||
int colorIdx = _sampleScaledPixel(x, y);
|
|
||||||
if (!colorMap.containsKey(colorIdx)) {
|
|
||||||
colorMap[colorIdx] = Uint8List(_outputWidth);
|
|
||||||
}
|
|
||||||
// Set the bit corresponding to the vertical position (0-5)
|
|
||||||
colorMap[colorIdx]![x] |= (1 << yOffset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write the encoded Sixel characters for each color present in the band
|
|
||||||
bool firstColor = true;
|
|
||||||
for (var entry in colorMap.entries) {
|
|
||||||
if (!firstColor) {
|
|
||||||
// Carriage return to overlay colors on the same band
|
|
||||||
sb.write('\$');
|
|
||||||
}
|
|
||||||
firstColor = false;
|
|
||||||
|
|
||||||
// Select color index
|
|
||||||
sb.write('#${entry.key}');
|
|
||||||
|
|
||||||
Uint8List cols = entry.value;
|
|
||||||
int currentVal = -1;
|
|
||||||
int runLength = 0;
|
|
||||||
|
|
||||||
// Run-Length Encoding (RLE) loop
|
|
||||||
for (int x = 0; x < _outputWidth; x++) {
|
|
||||||
int val = cols[x];
|
|
||||||
if (val == currentVal) {
|
|
||||||
runLength++;
|
|
||||||
} else {
|
|
||||||
if (runLength > 0) _writeSixelRle(sb, currentVal, runLength);
|
|
||||||
currentVal = val;
|
|
||||||
runLength = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (runLength > 0) _writeSixelRle(sb, currentVal, runLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (band + 6 < _outputHeight) {
|
|
||||||
sb.write('-');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// End Sixel sequence
|
|
||||||
sb.write('\x1b\\');
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
int _sampleScaledPixel(int outX, int outY) {
|
|
||||||
final int srcX = ((((outX + 0.5) * width) / _outputWidth) - 0.5)
|
|
||||||
.round()
|
|
||||||
.clamp(
|
|
||||||
0,
|
|
||||||
width - 1,
|
|
||||||
);
|
|
||||||
final int srcY = ((((outY + 0.5) * height) / _outputHeight) - 0.5)
|
|
||||||
.round()
|
|
||||||
.clamp(
|
|
||||||
0,
|
|
||||||
height - 1,
|
|
||||||
);
|
|
||||||
return _screen[srcY * width + srcX];
|
|
||||||
}
|
|
||||||
|
|
||||||
void _writeSixelRle(StringBuffer sb, int value, int runLength) {
|
|
||||||
String char = String.fromCharCode(value + 63);
|
|
||||||
// Sixel RLE format: !<count><char> (only worth it if count > 3)
|
|
||||||
if (runLength > 3) {
|
|
||||||
sb.write('!$runLength$char');
|
|
||||||
} else {
|
|
||||||
sb.write(char * runLength);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// PRIVATE HUD HELPERS (Adapted for 8-bit index buffer)
|
|
||||||
// ===========================================================================
|
|
||||||
|
|
||||||
void _blitVgaImage(VgaImage image, int startX, int startY) {
|
|
||||||
int planeWidth = image.width ~/ 4;
|
|
||||||
int planeSize = planeWidth * image.height;
|
|
||||||
final double scaleX = width / 320.0;
|
|
||||||
final double scaleY = height / 200.0;
|
|
||||||
|
|
||||||
final int destStartX = (startX * scaleX).toInt();
|
|
||||||
final int destStartY = (startY * scaleY).toInt();
|
|
||||||
final int destWidth = math.max(1, (image.width * scaleX).toInt());
|
|
||||||
final int destHeight = math.max(1, (image.height * scaleY).toInt());
|
|
||||||
|
|
||||||
for (int dy = 0; dy < destHeight; dy++) {
|
|
||||||
for (int dx = 0; dx < destWidth; dx++) {
|
|
||||||
int drawX = destStartX + dx;
|
|
||||||
int drawY = destStartY + dy;
|
|
||||||
|
|
||||||
if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) {
|
|
||||||
int srcX = (dx / scaleX).toInt().clamp(0, image.width - 1);
|
|
||||||
int srcY = (dy / scaleY).toInt().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) {
|
|
||||||
_screen[drawY * width + drawX] = 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 = (health <= 0)
|
|
||||||
? 127
|
|
||||||
: 106 + (((100 - health) ~/ 16).clamp(0, 6) * 3);
|
|
||||||
if (faceIndex < engine.data.vgaImages.length) {
|
|
||||||
_blitVgaImage(engine.data.vgaImages[faceIndex], 136, 164);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _drawWeaponIcon(WolfEngine engine) {
|
|
||||||
int weaponIndex = 89;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,265 +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';
|
|
||||||
|
|
||||||
class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
|
|
||||||
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
|
|
||||||
FrameBuffer finalizeFrame() {
|
|
||||||
// If the player took damage, overlay a red tint across the 3D view
|
|
||||||
if (_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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
167
packages/wolf_3d_dart/lib/wolf_3d_menu.dart
Normal file
167
packages/wolf_3d_dart/lib/wolf_3d_menu.dart
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/// Shared menu helpers for Wolf3D hosts.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||||
|
|
||||||
|
/// Known VGA picture indexes used by the original Wolf3D control-panel menus.
|
||||||
|
///
|
||||||
|
/// Values below are picture-table indexes (not raw chunk ids).
|
||||||
|
/// For example, `C_CONTROLPIC` is chunk 26 in `GFXV_WL6.H`, so its picture
|
||||||
|
/// index is `26 - STARTPICS(3) = 23`.
|
||||||
|
abstract class WolfMenuPic {
|
||||||
|
static const int hBj = 0; // H_BJPIC
|
||||||
|
static const int hTopWindow = 3; // H_TOPWINDOWPIC
|
||||||
|
static const int cOptions = 7; // C_OPTIONSPIC
|
||||||
|
static const int cCursor1 = 8; // C_CURSOR1PIC
|
||||||
|
static const int cCursor2 = 9; // C_CURSOR2PIC
|
||||||
|
static const int cNotSelected = 10; // C_NOTSELECTEDPIC
|
||||||
|
static const int cSelected = 11; // C_SELECTEDPIC
|
||||||
|
static const int cBabyMode = 16; // C_BABYMODEPIC
|
||||||
|
static const int cEasy = 17; // C_EASYPIC
|
||||||
|
static const int cNormal = 18; // C_NORMALPIC
|
||||||
|
static const int cHard = 19; // C_HARDPIC
|
||||||
|
static const int cControl = 23; // C_CONTROLPIC
|
||||||
|
static const int cEpisode1 = 27; // C_EPISODE1PIC
|
||||||
|
static const int cEpisode2 = 28; // C_EPISODE2PIC
|
||||||
|
static const int cEpisode3 = 29; // C_EPISODE3PIC
|
||||||
|
static const int cEpisode4 = 30; // C_EPISODE4PIC
|
||||||
|
static const int cEpisode5 = 31; // C_EPISODE5PIC
|
||||||
|
static const int cEpisode6 = 32; // C_EPISODE6PIC
|
||||||
|
static const int statusBar = 83; // STATUSBARPIC
|
||||||
|
static const int title = 84; // TITLEPIC
|
||||||
|
static const int pg13 = 85; // PG13PIC
|
||||||
|
static const int credits = 86; // CREDITSPIC
|
||||||
|
static const int highScores = 87; // HIGHSCORESPIC
|
||||||
|
|
||||||
|
static const List<int> episodePics = [
|
||||||
|
cEpisode1,
|
||||||
|
cEpisode2,
|
||||||
|
cEpisode3,
|
||||||
|
cEpisode4,
|
||||||
|
cEpisode5,
|
||||||
|
cEpisode6,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Structured accessors for classic Wolf3D menu art.
|
||||||
|
class WolfClassicMenuArt {
|
||||||
|
final WolfensteinData data;
|
||||||
|
|
||||||
|
WolfClassicMenuArt(this.data);
|
||||||
|
|
||||||
|
int? _resolvedIndexOffset;
|
||||||
|
|
||||||
|
VgaImage? get controlBackground {
|
||||||
|
final preferred = mappedPic(WolfMenuPic.cControl);
|
||||||
|
if (_looksLikeMenuBackdrop(preferred)) {
|
||||||
|
return preferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Older data layouts may shift/control-panel art around nearby indices.
|
||||||
|
for (int delta = -4; delta <= 4; delta++) {
|
||||||
|
final candidate = mappedPic(WolfMenuPic.cControl + delta);
|
||||||
|
if (_looksLikeMenuBackdrop(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return preferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
VgaImage? get title => mappedPic(WolfMenuPic.title);
|
||||||
|
|
||||||
|
VgaImage? get heading => mappedPic(WolfMenuPic.hTopWindow);
|
||||||
|
|
||||||
|
VgaImage? get selectedMarker => mappedPic(WolfMenuPic.cSelected);
|
||||||
|
|
||||||
|
VgaImage? get unselectedMarker => mappedPic(WolfMenuPic.cNotSelected);
|
||||||
|
|
||||||
|
VgaImage? get optionsLabel => mappedPic(WolfMenuPic.cOptions);
|
||||||
|
|
||||||
|
VgaImage? get credits => mappedPic(WolfMenuPic.credits);
|
||||||
|
|
||||||
|
VgaImage? episodeOption(int episodeIndex) {
|
||||||
|
if (episodeIndex < 0 || episodeIndex >= WolfMenuPic.episodePics.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return mappedPic(WolfMenuPic.episodePics[episodeIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
VgaImage? difficultyOption(Difficulty difficulty) {
|
||||||
|
switch (difficulty) {
|
||||||
|
case Difficulty.baby:
|
||||||
|
return mappedPic(WolfMenuPic.cBabyMode);
|
||||||
|
case Difficulty.easy:
|
||||||
|
return mappedPic(WolfMenuPic.cEasy);
|
||||||
|
case Difficulty.medium:
|
||||||
|
return mappedPic(WolfMenuPic.cNormal);
|
||||||
|
case Difficulty.hard:
|
||||||
|
return mappedPic(WolfMenuPic.cHard);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns [index] after applying a detected version/layout offset.
|
||||||
|
VgaImage? mappedPic(int index) {
|
||||||
|
return pic(index + _indexOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
int get _indexOffset {
|
||||||
|
if (_resolvedIndexOffset != null) {
|
||||||
|
return _resolvedIndexOffset!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retail and shareware generally place STATUSBAR/TITLE/PG13/CREDITS as a
|
||||||
|
// contiguous block. If files are from a different release, infer a shift.
|
||||||
|
for (int i = 0; i < data.vgaImages.length - 3; i++) {
|
||||||
|
final status = data.vgaImages[i];
|
||||||
|
if (!_looksLikeStatusBar(status)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final title = data.vgaImages[i + 1];
|
||||||
|
final pg13 = data.vgaImages[i + 2];
|
||||||
|
final credits = data.vgaImages[i + 3];
|
||||||
|
if (_looksLikeFullScreen(title) &&
|
||||||
|
_looksLikeFullScreen(pg13) &&
|
||||||
|
_looksLikeFullScreen(credits)) {
|
||||||
|
_resolvedIndexOffset = i - WolfMenuPic.statusBar;
|
||||||
|
return _resolvedIndexOffset!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_resolvedIndexOffset = 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _looksLikeStatusBar(VgaImage image) {
|
||||||
|
return image.width >= 280 && image.height >= 24 && image.height <= 64;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _looksLikeFullScreen(VgaImage image) {
|
||||||
|
return image.width >= 280 && image.height >= 140;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _looksLikeMenuBackdrop(VgaImage? image) {
|
||||||
|
if (image == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return image.width >= 180 && image.height >= 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
VgaImage? pic(int index) {
|
||||||
|
if (index < 0 || index >= data.vgaImages.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final image = data.vgaImages[index];
|
||||||
|
|
||||||
|
// Ignore known gameplay HUD art in menu composition.
|
||||||
|
if (index == WolfMenuPic.statusBar + _indexOffset) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (image.width <= 0 || image.height <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,12 @@ class Wolf3d {
|
|||||||
/// Shared engine audio backend used by menus and gameplay sessions.
|
/// Shared engine audio backend used by menus and gameplay sessions.
|
||||||
final EngineAudio audio = WolfAudio();
|
final EngineAudio audio = WolfAudio();
|
||||||
|
|
||||||
|
/// Engine menu background color as 24-bit RGB.
|
||||||
|
int menuBackgroundRgb = 0x890000;
|
||||||
|
|
||||||
|
/// Engine menu panel color as 24-bit RGB.
|
||||||
|
int menuPanelRgb = 0x590002;
|
||||||
|
|
||||||
/// Shared Flutter input adapter reused by gameplay screens.
|
/// Shared Flutter input adapter reused by gameplay screens.
|
||||||
final Wolf3dFlutterInput input = Wolf3dFlutterInput();
|
final Wolf3dFlutterInput input = Wolf3dFlutterInput();
|
||||||
|
|
||||||
@@ -41,6 +47,54 @@ class Wolf3d {
|
|||||||
/// Index of the episode currently selected in the UI flow.
|
/// Index of the episode currently selected in the UI flow.
|
||||||
int get activeEpisode => _activeEpisode;
|
int get activeEpisode => _activeEpisode;
|
||||||
|
|
||||||
|
Difficulty? _activeDifficulty;
|
||||||
|
|
||||||
|
/// The difficulty applied when [launchEngine] creates a new session.
|
||||||
|
Difficulty? get activeDifficulty => _activeDifficulty;
|
||||||
|
|
||||||
|
/// Stores [difficulty] so the next [launchEngine] call uses it.
|
||||||
|
void setActiveDifficulty(Difficulty difficulty) {
|
||||||
|
_activeDifficulty = difficulty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears any previously selected difficulty so the engine can prompt for one.
|
||||||
|
void clearActiveDifficulty() {
|
||||||
|
_activeDifficulty = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
WolfEngine? _engine;
|
||||||
|
|
||||||
|
/// The most recently launched engine.
|
||||||
|
///
|
||||||
|
/// Throws a [StateError] until [launchEngine] has been called.
|
||||||
|
WolfEngine get engine {
|
||||||
|
if (_engine == null) {
|
||||||
|
throw StateError('No engine launched. Call launchEngine() first.');
|
||||||
|
}
|
||||||
|
return _engine!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates and initializes a [WolfEngine] for the current session config.
|
||||||
|
///
|
||||||
|
/// Uses [activeGame], [activeEpisode], and [activeDifficulty]. Stores the
|
||||||
|
/// engine so it can be retrieved via [engine]. [onGameWon] is invoked when
|
||||||
|
/// the player completes the final level of the episode.
|
||||||
|
WolfEngine launchEngine({required void Function() onGameWon}) {
|
||||||
|
_engine = WolfEngine(
|
||||||
|
data: activeGame,
|
||||||
|
difficulty: _activeDifficulty,
|
||||||
|
startingEpisode: _activeEpisode,
|
||||||
|
frameBuffer: FrameBuffer(320, 200),
|
||||||
|
menuBackgroundRgb: menuBackgroundRgb,
|
||||||
|
menuPanelRgb: menuPanelRgb,
|
||||||
|
audio: audio,
|
||||||
|
input: input,
|
||||||
|
onGameWon: onGameWon,
|
||||||
|
);
|
||||||
|
_engine!.init();
|
||||||
|
return _engine!;
|
||||||
|
}
|
||||||
|
|
||||||
/// Sets the active episode for the current [activeGame].
|
/// Sets the active episode for the current [activeGame].
|
||||||
void setActiveEpisode(int episodeIndex) {
|
void setActiveEpisode(int episodeIndex) {
|
||||||
if (_activeGame == null) {
|
if (_activeGame == null) {
|
||||||
|
|||||||
@@ -37,7 +37,12 @@ class Wolf3dFlutterInput extends Wolf3dInput {
|
|||||||
LogicalKeyboardKey.controlLeft,
|
LogicalKeyboardKey.controlLeft,
|
||||||
LogicalKeyboardKey.controlRight,
|
LogicalKeyboardKey.controlRight,
|
||||||
},
|
},
|
||||||
WolfInputAction.interact: {LogicalKeyboardKey.space},
|
WolfInputAction.interact: {
|
||||||
|
LogicalKeyboardKey.space,
|
||||||
|
LogicalKeyboardKey.enter,
|
||||||
|
LogicalKeyboardKey.numpadEnter,
|
||||||
|
},
|
||||||
|
WolfInputAction.back: {LogicalKeyboardKey.escape},
|
||||||
WolfInputAction.weapon1: {LogicalKeyboardKey.digit1},
|
WolfInputAction.weapon1: {LogicalKeyboardKey.digit1},
|
||||||
WolfInputAction.weapon2: {LogicalKeyboardKey.digit2},
|
WolfInputAction.weapon2: {LogicalKeyboardKey.digit2},
|
||||||
WolfInputAction.weapon3: {LogicalKeyboardKey.digit3},
|
WolfInputAction.weapon3: {LogicalKeyboardKey.digit3},
|
||||||
@@ -52,6 +57,9 @@ class Wolf3dFlutterInput extends Wolf3dInput {
|
|||||||
double _mouseDeltaX = 0.0;
|
double _mouseDeltaX = 0.0;
|
||||||
double _mouseDeltaY = 0.0;
|
double _mouseDeltaY = 0.0;
|
||||||
bool _previousMouseRightDown = false;
|
bool _previousMouseRightDown = false;
|
||||||
|
bool _queuedBack = false;
|
||||||
|
double? _queuedMenuTapX;
|
||||||
|
double? _queuedMenuTapY;
|
||||||
|
|
||||||
// Mouse-look is optional so touch or keyboard-only hosts can keep the same
|
// Mouse-look is optional so touch or keyboard-only hosts can keep the same
|
||||||
// adapter without incurring accidental pointer-driven movement.
|
// adapter without incurring accidental pointer-driven movement.
|
||||||
@@ -105,6 +113,17 @@ class Wolf3dFlutterInput extends Wolf3dInput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Queues a one-frame back action, typically from system back gestures.
|
||||||
|
void queueBackAction() {
|
||||||
|
_queuedBack = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queues a one-frame menu tap with normalized coordinates [0..1].
|
||||||
|
void queueMenuTap({required double x, required double y}) {
|
||||||
|
_queuedMenuTapX = x.clamp(0.0, 1.0);
|
||||||
|
_queuedMenuTapY = y.clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns whether any bound key for [action] is currently pressed.
|
/// Returns whether any bound key for [action] is currently pressed.
|
||||||
bool _isActive(WolfInputAction action, Set<LogicalKeyboardKey> pressedKeys) {
|
bool _isActive(WolfInputAction action, Set<LogicalKeyboardKey> pressedKeys) {
|
||||||
return bindings[action]!.any((key) => pressedKeys.contains(key));
|
return bindings[action]!.any((key) => pressedKeys.contains(key));
|
||||||
@@ -146,6 +165,11 @@ class Wolf3dFlutterInput extends Wolf3dInput {
|
|||||||
_isNewlyPressed(WolfInputAction.interact, newlyPressedKeys) ||
|
_isNewlyPressed(WolfInputAction.interact, newlyPressedKeys) ||
|
||||||
(mouseLookEnabled && isMouseRightDown && !_previousMouseRightDown);
|
(mouseLookEnabled && isMouseRightDown && !_previousMouseRightDown);
|
||||||
|
|
||||||
|
isBack =
|
||||||
|
_isNewlyPressed(WolfInputAction.back, newlyPressedKeys) || _queuedBack;
|
||||||
|
menuTapX = _queuedMenuTapX;
|
||||||
|
menuTapY = _queuedMenuTapY;
|
||||||
|
|
||||||
// Left click or Ctrl to fire
|
// Left click or Ctrl to fire
|
||||||
isFiring =
|
isFiring =
|
||||||
_isActive(WolfInputAction.fire, pressedKeys) ||
|
_isActive(WolfInputAction.fire, pressedKeys) ||
|
||||||
@@ -169,5 +193,8 @@ class Wolf3dFlutterInput extends Wolf3dInput {
|
|||||||
// weapon switching only fire once per physical key press.
|
// weapon switching only fire once per physical key press.
|
||||||
_previousKeys = Set.from(pressedKeys);
|
_previousKeys = Set.from(pressedKeys);
|
||||||
_previousMouseRightDown = isMouseRightDown;
|
_previousMouseRightDown = isMouseRightDown;
|
||||||
|
_queuedBack = false;
|
||||||
|
_queuedMenuTapX = null;
|
||||||
|
_queuedMenuTapY = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ class _WolfAsciiRendererState extends BaseWolfRendererState<WolfAsciiRenderer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Color get scaffoldColor => const Color.fromARGB(255, 4, 64, 64);
|
Color get scaffoldColor => widget.engine.isDifficultySelectionPending
|
||||||
|
? _colorFromRgb(widget.engine.menuBackgroundRgb)
|
||||||
|
: const Color.fromARGB(255, 4, 64, 64);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void performRender() {
|
void performRender() {
|
||||||
@@ -51,6 +53,10 @@ class _WolfAsciiRendererState extends BaseWolfRendererState<WolfAsciiRenderer> {
|
|||||||
? const SizedBox.shrink()
|
? const SizedBox.shrink()
|
||||||
: AsciiFrameWidget(frameData: _asciiFrame);
|
: AsciiFrameWidget(frameData: _asciiFrame);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Color _colorFromRgb(int rgb) {
|
||||||
|
return Color(0xFF000000 | (rgb & 0x00FFFFFF));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Paints a pre-rasterized ASCII frame using grouped text spans per color run.
|
/// Paints a pre-rasterized ASCII frame using grouped text spans per color run.
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ class _WolfFlutterRendererState
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Color get scaffoldColor => const Color.fromARGB(255, 4, 64, 64);
|
Color get scaffoldColor => widget.engine.isDifficultySelectionPending
|
||||||
|
? _colorFromRgb(widget.engine.menuBackgroundRgb)
|
||||||
|
: const Color.fromARGB(255, 4, 64, 64);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void performRender() {
|
void performRender() {
|
||||||
@@ -85,4 +87,8 @@ class _WolfFlutterRendererState
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Color _colorFromRgb(int rgb) {
|
||||||
|
return Color(0xFF000000 | (rgb & 0x00FFFFFF));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user