Files
raycaster/lib/main.dart
T
2026-09-10 20:25:52 +02:00

889 lines
22 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import "dart:math" as math;
import "package:flutter/services.dart";
import "package:flutter/widgets.dart";
// dart format off
const List<int> 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;
extension GameValues on double {
/// Normalizes an angle back into [0, 2π].
double get normalize => (this % (2 * math.pi) + 2 * math.pi) % (2 * math.pi);
/// Returns the degree angle
double get radianToDegree {
final double proposed = this * 180 / math.pi;
if (proposed > 360) {
throw Exception(
"Proposed angle is $proposed, which is larger than 360. Check input: $this",
);
}
if (proposed.isNegative) {
throw Exception(
"Proposed angle is $proposed, which is negative. Check input: $this",
);
}
return proposed;
}
// Calculate the direction we're facing using the hypotenuse of the angle.
// This means the x is positive when we're facing "east" and negative when facing "west"
// and y is positive when facting "north" while negative when facing "south".
// Flutter's coordinate system flips the y-axis, which is why we have -sin() -- to flip
// it back in the correct direction.
Offset get direction => Offset(
math.cos(normalize),
-math.sin(normalize),
);
/// Get the delta from the given angle
Offset get playerDelta => Offset(
math.cos(this) * Player.speed,
math.sin(this) * Player.speed,
);
}
extension WorldValues on Offset {
// The current "tile" we're in on the map
Offset get currentTile => Offset(
(dx / mapSize).floorToDouble(),
(dy / mapSize).floorToDouble(),
);
// The index of the current "tile" we're in
// index = row * stride + column
int get index => (currentTile.dy.toInt() * mapX) + currentTile.dx.toInt();
}
void main() {
runApp(const MainApp());
}
class MainApp extends StatefulWidget {
const MainApp({super.key});
@override
State<MainApp> createState() => _MainAppState();
}
class _MainAppState extends State<MainApp> {
RayData rayData = const RayData(
playerAngle: 0,
playerPosition: Offset(1.5 * mapSize, 1.5 * mapSize),
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(
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,
);
});
},
),
],
),
),
),
),
),
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,
required this.rayData,
super.key,
});
final ValueChanged<List<Ray>> onRaysUpdated;
final ValueChanged<double> onAngleUpdated;
final ValueChanged<Offset> onPositionUpdated;
final RayData rayData;
/// How "fast" the player moves
static const int speed = 5;
@override
State<Player> createState() => _PlayerState();
}
class _PlayerState extends State<Player> {
@override
void initState() {
super.initState();
HardwareKeyboard.instance.addHandler(keyPressHandler);
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onRaysUpdated(
calculateRays(
widget.rayData.playerPosition,
widget.rayData.playerAngle.playerDelta,
),
);
widget.onAngleUpdated(
widget.rayData.playerAngle,
);
widget.onPositionUpdated(
widget.rayData.playerPosition,
);
});
}
@override
void dispose() {
super.dispose();
HardwareKeyboard.instance.removeHandler(keyPressHandler);
}
/// 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();
if (col < 0 || col >= mapX || row < 0 || row >= mapY) return 1; // solid
return map[row * mapX + 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;
for (double d = 0; d < maxCheck; d += stepSize) {
final Offset p = position + Offset(direction.dx, direction.dy) * d;
if (tileAt(p) > 0) return d;
}
return double.infinity;
}
bool keyPressHandler(KeyEvent event) {
final Set<LogicalKeyboardKey> keys =
HardwareKeyboard.instance.logicalKeysPressed;
bool handled = false;
// Where is the player now?
Offset playerPosition = widget.rayData.playerPosition;
double playerAngle = widget.rayData.playerAngle;
final Offset direction = playerAngle.direction;
// How many pixels away from a wall to stay
const double buffer = 8;
if (keys.contains(LogicalKeyboardKey.keyA)) {
// Move left
playerAngle -= 0.1;
if (playerAngle < 0) {
playerAngle += 2 * math.pi;
}
handled = true;
} else if (keys.contains(LogicalKeyboardKey.keyD)) {
// Move right
playerAngle += 0.1;
if (playerAngle > 2 * math.pi) {
playerAngle -= 2 * math.pi;
}
handled = true;
}
if (keys.contains(LogicalKeyboardKey.keyW)) {
if (safeDistanceToWall(playerPosition, direction) >
Player.speed + buffer) {
// Move forward
playerPosition += playerAngle.playerDelta;
}
handled = true;
} else if (keys.contains(LogicalKeyboardKey.keyS)) {
if (safeDistanceToWall(playerPosition, -direction) >
Player.speed + buffer) {
// Move backwwards
playerPosition -= playerAngle.playerDelta;
}
handled = true;
}
if (handled) {
widget.onRaysUpdated(
calculateRays(playerPosition, playerAngle.playerDelta),
);
widget.onAngleUpdated(playerAngle);
widget.onPositionUpdated(playerPosition);
return true;
}
return false;
}
List<Ray> calculateRays(Offset offset, Offset delta) {
const double dr = math.pi / 180;
final List<Ray> 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) {
return CustomPaint(
painter: PlayerPainter(
offset: widget.rayData.playerPosition,
delta: widget.rayData.playerAngle.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<Ray> 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<int> 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<Ray> rays;
const RayData({
required this.playerAngle,
required this.playerPosition,
required this.rays,
});
RayData copyWith({
double? playerAngle,
Offset? playerPosition,
List<Ray>? rays,
}) {
return RayData(
playerAngle: playerAngle ?? this.playerAngle,
playerPosition: playerPosition ?? this.playerPosition,
rays: rays ?? this.rays,
);
}
}