From 8ecc8e2fd4d671bd49635911984e20633a984045 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Fri, 13 Mar 2026 16:04:14 +0100 Subject: [PATCH] Added WL1 map parsing Signed-off-by: Hans Kokx --- assets/GAMEMAPS.WL1 | Bin 0 -> 27425 bytes assets/MAPHEAD.WL1 | Bin 0 -> 402 bytes lib/classes/linear_coordinates.dart | 1 + lib/classes/matrix.dart | 1 + lib/features/map/wolf_level.dart | 15 + lib/features/map/wolf_map.dart | 24 ++ lib/features/map/wolf_map_parser.dart | 164 ++++++++++ lib/features/renderer/raycast_painter.dart | 232 ++++++++++++++ lib/features/renderer/renderer.dart | 171 +++++++++++ lib/main.dart | 337 +-------------------- pubspec.yaml | 4 +- 11 files changed, 612 insertions(+), 337 deletions(-) create mode 100644 assets/GAMEMAPS.WL1 create mode 100644 assets/MAPHEAD.WL1 create mode 100644 lib/classes/linear_coordinates.dart create mode 100644 lib/classes/matrix.dart create mode 100644 lib/features/map/wolf_level.dart create mode 100644 lib/features/map/wolf_map.dart create mode 100644 lib/features/map/wolf_map_parser.dart create mode 100644 lib/features/renderer/raycast_painter.dart create mode 100644 lib/features/renderer/renderer.dart diff --git a/assets/GAMEMAPS.WL1 b/assets/GAMEMAPS.WL1 new file mode 100644 index 0000000000000000000000000000000000000000..8ca197c388f61ff3e89865b4432bc53d82b6de62 GIT binary patch literal 27425 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 B2}=L~ literal 0 HcmV?d00001 diff --git a/lib/classes/linear_coordinates.dart b/lib/classes/linear_coordinates.dart new file mode 100644 index 0000000..65eafc5 --- /dev/null +++ b/lib/classes/linear_coordinates.dart @@ -0,0 +1 @@ +typedef LinearCoordinates = ({double x, double y}); diff --git a/lib/classes/matrix.dart b/lib/classes/matrix.dart new file mode 100644 index 0000000..d892678 --- /dev/null +++ b/lib/classes/matrix.dart @@ -0,0 +1 @@ +typedef Matrix = List>; diff --git a/lib/features/map/wolf_level.dart b/lib/features/map/wolf_level.dart new file mode 100644 index 0000000..254a6d8 --- /dev/null +++ b/lib/features/map/wolf_level.dart @@ -0,0 +1,15 @@ +import 'package:wolf_dart/classes/matrix.dart'; + +class WolfLevel { + final String name; + final int width; // Always 64 in standard Wolf3D + final int height; // Always 64 + final Matrix wallGrid; + + WolfLevel({ + required this.name, + required this.width, + required this.height, + required this.wallGrid, + }); +} diff --git a/lib/features/map/wolf_map.dart b/lib/features/map/wolf_map.dart new file mode 100644 index 0000000..c78f133 --- /dev/null +++ b/lib/features/map/wolf_map.dart @@ -0,0 +1,24 @@ +import 'package:flutter/services.dart'; +import 'package:wolf_dart/features/map/wolf_level.dart'; +import 'package:wolf_dart/features/map/wolf_map_parser.dart'; + +class WolfMap { + /// The fully parsed and decompressed levels from the game files. + final List levels; + + // A private constructor so we can only instantiate this from the async loader + WolfMap._(this.levels); + + /// Asynchronously loads the map files and parses them into a new WolfMap instance. + static Future load() async { + // 1. Load the binary data + final mapHead = await rootBundle.load("assets/MAPHEAD.WL1"); + final gameMaps = await rootBundle.load("assets/GAMEMAPS.WL1"); + + // 2. Parse the data using the parser we just built + final parsedLevels = WolfMapParser.parseMaps(mapHead, gameMaps); + + // 3. Return the populated instance! + return WolfMap._(parsedLevels); + } +} diff --git a/lib/features/map/wolf_map_parser.dart b/lib/features/map/wolf_map_parser.dart new file mode 100644 index 0000000..5973874 --- /dev/null +++ b/lib/features/map/wolf_map_parser.dart @@ -0,0 +1,164 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:wolf_dart/classes/matrix.dart'; +import 'package:wolf_dart/features/map/wolf_level.dart'; + +abstract class WolfMapParser { + /// Parses MAPHEAD and GAMEMAPS to extract the raw level data. + static List parseMaps(ByteData mapHead, ByteData gameMaps) { + List levels = []; + + // 1. READ MAPHEAD + // The very first 16-bit word in MAPHEAD is the RLEW tag (usually 0xABCD) + // We will need this later for decompression! + int rlewTag = mapHead.getUint16(0, Endian.little); + + // MAPHEAD contains up to 100 levels. + // Starting at byte 2, there are 100 32-bit integers representing + // the byte offset of each level's header inside GAMEMAPS. + for (int i = 0; i < 100; i++) { + int mapOffset = mapHead.getUint32(2 + (i * 4), Endian.little); + + // An offset of 0 means the level doesn't exist (end of the list) + if (mapOffset == 0) continue; + + // 2. READ GAMEMAPS HEADER + // Jump to the offset in GAMEMAPS to read the 38-byte Level Header + + // Pointers to the compressed data for the 3 planes (Walls, Objects, Extra) + int plane0Offset = gameMaps.getUint32(mapOffset + 0, Endian.little); + int plane1Offset = gameMaps.getUint32(mapOffset + 4, Endian.little); + // Plane 2 (offset + 8) is usually unused in standard Wolf3D + + // Lengths of the compressed data for each plane + int plane0Length = gameMaps.getUint16(mapOffset + 12, Endian.little); + int plane1Length = gameMaps.getUint16(mapOffset + 14, Endian.little); + + // Dimensions (Always 64x64, but we read it anyway for accuracy) + int width = gameMaps.getUint16(mapOffset + 18, Endian.little); + int height = gameMaps.getUint16(mapOffset + 20, Endian.little); + + // Map Name (16 bytes of ASCII text) + List nameBytes = []; + for (int n = 0; n < 16; n++) { + int charCode = gameMaps.getUint8(mapOffset + 22 + n); + if (charCode == 0) break; // Null terminator + nameBytes.add(charCode); + } + String name = ascii.decode(nameBytes); + + // 3. EXTRACT AND DECOMPRESS THE WALL DATA + final compressedWallData = gameMaps.buffer.asUint8List( + plane0Offset, + plane0Length, + ); + + // 1st Pass: Un-Carmack + Uint16List carmackExpanded = _expandCarmack(compressedWallData); + // 2nd Pass: Un-RLEW + List flatGrid = _expandRlew(carmackExpanded, rlewTag); + + // Convert the flat List (4096 items) into a Matrix (64x64 grid) + Matrix wallGrid = []; + for (int y = 0; y < height; y++) { + List row = []; + for (int x = 0; x < width; x++) { + // Note: In original Wolf3D, empty space is usually ID 90 or 106, + // but we can map them down to 0 for your raycaster logic later. + row.add(flatGrid[y * width + x]); + } + wallGrid.add(row); + } + + levels.add( + WolfLevel( + name: name, + width: width, + height: height, + wallGrid: wallGrid, // Pass the fully decompressed matrix! + ), + ); + } + + return levels; + } + + // --- ALGORITHM 1: CARMACK EXPANSION --- + static Uint16List _expandCarmack(Uint8List compressed) { + ByteData data = ByteData.sublistView(compressed); + + // The first 16-bit word is the total length of the expanded data in BYTES. + int expandedLengthBytes = data.getUint16(0, Endian.little); + int expandedLengthWords = expandedLengthBytes ~/ 2; + Uint16List expanded = Uint16List(expandedLengthWords); + + int inIdx = 2; // Skip the length word we just read + int outIdx = 0; + + while (outIdx < expandedLengthWords && inIdx < compressed.length) { + int word = data.getUint16(inIdx, Endian.little); + inIdx += 2; + + int highByte = word >> 8; + int lowByte = word & 0xFF; + + // 0xA7 and 0xA8 are the Carmack Pointer Tags + if (highByte == 0xA7 || highByte == 0xA8) { + if (lowByte == 0) { + // Exception Rule: If the length (lowByte) is 0, it's not a pointer. + // It's literally just the tag byte followed by another byte. + int nextByte = data.getUint8(inIdx++); + expanded[outIdx++] = (nextByte << 8) | highByte; + } else if (highByte == 0xA7) { + // 0xA7 = Near Pointer (look back a few spaces) + int offset = data.getUint8(inIdx++); + int copyFrom = outIdx - offset; + for (int i = 0; i < lowByte; i++) { + expanded[outIdx++] = expanded[copyFrom++]; + } + } else if (highByte == 0xA8) { + // 0xA8 = Far Pointer (absolute offset from the very beginning) + int offset = data.getUint16(inIdx, Endian.little); + inIdx += 2; + for (int i = 0; i < lowByte; i++) { + expanded[outIdx++] = expanded[offset++]; + } + } + } else { + // Normal, uncompressed word + expanded[outIdx++] = word; + } + } + return expanded; + } + + // --- ALGORITHM 2: RLEW EXPANSION --- + static List _expandRlew(Uint16List carmackExpanded, int rlewTag) { + // The first word is the expanded length in BYTES + int expandedLengthBytes = carmackExpanded[0]; + int expandedLengthWords = expandedLengthBytes ~/ 2; + List rlewExpanded = List.filled(expandedLengthWords, 0); + + int inIdx = 1; // Skip the length word + int outIdx = 0; + + while (outIdx < expandedLengthWords && inIdx < carmackExpanded.length) { + int word = carmackExpanded[inIdx++]; + + if (word == rlewTag) { + // We found an RLEW tag! + // The next word is the count, the word after that is the value. + int count = carmackExpanded[inIdx++]; + int value = carmackExpanded[inIdx++]; + for (int i = 0; i < count; i++) { + rlewExpanded[outIdx++] = value; + } + } else { + // Normal word + rlewExpanded[outIdx++] = word; + } + } + return rlewExpanded; + } +} diff --git a/lib/features/renderer/raycast_painter.dart b/lib/features/renderer/raycast_painter.dart new file mode 100644 index 0000000..eb97da3 --- /dev/null +++ b/lib/features/renderer/raycast_painter.dart @@ -0,0 +1,232 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:wolf_dart/classes/linear_coordinates.dart'; +import 'package:wolf_dart/classes/matrix.dart'; + +class RaycasterPainter extends CustomPainter { + final Matrix map; + final LinearCoordinates player; + final double playerAngle; + final double fov; + + RaycasterPainter({ + required this.map, + required this.player, + required this.playerAngle, + required this.fov, + }); + + @override + void paint(Canvas canvas, Size size) { + // 1. Draw Ceiling & Floor + canvas.drawRect( + Rect.fromLTWH(0, 0, size.width, size.height / 2), + Paint()..color = Colors.blueGrey[900]!, + ); + canvas.drawRect( + Rect.fromLTWH(0, size.height / 2, size.width, size.height / 2), + Paint()..color = Colors.brown[900]!, + ); + + int screenWidth = size.width.toInt(); + + // 2. Camera Plane Setup + // Direction vector of the player + double dirX = math.cos(playerAngle); + double dirY = math.sin(playerAngle); + + // The camera plane is perpendicular to the direction vector. + // Multiplying by tan(fov/2) scales the plane to match our field of view. + double planeX = -dirY * math.tan(fov / 2); + double planeY = dirX * math.tan(fov / 2); + + for (int x = 0; x < screenWidth; x++) { + // Calculate where on the camera plane this ray passes (-1 is left edge, 1 is right edge) + double cameraX = 2 * x / screenWidth - 1.0; + double rayDirX = dirX + planeX * cameraX; + double rayDirY = dirY + planeY * cameraX; + + // Current map box we are in + int mapX = player.x.toInt(); + int mapY = player.y.toInt(); + + // Length of ray from current position to next x or y-side + double sideDistX; + double sideDistY; + + // Length of ray from one x or y-side to next x or y-side + double deltaDistX = (rayDirX == 0) + ? double.infinity + : (1.0 / rayDirX).abs(); + double deltaDistY = (rayDirY == 0) + ? double.infinity + : (1.0 / rayDirY).abs(); + double perpWallDist; + + // Direction to step in x or y direction (+1 or -1) + int stepX; + int stepY; + + bool hit = false; + // 0 for North/South (vertical) walls, 1 for East/West (horizontal) walls + int side = 0; + int hitWallId = 0; + + // Calculate step and initial sideDist + if (rayDirX < 0) { + stepX = -1; + sideDistX = (player.x - mapX) * deltaDistX; + } else { + stepX = 1; + sideDistX = (mapX + 1.0 - player.x) * deltaDistX; + } + if (rayDirY < 0) { + stepY = -1; + sideDistY = (player.y - mapY) * deltaDistY; + } else { + stepY = 1; + sideDistY = (mapY + 1.0 - player.y) * deltaDistY; + } + + // 3. The True DDA Loop + while (!hit) { + // Jump to next map square, either in x-direction, or in y-direction + if (sideDistX < sideDistY) { + sideDistX += deltaDistX; + mapX += stepX; + side = 0; + } else { + sideDistY += deltaDistY; + mapY += stepY; + side = 1; + } + + // Check bounds and wall collisions + if (mapY < 0 || + mapY >= map.length || + mapX < 0 || + mapX >= map[0].length) { + hit = true; + perpWallDist = 20.0; // Out of bounds fallback + } else if (map[mapY][mapX] > 0) { + hit = true; + hitWallId = map[mapY][mapX]; + } + } + + // Calculate distance projected on camera direction (No fisheye effect!) + if (side == 0) { + perpWallDist = (sideDistX - deltaDistX); + } else { + perpWallDist = (sideDistY - deltaDistY); + } + + // 4. Calculate exact wall hit coordinate for textures + double wallX; + if (side == 0) { + wallX = player.y + perpWallDist * rayDirY; + } else { + wallX = player.x + perpWallDist * rayDirX; + } + wallX -= wallX.floor(); // Get just the fractional part (0.0 to 0.99) + + _drawTexturedColumn( + canvas, + x, + perpWallDist, + wallX, + side, + size, + hitWallId, + ); + } + } + + void _drawTexturedColumn( + Canvas canvas, + int x, + double distance, + double wallX, + int side, + Size size, + int hitWallId, + ) { + if (distance <= 0.01) distance = 0.01; + + double wallHeight = size.height / distance; + double drawStart = (size.height / 2) - (wallHeight / 2); + double drawEnd = (size.height / 2) + (wallHeight / 2); + + // --- PROCEDURAL TEXTURE LOGIC --- + Color baseColor; + + // Draw a dark edge on the sides of the block to create "tiles" + if (wallX < 0.05 || wallX > 0.95) { + baseColor = Colors.black87; + } else { + switch (hitWallId) { + case 1: + case 2: + case 3: + baseColor = Colors.grey[600]!; // Standard Grey Stone + break; + case 7: + case 8: + case 19: + baseColor = Colors.brown[600]!; // Wood Paneling + break; + case 9: + case 10: + baseColor = Colors.indigo[800]!; // Blue Stone + break; + case 17: + baseColor = Colors.red[900]!; // Red Brick + break; + case 41: + case 42: + baseColor = Colors.blueGrey; // Elevator walls + break; + default: + baseColor = Colors.teal; // Fallback for unknown IDs + } + } + + // Faux-Lighting: Darken East/West walls to give a 3D pop to corners + if (side == 1) { + baseColor = Color.fromARGB( + 255, + ((baseColor.r * 255).round().clamp(0, 255) * 0.7).toInt(), + ((baseColor.g * 255).round().clamp(0, 255) * 0.7).toInt(), + ((baseColor.b * 255).round().clamp(0, 255) * 0.7).toInt(), + ); + } + + // Depth cueing: Dim colors as they get further away + double dimFactor = (1.0 - (distance / 15)).clamp(0.0, 1.0); + Color finalColor = Color.fromARGB( + 255, + ((baseColor.r * 255).round().clamp(0, 255) * dimFactor).toInt(), + ((baseColor.g * 255).round().clamp(0, 255) * dimFactor).toInt(), + ((baseColor.b * 255).round().clamp(0, 255) * dimFactor).toInt(), + ); + + final paint = Paint() + ..color = finalColor + ..strokeWidth = + 1.1 // Prevent transparent gaps between line strokes + ..style = PaintingStyle.stroke; + + canvas.drawLine( + Offset(x.toDouble(), drawStart), + Offset(x.toDouble(), drawEnd), + paint, + ); + } + + @override + bool shouldRepaint(covariant RaycasterPainter oldDelegate) { + return oldDelegate.player != player || + oldDelegate.playerAngle != playerAngle; + } +} diff --git a/lib/features/renderer/renderer.dart b/lib/features/renderer/renderer.dart new file mode 100644 index 0000000..47d094b --- /dev/null +++ b/lib/features/renderer/renderer.dart @@ -0,0 +1,171 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:wolf_dart/classes/linear_coordinates.dart'; +import 'package:wolf_dart/classes/matrix.dart'; +import 'package:wolf_dart/features/map/wolf_map.dart'; +import 'package:wolf_dart/features/renderer/raycast_painter.dart'; + +class WolfRenderer extends StatefulWidget { + const WolfRenderer({super.key}); + + @override + State createState() => _WolfRendererState(); +} + +class _WolfRendererState extends State + with SingleTickerProviderStateMixin { + late Ticker _gameLoop; + final FocusNode _focusNode = FocusNode(); + late WolfMap gameMap; + late Matrix currentLevel; + + bool _isLoading = true; + + LinearCoordinates player = (x: 2.5, y: 2.5); + double playerAngle = 0.0; + final double fov = math.pi / 3; + + @override + void initState() { + super.initState(); + _initGame(); + } + + Future _initGame() async { + // 1. Load the entire WAD/WL1 data + gameMap = await WolfMap.load(); + + // 2. Extract Level 1 (E1M1) + currentLevel = gameMap.levels[0].wallGrid; + + // 3. (Optional) Remap the Wolf3D floor IDs so they work with your raycaster. + // In Wolf3D, 90 through 106 are usually empty floor. Your raycaster currently + // expects 0 to be empty space. Let's force them to 0 for now. + for (int y = 0; y < 64; y++) { + for (int x = 0; x < 64; x++) { + // In Wolf3D, wall values are 1 through ~63. + // Values 90+ represent empty floor spaces and doors. + // Let's zero out anything 90 or above, and LEAVE the walls alone. + if (currentLevel[y][x] >= 90) { + currentLevel[y][x] = 0; // Empty space + } + } + } + + // 4. Start the game! + _bumpPlayerIfStuck(); + _gameLoop = createTicker(_tick)..start(); + _focusNode.requestFocus(); + + setState(() { + _isLoading = false; + }); + } + + @override + void dispose() { + _gameLoop.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _bumpPlayerIfStuck() { + int pX = player.x.toInt(); + int pY = player.y.toInt(); + + if (pY < 0 || + pY >= currentLevel.length || + pX < 0 || + pX >= currentLevel[0].length || + currentLevel[pY][pX] > 0) { + double shortestDist = double.infinity; + LinearCoordinates nearestSafeSpot = (x: 1.5, y: 1.5); + + for (int y = 0; y < currentLevel.length; y++) { + for (int x = 0; x < currentLevel[y].length; x++) { + if (currentLevel[y][x] == 0) { + double safeX = x + 0.5; + double safeY = y + 0.5; + double dist = math.sqrt( + math.pow(safeX - player.x, 2) + math.pow(safeY - player.y, 2), + ); + + if (dist < shortestDist) { + shortestDist = dist; + nearestSafeSpot = (x: safeX, y: safeY); + } + } + } + } + player = nearestSafeSpot; + } + } + + void _tick(Duration elapsed) { + const double moveSpeed = 0.05; + const double turnSpeed = 0.04; + + double newX = player.x; + double newY = player.y; + + final pressedKeys = HardwareKeyboard.instance.logicalKeysPressed; + + if (pressedKeys.contains(LogicalKeyboardKey.keyW)) { + newX += math.cos(playerAngle) * moveSpeed; + newY += math.sin(playerAngle) * moveSpeed; + } + if (pressedKeys.contains(LogicalKeyboardKey.keyS)) { + newX -= math.cos(playerAngle) * moveSpeed; + newY -= math.sin(playerAngle) * moveSpeed; + } + + if (pressedKeys.contains(LogicalKeyboardKey.keyA)) { + playerAngle -= turnSpeed; + } + if (pressedKeys.contains(LogicalKeyboardKey.keyD)) { + playerAngle += turnSpeed; + } + + // Keep the angle mapped cleanly between 0 and 2*PI (optional, but good practice) + if (playerAngle < 0) playerAngle += 2 * math.pi; + if (playerAngle > 2 * math.pi) playerAngle -= 2 * math.pi; + + if (currentLevel[newY.toInt()][newX.toInt()] == 0) { + player = (x: newX, y: newY); + } + + setState(() {}); + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Center(child: CircularProgressIndicator(color: Colors.teal)); + } + + return Scaffold( + backgroundColor: Colors.black, + body: KeyboardListener( + focusNode: _focusNode, + autofocus: true, + onKeyEvent: (_) {}, + child: LayoutBuilder( + builder: (context, constraints) { + return CustomPaint( + size: Size(constraints.maxWidth, constraints.maxHeight), + painter: RaycasterPainter( + map: currentLevel, + player: player, + playerAngle: playerAngle, + fov: fov, + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 8417d7f..81025ef 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,339 +1,4 @@ -import 'dart:math' as math; - import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:flutter/services.dart'; - -typedef Matrix = List>; +import 'package:wolf_dart/features/renderer/renderer.dart'; void main() => runApp(const MaterialApp(home: WolfRenderer())); - -typedef LinearCoordinates = ({double x, double y}); - -class WolfRenderer extends StatefulWidget { - const WolfRenderer({super.key}); - - @override - State createState() => _WolfRendererState(); -} - -class _WolfRendererState extends State - with SingleTickerProviderStateMixin { - late Ticker _gameLoop; - final FocusNode _focusNode = FocusNode(); - - final Matrix map = [ - [1, 1, 1, 1, 1, 1, 1, 1], - [1, 0, 0, 0, 0, 0, 0, 1], - [1, 0, 1, 0, 0, 1, 0, 1], - [1, 0, 1, 0, 0, 1, 0, 1], - [1, 0, 0, 0, 0, 0, 0, 1], - [1, 1, 1, 1, 1, 1, 1, 1], - ]; - - LinearCoordinates player = (x: 2.5, y: 2.5); - double playerAngle = 0.0; - final double fov = math.pi / 3; - - @override - void initState() { - super.initState(); - _bumpPlayerIfStuck(); - - _gameLoop = createTicker(_tick)..start(); - _focusNode.requestFocus(); - } - - @override - void dispose() { - _gameLoop.dispose(); - _focusNode.dispose(); - super.dispose(); - } - - void _bumpPlayerIfStuck() { - int pX = player.x.toInt(); - int pY = player.y.toInt(); - - if (pY < 0 || - pY >= map.length || - pX < 0 || - pX >= map[0].length || - map[pY][pX] > 0) { - double shortestDist = double.infinity; - LinearCoordinates nearestSafeSpot = (x: 1.5, y: 1.5); - - for (int y = 0; y < map.length; y++) { - for (int x = 0; x < map[y].length; x++) { - if (map[y][x] == 0) { - double safeX = x + 0.5; - double safeY = y + 0.5; - double dist = math.sqrt( - math.pow(safeX - player.x, 2) + math.pow(safeY - player.y, 2), - ); - - if (dist < shortestDist) { - shortestDist = dist; - nearestSafeSpot = (x: safeX, y: safeY); - } - } - } - } - player = nearestSafeSpot; - } - } - - // Updated Movement Logic - void _tick(Duration elapsed) { - const double moveSpeed = 0.05; - const double turnSpeed = 0.04; // Added a dedicated turning speed - - double newX = player.x; - double newY = player.y; - - final pressedKeys = HardwareKeyboard.instance.logicalKeysPressed; - - if (pressedKeys.contains(LogicalKeyboardKey.keyW)) { - newX += math.cos(playerAngle) * moveSpeed; - newY += math.sin(playerAngle) * moveSpeed; - } - if (pressedKeys.contains(LogicalKeyboardKey.keyS)) { - newX -= math.cos(playerAngle) * moveSpeed; - newY -= math.sin(playerAngle) * moveSpeed; - } - - if (pressedKeys.contains(LogicalKeyboardKey.keyA)) { - playerAngle -= turnSpeed; - } - if (pressedKeys.contains(LogicalKeyboardKey.keyD)) { - playerAngle += turnSpeed; - } - - // Keep the angle mapped cleanly between 0 and 2*PI (optional, but good practice) - if (playerAngle < 0) playerAngle += 2 * math.pi; - if (playerAngle > 2 * math.pi) playerAngle -= 2 * math.pi; - - if (map[newY.toInt()][newX.toInt()] == 0) { - player = (x: newX, y: newY); - } - - setState(() {}); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - body: KeyboardListener( - focusNode: _focusNode, - autofocus: true, - onKeyEvent: (_) {}, - child: LayoutBuilder( - builder: (context, constraints) { - return CustomPaint( - size: Size(constraints.maxWidth, constraints.maxHeight), - painter: RaycasterPainter( - map: map, - player: player, - playerAngle: playerAngle, - fov: fov, - ), - ); - }, - ), - ), - ); - } -} - -class RaycasterPainter extends CustomPainter { - final Matrix map; - final LinearCoordinates player; - final double playerAngle; - final double fov; - - RaycasterPainter({ - required this.map, - required this.player, - required this.playerAngle, - required this.fov, - }); - - @override - void paint(Canvas canvas, Size size) { - // 1. Draw Ceiling & Floor - canvas.drawRect( - Rect.fromLTWH(0, 0, size.width, size.height / 2), - Paint()..color = Colors.blueGrey[900]!, - ); - canvas.drawRect( - Rect.fromLTWH(0, size.height / 2, size.width, size.height / 2), - Paint()..color = Colors.brown[900]!, - ); - - int screenWidth = size.width.toInt(); - - // 2. Camera Plane Setup - // Direction vector of the player - double dirX = math.cos(playerAngle); - double dirY = math.sin(playerAngle); - - // The camera plane is perpendicular to the direction vector. - // Multiplying by tan(fov/2) scales the plane to match our field of view. - double planeX = -dirY * math.tan(fov / 2); - double planeY = dirX * math.tan(fov / 2); - - for (int x = 0; x < screenWidth; x++) { - // Calculate where on the camera plane this ray passes (-1 is left edge, 1 is right edge) - double cameraX = 2 * x / screenWidth - 1.0; - double rayDirX = dirX + planeX * cameraX; - double rayDirY = dirY + planeY * cameraX; - - // Current map box we are in - int mapX = player.x.toInt(); - int mapY = player.y.toInt(); - - // Length of ray from current position to next x or y-side - double sideDistX; - double sideDistY; - - // Length of ray from one x or y-side to next x or y-side - double deltaDistX = (rayDirX == 0) - ? double.infinity - : (1.0 / rayDirX).abs(); - double deltaDistY = (rayDirY == 0) - ? double.infinity - : (1.0 / rayDirY).abs(); - double perpWallDist; - - // Direction to step in x or y direction (+1 or -1) - int stepX; - int stepY; - - bool hit = false; - int side = - 0; // 0 for North/South (vertical) walls, 1 for East/West (horizontal) walls - - // Calculate step and initial sideDist - if (rayDirX < 0) { - stepX = -1; - sideDistX = (player.x - mapX) * deltaDistX; - } else { - stepX = 1; - sideDistX = (mapX + 1.0 - player.x) * deltaDistX; - } - if (rayDirY < 0) { - stepY = -1; - sideDistY = (player.y - mapY) * deltaDistY; - } else { - stepY = 1; - sideDistY = (mapY + 1.0 - player.y) * deltaDistY; - } - - // 3. The True DDA Loop - while (!hit) { - // Jump to next map square, either in x-direction, or in y-direction - if (sideDistX < sideDistY) { - sideDistX += deltaDistX; - mapX += stepX; - side = 0; - } else { - sideDistY += deltaDistY; - mapY += stepY; - side = 1; - } - - // Check bounds and wall collisions - if (mapY < 0 || - mapY >= map.length || - mapX < 0 || - mapX >= map[0].length) { - hit = true; - perpWallDist = 20.0; // Out of bounds fallback - } else if (map[mapY][mapX] > 0) { - hit = true; - } - } - - // Calculate distance projected on camera direction (No fisheye effect!) - if (side == 0) { - perpWallDist = (sideDistX - deltaDistX); - } else { - perpWallDist = (sideDistY - deltaDistY); - } - - // 4. Calculate exact wall hit coordinate for textures - double wallX; - if (side == 0) { - wallX = player.y + perpWallDist * rayDirY; - } else { - wallX = player.x + perpWallDist * rayDirX; - } - wallX -= wallX.floor(); // Get just the fractional part (0.0 to 0.99) - - _drawTexturedColumn(canvas, x, perpWallDist, wallX, side, size); - } - } - - void _drawTexturedColumn( - Canvas canvas, - int x, - double distance, - double wallX, - int side, - Size size, - ) { - if (distance <= 0.01) distance = 0.01; - - double wallHeight = size.height / distance; - double drawStart = (size.height / 2) - (wallHeight / 2); - double drawEnd = (size.height / 2) + (wallHeight / 2); - - // --- PROCEDURAL TEXTURE LOGIC --- - Color baseColor; - - // Draw a dark edge on the sides of the block to create "tiles" - if (wallX < 0.05 || wallX > 0.95) { - baseColor = Colors.black87; - } else { - baseColor = Colors.teal; // Main wall color - } - - // Faux-Lighting: Darken East/West walls to give a 3D pop to corners - if (side == 1) { - baseColor = Color.fromARGB( - 255, - ((baseColor.r * 255).round().clamp(0, 255) * 0.7).toInt(), - ((baseColor.g * 255).round().clamp(0, 255) * 0.7).toInt(), - ((baseColor.b * 255).round().clamp(0, 255) * 0.7).toInt(), - ); - } - - // Depth cueing: Dim colors as they get further away - double dimFactor = (1.0 - (distance / 15)).clamp(0.0, 1.0); - Color finalColor = Color.fromARGB( - 255, - ((baseColor.r * 255).round().clamp(0, 255) * dimFactor).toInt(), - ((baseColor.g * 255).round().clamp(0, 255) * dimFactor).toInt(), - ((baseColor.b * 255).round().clamp(0, 255) * dimFactor).toInt(), - ); - - final paint = Paint() - ..color = finalColor - ..strokeWidth = - 1.1 // Prevent transparent gaps between line strokes - ..style = PaintingStyle.stroke; - - canvas.drawLine( - Offset(x.toDouble(), drawStart), - Offset(x.toDouble(), drawEnd), - paint, - ); - } - - @override - bool shouldRepaint(covariant RaycasterPainter oldDelegate) { - return oldDelegate.player != player || - oldDelegate.playerAngle != playerAngle; - } -} diff --git a/pubspec.yaml b/pubspec.yaml index 7d8033c..868b15c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: wolf_dart description: "A new Flutter project." -publish_to: 'none' +publish_to: "none" version: 0.1.0+1 environment: @@ -17,3 +17,5 @@ dev_dependencies: flutter: uses-material-design: true + assets: + - assets/