From 6e566c339de46cdec6d16fdb7b6772e072927fde Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Sun, 13 Sep 2026 17:11:56 +0200 Subject: [PATCH] Fixed clipping through walls; map is now an overlay Signed-off-by: Hans Kokx --- lib/main.dart | 633 +++++++++++++++++++++++++++----------------------- 1 file changed, 346 insertions(+), 287 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index e799ac0..4b7c97d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,23 +3,6 @@ import "dart:math" as math; import "package:flutter/services.dart"; import "package:flutter/widgets.dart"; -// dart format off -const List map = [ - 1,1,1,1,1,1,1,1, - 1,0,0,0,0,0,0,1, - 1,0,1,0,0,0,0,1, - 1,0,1,0,0,0,0,1, - 1,0,0,0,0,0,0,1, - 1,0,0,0,0,2,0,1, - 1,0,0,0,0,0,0,1, - 1,1,1,1,1,1,1,1, -]; -// dart format on - -const int mapX = 8; -const int mapY = 8; -const int mapSize = 32; - const double gameWidth = 320; const double gameHeight = 256; @@ -67,13 +50,15 @@ extension GameValues on double { extension WorldValues on Offset { // The current "tile" we're in on the map Offset get currentTile => Offset( - (dx / mapSize).floorToDouble(), - (dy / mapSize).floorToDouble(), + (dx / GameMap.size).floorToDouble(), + (dy / GameMap.size).floorToDouble(), ); // The index of the current "tile" we're in // index = row * stride + column - int get index => (currentTile.dy.toInt() * mapX) + currentTile.dx.toInt(); + int get index => + (currentTile.dy.toInt() * GameMap.dimensions.width.toInt()) + + currentTile.dx.toInt(); } void main() { @@ -90,7 +75,7 @@ class MainApp extends StatefulWidget { class _MainAppState extends State { RayData rayData = const RayData( playerAngle: 0, - playerPosition: Offset(1.5 * mapSize, 1.5 * mapSize), + playerPosition: Offset(1.5 * GameMap.size, 1.5 * GameMap.size), rays: [], ); @@ -99,57 +84,49 @@ class _MainAppState extends State { return Directionality( textDirection: TextDirection.ltr, child: LayoutBuilder( - builder: (context, constraints) => Row( - spacing: mapSize / 1, + builder: (context, constraints) => Stack( textDirection: TextDirection.ltr, children: [ - Flexible( - child: Transform.translate( - offset: const Offset(16, 0), - child: Center( - child: FittedBox( - fit: BoxFit.contain, - child: Stack( - children: [ - Map(rayData), - Player( - rayData: rayData, - onAngleUpdated: (radians) { - // degrees = radians × (180 / π) - setState(() { - rayData = rayData.copyWith( - playerAngle: radians, - ); - }); - }, - onPositionUpdated: (position) { - setState(() { - rayData = rayData.copyWith( - playerPosition: position, - ); - }); - }, - onRaysUpdated: (rays) { - setState(() { - rayData = rayData.copyWith( - rays: rays, - ); - }); - }, - ), - ], - ), - ), - ), - ), + Stack( + children: [ + const Background(), + Walls(rayData), + ], ), - Flexible( - flex: 3, - child: Stack( - children: [ - const Background(), - Walls(rayData), - ], + Opacity( + opacity: 0.5, + child: SizedBox.square( + dimension: GameMap.size * GameMap.dimensions.width / 2, + child: Stack( + children: [ + Map(rayData), + Player( + rayData: rayData, + onAngleUpdated: (radians) { + // degrees = radians × (180 / π) + setState(() { + rayData = rayData.copyWith( + playerAngle: radians, + ); + }); + }, + onPositionUpdated: (position) { + setState(() { + rayData = rayData.copyWith( + playerPosition: position, + ); + }); + }, + onRaysUpdated: (rays) { + setState(() { + rayData = rayData.copyWith( + rays: rays, + ); + }); + }, + ), + ], + ), ), ), ], @@ -214,25 +191,38 @@ class _PlayerState extends State { /// Get the map tile at a given pixel coordinate int tileAt(Offset position) { - final int col = (position.dx / mapSize).floor(); - final int row = (position.dy / mapSize).floor(); + final int col = (position.dx / GameMap.size).floor(); + final int row = (position.dy / GameMap.size).floor(); - if (col < 0 || col >= mapX || row < 0 || row >= mapY) return 1; // solid + if (col < 0 || + col >= GameMap.dimensions.width || + row < 0 || + row >= GameMap.dimensions.height) + return 1; // solid - return map[row * mapX + col]; + return GameMap.map[row * GameMap.dimensions.width.toInt() + col]; } /// How far until we hit a wall in the movement direction? double safeDistanceToWall(Offset position, Offset direction) { - const int maxCheck = 64; - const int stepSize = 8; + final List choices = [ + RayHit.castRay( + position, + direction.dx, + ).distance, + RayHit.castRay( + position, + direction.direction.normalizeAngle - 0.05, + ).distance, + RayHit.castRay( + position, + direction.direction.normalizeAngle + 0.05, + ).distance, + ]; - for (double d = 0; d < maxCheck; d += stepSize) { - final Offset p = position + Offset(direction.dx, direction.dy) * d; - if (tileAt(p) > 0) return d; - } + choices.sort(); - return double.infinity; + return choices.first; } bool keyPressHandler(KeyEvent event) { @@ -243,6 +233,9 @@ class _PlayerState extends State { // Where is the player now? Offset playerPosition = widget.rayData.playerPosition; + final Offset playerCenter = + playerPosition + + Offset(GameMap.dimensions.width / 2, GameMap.dimensions.height / 2); double playerAngle = widget.rayData.playerAngle; // How many pixels away from a wall to stay @@ -262,14 +255,13 @@ class _PlayerState extends State { final Offset direction = playerAngle.direction; if (keys.contains(LogicalKeyboardKey.keyW)) { - if (safeDistanceToWall(playerPosition, direction) > - Player.speed + buffer) { + if (safeDistanceToWall(playerCenter, direction) > Player.speed + buffer) { // Move forward playerPosition += playerAngle.playerDelta; } handled = true; } else if (keys.contains(LogicalKeyboardKey.keyS)) { - if (safeDistanceToWall(playerPosition, -direction) > + if (safeDistanceToWall(playerCenter, -direction) > Player.speed + buffer) { // Move backwwards playerPosition -= playerAngle.playerDelta; @@ -291,198 +283,29 @@ class _PlayerState extends State { List calculateRays(Offset offset, double playerAngle) { const double dr = math.pi / 180; + final double baseAngle = (playerAngle - dr * 30).normalizeAngle; final List rays = []; - int dof; - int mx; - int my; - int mapPoint; - - double yo = 1; - double xo = 1; - - final Offset playerCenter = offset + const Offset(4, 4); - - double rayX = 0; - double rayY = 0; - double disT = 0; - - double rayAngle = (playerAngle - (dr * 30)).normalizeAngle; + final Offset playerCenter = + offset + + Offset( + GameMap.dimensions.width / 2, + GameMap.dimensions.height / 2, + ); int r; for (r = 0; r < 60; r++) { - // * --- Check Horizontal Lines --- - dof = 0; - double distanceHor = 1000000; - double hx = playerCenter.dx; - double hy = playerCenter.dy; + final double angle = (baseAngle + r * dr).normalizeAngle; + final RayHit hit = RayHit.castRay(playerCenter, angle); - // Negative inverse of tangent - final double aTan = -1.0 / math.tan(rayAngle); - - // ? Looking up (ray angle > π) - if (rayAngle > math.pi) { - rayY = ((playerCenter.dy / mapSize).floorToDouble() * mapSize) - 0.0001; - - rayX = (playerCenter.dy - rayY) * aTan + playerCenter.dx; - - yo = -mapSize / 1; - xo = -yo * aTan; - } - - // ? Looking down - if (rayAngle < math.pi) { - rayY = - ((playerCenter.dy / mapSize).floorToDouble() * mapSize) + mapSize; - - rayX = (playerCenter.dy - rayY) * aTan + playerCenter.dx; - - yo = mapSize / 1; - xo = -yo * aTan; - } - - // ? Looking straight left or right - if (rayAngle == 0.0 || rayAngle == math.pi) { - // It's impossible to ever hit a horizontal line - rayX = playerCenter.dx; - rayY = playerCenter.dy; - dof = 8; - } - - int mv = 0; - int mh = 0; - - while (dof < 8) { - mx = rayX ~/ mapSize; - my = rayY ~/ mapSize; - - mapPoint = my * mapX + mx; - - if (mapPoint > 0 && mapPoint < mapX * mapY && map[mapPoint] > 0) { - // We hit a wall! - - mh = map[mapPoint]; - - hx = rayX; - hy = rayY; - distanceHor = dist( - playerCenter.dx, - playerCenter.dy, - hx, - hy, - rayAngle, - ); - - dof = 8; - } else { - rayX += xo; - rayY += yo; - dof += 1; - } - } - - // * --- Check Vertical Lines --- - dof = 0; - double distanceVert = 1000000; - double vx = playerCenter.dx; - double vy = playerCenter.dy; - - // Negative of tangent - final double nTan = -math.tan(rayAngle); - - const double pi2 = math.pi / 2; - const double pi3 = 3 * math.pi / 2; - - // ? Looking left - if (rayAngle > pi2 && rayAngle < pi3) { - rayX = ((playerCenter.dx / mapSize).floorToDouble() * mapSize) - 0.0001; - rayY = (playerCenter.dx - rayX) * nTan + playerCenter.dy; - xo = -mapSize / 1; - yo = -xo * nTan; - } - - // ? Looking right - if (rayAngle < pi2 || rayAngle > pi3) { - rayX = - ((playerCenter.dx / mapSize).floorToDouble() * mapSize) + mapSize; - rayY = (playerCenter.dx - rayX) * nTan + playerCenter.dy; - xo = mapSize / 1; - yo = -xo * nTan; - } - - // ? Looking straight up or down - if (rayAngle == pi2 || rayAngle == pi3) { - rayX = playerCenter.dx; - rayY = playerCenter.dy; - dof = 8; - } - - while (dof < 8) { - mx = rayX ~/ mapSize; - my = rayY ~/ mapSize; - - mapPoint = my * mapX + mx; - - if (mapPoint > 0 && mapPoint < mapX * mapY && map[mapPoint] > 0) { - // We hit a wall! - - mv = map[mapPoint]; - - // Save the values and calculate the distance from the player - vx = rayX; - vy = rayY; - distanceVert = dist( - playerCenter.dx, - playerCenter.dy, - vx, - vy, - rayAngle, - ); - - dof = 8; // Break the loop - no need to calculate more. - } else { - // If we didn't hit a wall, we check the next horizontal line by adding - // the x and y offset. - rayX += xo; - rayY += yo; - dof += 1; - } - } - - Color wallColor = const Color.fromRGBO(0, 0, 0, 0); - - if (distanceVert < distanceHor) { - rayX = vx; - rayY = vy; - disT = distanceVert; - // Vertical walls are drawn a little brighter than horizontal walls - wallColor = const Color.fromRGBO(230, 0, 0, 1); - - if (mv == 2) { - wallColor = const Color.fromRGBO(0, 179, 0, 1); - } - - rays.add(Ray(Offset(rayX, rayY), disT, wallColor)); - } - - if (distanceHor < distanceVert) { - rayX = hx; - rayY = hy; - disT = distanceHor; - // Horizontal walls are drawn a little darker than vertical walls - wallColor = const Color.fromRGBO(179, 0, 0, 1); - - if (mh == 2) { - wallColor = const Color.fromRGBO(0, 102, 0, 1); - } - - rays.add(Ray(Offset(rayX, rayY), disT, wallColor)); - } - - // * Increase the ray angle to draw the next ray - rayAngle = - (playerAngle.normalizeAngle - (dr * 30) + r * dr).normalizeAngle; + rays.add( + Ray( + hit.hitPosition, + hit.distance, + hit.colorFor(hit.tileValue, hit.axis), + ), + ); } return rays; @@ -595,7 +418,7 @@ class WallPainter extends CustomPainter { final double disT = ray.distance * math.cos(correctedAngle); - double lineHeight = (mapSize * gameHeight) / disT; + double lineHeight = (GameMap.size * gameHeight) / disT; if (lineHeight > gameHeight) { lineHeight = gameHeight; } @@ -621,9 +444,9 @@ class WallPainter extends CustomPainter { } @override - bool shouldRepaint(covariant oldDelegate) => false; + bool shouldRepaint(covariant oldDelegate) => true; @override - bool shouldRebuildSemantics(covariant oldDelegate) => false; + bool shouldRebuildSemantics(covariant oldDelegate) => true; } // * Map @@ -637,18 +460,15 @@ class Map extends StatelessWidget { @override Widget build(BuildContext context) { - const double width = mapX * mapSize / 1; - const double height = mapY * mapSize / 1; - return Stack( children: [ - const CustomPaint( - size: Size(width, height), + CustomPaint( + size: GameMap.dimensions, painter: MapPainter( - mapData: map, - mapX: mapX, - mapY: mapY, - tileSize: mapSize / 1, + mapData: GameMap.map, + mapX: GameMap.dimensions.width, + mapY: GameMap.dimensions.height, + tileSize: GameMap.size / 1, ), ), @@ -664,8 +484,8 @@ class Map extends StatelessWidget { class MapPainter extends CustomPainter { final List mapData; - final int mapX; - final int mapY; + final double mapX; + final double mapY; final double tileSize; const new({ @@ -698,9 +518,9 @@ class MapPainter extends CustomPainter { } @override - bool shouldRepaint(covariant oldDelegate) => false; + bool shouldRepaint(covariant oldDelegate) => true; @override - bool shouldRebuildSemantics(covariant oldDelegate) => false; + bool shouldRebuildSemantics(covariant oldDelegate) => true; } class MapRayPainter extends CustomPainter { @@ -726,9 +546,9 @@ class MapRayPainter extends CustomPainter { } @override - bool shouldRepaint(covariant oldDelegate) => false; + bool shouldRepaint(covariant oldDelegate) => true; @override - bool shouldRebuildSemantics(covariant oldDelegate) => false; + bool shouldRebuildSemantics(covariant oldDelegate) => true; } // * Background @@ -823,7 +643,7 @@ class Floor extends CustomPainter { bool shouldRebuildSemantics(covariant oldDelegate) => false; } -// * Ray Data +// * Rays class Ray( /// Where it hit on the 2D map var Offset hitPosition, @@ -863,3 +683,242 @@ class RayData { ); } } + +class RayHit( + /// Exact boundary crossing where the ray stopped + var Offset hitPosition, + + /// Euclidean, from ray origin + var double distance, + + /// 0 means "no hit within map" + var int tileValue, + + /// [Axis] of the corresponding ray hit + var Axis axis, +) { + factory castRay(Offset origin, double angle) { + // 1. run horizontal-axis DDA → hit position + distance (or none) + // 2. run vertical-axis DDA → hit position + distance (or none) + // 3. return the nearer one + + final RayHit hitHorizontal = RayHit._marchHorizontal(origin, angle); + final RayHit hitVertical = RayHit._marchVertical(origin, angle); + + if (hitVertical.distance < hitHorizontal.distance) { + return hitVertical; + } + + return hitHorizontal; + } + + factory _marchHorizontal(Offset origin, double angle) { + // * --- Check Horizontal Lines --- + int dof = 0; + double distanceHor = double.infinity; + Offset hit = origin; + + // Negative inverse of tangent + final double aTan = -1.0 / math.tan(angle); + + double rayY = 0; + double rayX = 0; + double yOffset = 0; + double xOffset = 0; + + // ? Looking up (ray angle > π) + if (angle > math.pi) { + rayY = + ((origin.dy / GameMap.size).floorToDouble() * GameMap.size) - 0.0001; + + rayX = (origin.dy - rayY) * aTan + origin.dx; + + yOffset = -GameMap.size / 1; + xOffset = -yOffset * aTan; + } + + // ? Looking down + if (angle < math.pi) { + rayY = + ((origin.dy / GameMap.size).floorToDouble() * GameMap.size) + + GameMap.size; + + rayX = (origin.dy - rayY) * aTan + origin.dx; + + yOffset = GameMap.size / 1; + xOffset = -yOffset * aTan; + } + + int mapPointX = 0; + int mapPointY = 0; + int mapPoint = 0; + int mapHit = 0; + + while (dof < 8) { + mapPointX = rayX ~/ GameMap.size; + mapPointY = rayY ~/ GameMap.size; + + mapPoint = mapPointY * GameMap.dimensions.width.toInt() + mapPointX; + + if (mapPoint > 0 && + mapPoint < + GameMap.dimensions.width.toInt() * + GameMap.dimensions.height.toInt() && + GameMap.map[mapPoint] > 0) { + // We hit a wall! + mapHit = GameMap.map[mapPoint]; + + hit = Offset(rayX, rayY); + + distanceHor = dist( + origin, + hit, + angle, + ); + + dof = 8; + } else { + rayX += xOffset; + rayY += yOffset; + dof += 1; + } + } + + return RayHit(hit, distanceHor, mapHit, Axis.horizontal); + } + + factory _marchVertical(Offset origin, double angle) { + // * --- Check Vertical Lines --- + int dof = 0; + double distanceVert = double.infinity; + Offset hit = origin; + + // Negative of tangent + final double nTan = -math.tan(angle); + + const double pi2 = math.pi / 2; + const double pi3 = 3 * math.pi / 2; + + double rayX = 0; + double rayY = 0; + double xOffset = 0; + double yOffset = 0; + + // ? Looking left + if (angle > pi2 && angle < pi3) { + rayX = + ((origin.dx / GameMap.size).floorToDouble() * GameMap.size) - 0.0001; + rayY = (origin.dx - rayX) * nTan + origin.dy; + xOffset = -GameMap.size / 1; + yOffset = -xOffset * nTan; + } + + // ? Looking right + if (angle < pi2 || angle > pi3) { + rayX = + ((origin.dx / GameMap.size).floorToDouble() * GameMap.size) + + GameMap.size; + rayY = (origin.dx - rayX) * nTan + origin.dy; + xOffset = GameMap.size / 1; + yOffset = -xOffset * nTan; + } + + // ? Looking straight up or down + if (angle == pi2 || angle == pi3) { + rayX = origin.dx; + rayY = origin.dy; + dof = 8; + } + + int mapPointX = 0; + int mapPointY = 0; + int mapPoint = 0; + int mapHit = 0; + + while (dof < 8) { + mapPointX = rayX ~/ GameMap.size; + mapPointY = rayY ~/ GameMap.size; + + mapPoint = mapPointY * GameMap.dimensions.width.toInt() + mapPointX; + + if (mapPoint > 0 && + mapPoint < + GameMap.dimensions.width.toInt() * + GameMap.dimensions.height.toInt() && + GameMap.map[mapPoint] > 0) { + // We hit a wall! + mapHit = GameMap.map[mapPoint]; + + // Save the values and calculate the distance from the player + hit = Offset(rayX, rayY); + + distanceVert = dist( + origin, + hit, + angle, + ); + + // Break the loop - no need to calculate more. + dof = 8; + } else { + // If we didn't hit a wall, we check the next horizontal line by adding + // the x and y offset. + rayX += xOffset; + rayY += yOffset; + dof += 1; + } + } + + return RayHit(hit, distanceVert, mapHit, Axis.vertical); + } + + static double dist(Offset a, Offset b, double ang) { + return math.sqrt( + (b.dx - a.dx) * (b.dx - a.dx) + (b.dy - a.dy) * (b.dy - a.dy), + ); + } + + Color colorFor(int tileValue, Axis axis) { + Color wallColor = const Color.fromRGBO(0, 0, 0, 0); + + if (axis == Axis.vertical) { + // Vertical walls are drawn a little brighter than horizontal walls + wallColor = const Color.fromRGBO(230, 0, 0, 1); + + if (tileValue == 2) { + wallColor = const Color.fromRGBO(0, 179, 0, 1); + } + } + + if (axis == Axis.horizontal) { + // Horizontal walls are drawn a little darker than vertical walls + wallColor = const Color.fromRGBO(179, 0, 0, 1); + + if (tileValue == 2) { + wallColor = const Color.fromRGBO(0, 102, 0, 1); + } + } + + return wallColor; + } +} + +// * Map + +abstract class GameMap { + // dart format off + static const List map = [ + 1,1,1,1,1,1,1,1, + 1,0,0,0,0,0,0,1, + 1,0,1,0,0,0,0,1, + 1,0,1,0,0,0,0,1, + 1,0,0,0,0,0,0,1, + 1,0,0,0,0,2,0,1, + 1,0,0,0,0,0,0,1, + 1,1,1,1,1,1,1,1, + ]; + // dart format on + + static const Size dimensions = Size(8, 8); + static const int size = 32; +}