diff --git a/assets/GAMEMAPS.WL1 b/assets/GAMEMAPS.WL1 new file mode 100644 index 0000000..8ca197c Binary files /dev/null and b/assets/GAMEMAPS.WL1 differ diff --git a/assets/MAPHEAD.WL1 b/assets/MAPHEAD.WL1 new file mode 100644 index 0000000..29c8f60 Binary files /dev/null and b/assets/MAPHEAD.WL1 differ 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/