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; void main() { runApp(const MainApp()); } class MainApp extends StatefulWidget { const MainApp({super.key}); @override State createState() => _MainAppState(); } class _MainAppState extends State { RayData rayData = const RayData( playerAngle: 0, playerPosition: Offset.zero, rays: [], ); @override Widget build(BuildContext context) { return Directionality( textDirection: TextDirection.ltr, child: LayoutBuilder( builder: (context, constraints) => Row( spacing: mapSize / 1, 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( onAngleUpdated: (angle) { setState(() { rayData = rayData.copyWith( playerAngle: angle, ); }); }, onPositionUpdated: (position) { setState(() { rayData = rayData.copyWith( playerPosition: position, ); }); }, onRaysUpdated: (rays) { setState(() { rayData = rayData.copyWith( rays: rays, ); }); }, ), ], ), ), ), ), ), Flexible( flex: 3, child: Stack( children: [ const Background(), Walls(rayData), ], ), ), ], ), ), ); } } // * Player class Player extends StatefulWidget { const new({ required this.onRaysUpdated, required this.onAngleUpdated, required this.onPositionUpdated, super.key, }); final ValueChanged> onRaysUpdated; final ValueChanged onAngleUpdated; final ValueChanged onPositionUpdated; @override State createState() => _PlayerState(); } class _PlayerState extends State { late final FocusNode _focusNode; double playerAngle = 0; double get playerDeltaX => math.cos(playerAngle) * 5; double get playerDeltaY => math.sin(playerAngle) * 5; Offset get playerDelta => Offset(playerDeltaX, playerDeltaY); Offset playerPosition = const Offset(1.5 * mapSize, 1.5 * mapSize); @override void initState() { super.initState(); _focusNode = FocusNode()..requestFocus(); WidgetsBinding.instance.addPostFrameCallback((_) { widget.onRaysUpdated(calculateRays(playerPosition, playerDelta)); widget.onAngleUpdated(playerAngle); widget.onPositionUpdated(playerPosition); }); } @override void dispose() { _focusNode.dispose(); super.dispose(); } List calculateRays(Offset offset, Offset delta) { const double dr = math.pi / 180; 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 = delta.direction - (dr * 30); if (rayAngle < 0) { rayAngle += 2 * math.pi; } if (rayAngle > 2 * math.pi) { rayAngle -= 2 * math.pi; } int r; for (r = 0; r < 60; r++) { // * --- Check Horizontal Lines --- dof = 0; double distanceHor = 1000000; double hx = playerCenter.dx; double hy = playerCenter.dy; // 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 += dr; if (rayAngle < 0) { rayAngle += 2 * math.pi; } if (rayAngle > 2 * math.pi) { rayAngle -= 2 * math.pi; } } return rays; } double dist(double ax, double ay, double bx, double by, double ang) { return math.sqrt((bx - ax) * (bx - ax) + (by - ay) * (by - ay)); } @override Widget build(BuildContext context) { // TODO: Use HardwareKeyboard return Focus( focusNode: _focusNode, onKeyEvent: (FocusNode node, value) { if (value is! KeyDownEvent && value is! KeyRepeatEvent) { return KeyEventResult.ignored; } final LogicalKeyboardKey key = value.logicalKey; bool handled = false; if (key == LogicalKeyboardKey.keyA) { // Move left playerAngle -= 0.1; if (playerAngle < 0) { playerAngle += 2 * math.pi; } handled = true; } else if (key == LogicalKeyboardKey.keyD) { // Move right playerAngle += 0.1; if (playerAngle > 2 * math.pi) { playerAngle -= 2 * math.pi; } handled = true; } else if (key == LogicalKeyboardKey.keyW) { // Move forward playerPosition += playerDelta; handled = true; } else if (key == LogicalKeyboardKey.keyS) { // Move backwwards playerPosition -= playerDelta; handled = true; } if (handled) { widget.onRaysUpdated(calculateRays(playerPosition, playerDelta)); widget.onAngleUpdated(playerAngle); widget.onPositionUpdated(playerPosition); return KeyEventResult.handled; } return KeyEventResult.ignored; }, child: CustomPaint( painter: PlayerPainter( offset: playerPosition, delta: playerDelta, ), ), ); } } class PlayerPainter extends CustomPainter { final Offset offset; final Offset delta; const new({ required this.offset, required this.delta, }); @override void paint(Canvas canvas, Size size) { // Player dot canvas.drawRect( Rect.fromLTWH(offset.dx, offset.dy, 8, 8), Paint()..color = const Color.fromRGBO(255, 255, 0, 1), ); // Player direction line canvas.drawLine( offset + const Offset(4, 4), offset + const Offset(4, 4) + (delta * 5), Paint() ..color = const Color.fromRGBO(255, 255, 0, 1) ..strokeWidth = 2, ); } @override bool shouldRepaint(covariant oldDelegate) => false; @override bool shouldRebuildSemantics(covariant oldDelegate) => false; } // * Walls class Walls extends StatelessWidget { const new(this.rayData, {super.key}); final RayData rayData; @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) => ClipRect( child: CustomPaint( size: constraints.biggest, painter: WallPainter( playerAngle: rayData.playerAngle, rayData: rayData.rays, ), ), ), ); } } class WallPainter extends CustomPainter { final double playerAngle; final List rayData; const new({ required this.playerAngle, required this.rayData, }); @override void paint(Canvas canvas, Size size) { final double scale = math.max( size.width / gameWidth, size.height / gameHeight, ); final double offsetX = (size.width - gameWidth * scale) / 2; final double offsetY = (size.height - gameHeight * scale) / 2; canvas ..save() ..translate(offsetX, offsetY) ..scale(scale); for (int i = 0; i < rayData.length; i++) { final Ray ray = rayData[i]; // * --- Draw 3D Walls --- // Correct the fish-eye effect const double dr = math.pi / 180; final double rayAngle = playerAngle - (dr * 30) + (i * dr); double correctedAngle = playerAngle - rayAngle; if (correctedAngle < 0) { correctedAngle += 2 * math.pi; } if (correctedAngle > 2 * math.pi) { correctedAngle -= 2 * math.pi; } final double disT = ray.distance * math.cos(correctedAngle); double lineHeight = (mapSize * gameHeight) / disT; if (lineHeight > gameHeight) { lineHeight = gameHeight; } if (lineHeight > gameWidth) { lineHeight = gameWidth; } final double lineOffset = (gameHeight / 2.0) - (lineHeight / 2.0); final double rayWidth = gameWidth / rayData.length; final double rayX3D = i * rayWidth; canvas.drawRect( Rect.fromLTWH(rayX3D, lineOffset, rayWidth, lineHeight), Paint() ..color = ray.paintColor ..isAntiAlias = false ..strokeWidth = 1.5, ); } canvas.restore(); } @override bool shouldRepaint(covariant oldDelegate) => false; @override bool shouldRebuildSemantics(covariant oldDelegate) => false; } // * Map class Map extends StatelessWidget { const new( this.rayData, { super.key, }); final RayData rayData; @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), painter: MapPainter( mapData: map, mapX: mapX, mapY: mapY, tileSize: mapSize / 1, ), ), CustomPaint( painter: MapRayPainter( rayData: rayData, ), ), ], ); } } class MapPainter extends CustomPainter { final List mapData; final int mapX; final int mapY; final double tileSize; const new({ required this.mapData, required this.mapX, required this.mapY, required this.tileSize, }); @override void paint(Canvas canvas, Size size) { final paintWhite = Paint()..color = const Color.fromRGBO(255, 255, 255, 1); final paintBlack = Paint()..color = const Color.fromRGBO(0, 0, 0, 1); final paintGrey = Paint()..color = const Color.fromRGBO(84, 84, 84, 1); canvas.drawRect( Rect.fromLTWH(0, 0, tileSize * mapX, tileSize * mapY), paintGrey, ); for (int i = 0; i < mapData.length; i++) { final double x = (i % mapX) * tileSize; final double y = (i ~/ mapX) * tileSize; canvas.drawRect( Rect.fromLTWH(x, y, tileSize - 1, tileSize - 1), mapData[i] == 0 ? paintBlack : paintWhite, ); } } @override bool shouldRepaint(covariant oldDelegate) => false; @override bool shouldRebuildSemantics(covariant oldDelegate) => false; } class MapRayPainter extends CustomPainter { final RayData rayData; const new({ required this.rayData, }); @override void paint(Canvas canvas, Size size) { final Offset playerCenter = rayData.playerPosition + const Offset(4, 4); for (final Ray ray in rayData.rays) { canvas.drawLine( playerCenter, ray.hitPosition - const Offset(1, 1), Paint() ..color = const Color.fromRGBO(0, 255, 255, 1) ..strokeWidth = 1, ); } } @override bool shouldRepaint(covariant oldDelegate) => false; @override bool shouldRebuildSemantics(covariant oldDelegate) => false; } // * Background class Background extends StatelessWidget { const new({ super.key, }); @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) => Stack( textDirection: TextDirection.ltr, children: [ CustomPaint( size: constraints.biggest, painter: Ceiling(), ), CustomPaint( size: constraints.biggest, painter: Floor(), ), ], ), ); } } class Ceiling extends CustomPainter { @override void paint(Canvas canvas, Size size) { final Rect rect = Offset.zero & size; final double halfHeight = rect.height / 2; final Rect topHalf = Rect.fromLTWH( rect.left, rect.top, rect.width, halfHeight, ); const LinearGradient gradient = LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Color.fromRGBO(32, 32, 32, 1.0), Color.fromRGBO(96, 96, 96, 1.0), ], stops: [0.0, 1.0], ); canvas.drawRect( topHalf, Paint()..shader = gradient.createShader(topHalf), ); } @override bool shouldRepaint(covariant oldDelegate) => false; @override bool shouldRebuildSemantics(covariant oldDelegate) => false; } class Floor extends CustomPainter { @override void paint(Canvas canvas, Size size) { final Rect rect = Offset.zero & size; final double halfHeight = rect.height / 2; final Rect bottomHalf = Rect.fromLTWH( rect.left, halfHeight, rect.width, rect.bottom, ); const LinearGradient gradient = LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Color.fromRGBO(32, 32, 0, 1.0), Color.fromRGBO(96, 96, 0, 1.0), ], stops: [0.0, 1.0], ); canvas.drawRect( bottomHalf, Paint()..shader = gradient.createShader(bottomHalf), ); } @override bool shouldRepaint(covariant oldDelegate) => false; @override bool shouldRebuildSemantics(covariant oldDelegate) => false; } // * Ray Data class Ray( /// Where it hit on the 2D map var Offset hitPosition, /// Corrected 3D distance var double distance, /// Wall color var Color paintColor, ); class RayData { /// The direction the player is facing final double playerAngle; /// The player's position final Offset playerPosition; /// The rays cast from the player final List rays; const RayData({ required this.playerAngle, required this.playerPosition, required this.rays, }); RayData copyWith({ double? playerAngle, Offset? playerPosition, List? rays, }) { return RayData( playerAngle: playerAngle ?? this.playerAngle, playerPosition: playerPosition ?? this.playerPosition, rays: rays ?? this.rays, ); } }