Initial commit: labirynt 3D pseudo-raycasting game

This commit is contained in:
lukasz@orzechowski.eu
2026-02-07 10:20:50 +01:00
commit a7f14c010f
132 changed files with 5319 additions and 0 deletions

209
lib/main.dart Normal file
View File

@@ -0,0 +1,209 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'maze.dart';
import 'player.dart';
import 'raycaster.dart';
void main() {
runApp(const LabiryntApp());
}
class LabiryntApp extends StatelessWidget {
const LabiryntApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Labirynt 3D',
theme: ThemeData.dark(),
home: const GameScreen(),
);
}
}
class GameScreen extends StatefulWidget {
const GameScreen({super.key});
@override
State<GameScreen> createState() => _GameScreenState();
}
class _GameScreenState extends State<GameScreen> with SingleTickerProviderStateMixin {
late Maze maze;
late Player player;
late AnimationController _controller;
final Set<LogicalKeyboardKey> _pressedKeys = {};
bool _won = false;
int _mazeSize = 15;
@override
void initState() {
super.initState();
_initGame();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
)..addListener(_gameLoop);
_controller.repeat();
}
void _initGame() {
if (_mazeSize.isEven) _mazeSize++;
maze = Maze(_mazeSize, _mazeSize);
player = Player(x: 1.5, y: 1.5);
_won = false;
}
void _gameLoop() {
if (_won) return;
setState(() {
if (_pressedKeys.contains(LogicalKeyboardKey.keyW) ||
_pressedKeys.contains(LogicalKeyboardKey.arrowUp)) {
player.moveForward(maze);
}
if (_pressedKeys.contains(LogicalKeyboardKey.keyS) ||
_pressedKeys.contains(LogicalKeyboardKey.arrowDown)) {
player.moveBackward(maze);
}
if (_pressedKeys.contains(LogicalKeyboardKey.keyA)) {
player.strafeLeft(maze);
}
if (_pressedKeys.contains(LogicalKeyboardKey.keyD)) {
player.strafeRight(maze);
}
if (_pressedKeys.contains(LogicalKeyboardKey.arrowLeft)) {
player.rotateLeft();
}
if (_pressedKeys.contains(LogicalKeyboardKey.arrowRight)) {
player.rotateRight();
}
if (maze.isGoal(player.x, player.y)) {
_won = true;
}
});
}
void _newGame() {
setState(() {
_mazeSize += 2;
if (_mazeSize > 31) _mazeSize = 15;
_initGame();
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: KeyboardListener(
focusNode: FocusNode()..requestFocus(),
autofocus: true,
onKeyEvent: (event) {
if (event is KeyDownEvent) {
_pressedKeys.add(event.logicalKey);
} else if (event is KeyUpEvent) {
_pressedKeys.remove(event.logicalKey);
}
},
child: Stack(
children: [
CustomPaint(
painter: RaycasterPainter(maze: maze, player: player),
size: Size.infinite,
),
Positioned(
bottom: 16,
left: 16,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'WASD / Strzalki — ruch\nCel: zielony punkt na mapie',
style: TextStyle(color: Colors.white70, fontSize: 14),
),
),
),
Positioned(
bottom: 16,
right: 16,
child: Column(
children: [
_controlButton(Icons.arrow_upward, () => player.moveForward(maze)),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_controlButton(Icons.rotate_left, () => player.rotateLeft()),
const SizedBox(width: 8),
_controlButton(Icons.rotate_right, () => player.rotateRight()),
],
),
_controlButton(Icons.arrow_downward, () => player.moveBackward(maze)),
],
),
),
if (_won)
Container(
color: Colors.black87,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'WYGRANA!',
style: TextStyle(
color: Colors.greenAccent,
fontSize: 64,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
Text(
'Labirynt ${_mazeSize}x$_mazeSize ukonczony',
style: const TextStyle(color: Colors.white70, fontSize: 24),
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: _newGame,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
),
child: const Text('Nastepny poziom', style: TextStyle(fontSize: 20)),
),
],
),
),
),
],
),
),
);
}
Widget _controlButton(IconData icon, VoidCallback onPressed) {
return Padding(
padding: const EdgeInsets.all(2),
child: GestureDetector(
onTapDown: (_) => setState(onPressed),
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: Colors.white70, size: 28),
),
),
);
}
}

45
lib/maze.dart Normal file
View File

@@ -0,0 +1,45 @@
class Maze {
final int width;
final int height;
late List<List<int>> grid;
Maze(this.width, this.height) {
grid = List.generate(height, (y) => List.generate(width, (x) => 1));
_generate();
}
void _generate() {
_carve(1, 1);
// Ensure start and end are open
grid[1][1] = 0;
grid[height - 2][width - 2] = 0;
}
void _carve(int x, int y) {
grid[y][x] = 0;
final dirs = [
[0, -2],
[0, 2],
[-2, 0],
[2, 0],
]..shuffle();
for (final d in dirs) {
final nx = x + d[0];
final ny = y + d[1];
if (ny > 0 && ny < height - 1 && nx > 0 && nx < width - 1 && grid[ny][nx] == 1) {
grid[y + d[1] ~/ 2][x + d[0] ~/ 2] = 0;
_carve(nx, ny);
}
}
}
bool isWall(int x, int y) {
if (x < 0 || x >= width || y < 0 || y >= height) return true;
return grid[y][x] == 1;
}
bool isGoal(double x, double y) {
return (x - (width - 2)).abs() < 0.5 && (y - (height - 2)).abs() < 0.5;
}
}

50
lib/player.dart Normal file
View File

@@ -0,0 +1,50 @@
import 'dart:math';
import 'maze.dart';
class Player {
double x;
double y;
double angle;
static const double moveSpeed = 0.05;
static const double rotSpeed = 0.04;
Player({required this.x, required this.y, this.angle = 0});
void moveForward(Maze maze) {
_tryMove(maze, cos(angle) * moveSpeed, sin(angle) * moveSpeed);
}
void moveBackward(Maze maze) {
_tryMove(maze, -cos(angle) * moveSpeed, -sin(angle) * moveSpeed);
}
void strafeLeft(Maze maze) {
_tryMove(maze, cos(angle - pi / 2) * moveSpeed, sin(angle - pi / 2) * moveSpeed);
}
void strafeRight(Maze maze) {
_tryMove(maze, cos(angle + pi / 2) * moveSpeed, sin(angle + pi / 2) * moveSpeed);
}
void rotateLeft() {
angle -= rotSpeed;
}
void rotateRight() {
angle += rotSpeed;
}
void _tryMove(Maze maze, double dx, double dy) {
const margin = 0.2;
final nx = x + dx;
final ny = y + dy;
if (!maze.isWall((nx + margin).toInt(), y.toInt()) &&
!maze.isWall((nx - margin).toInt(), y.toInt())) {
x = nx;
}
if (!maze.isWall(x.toInt(), (ny + margin).toInt()) &&
!maze.isWall(x.toInt(), (ny - margin).toInt())) {
y = ny;
}
}
}

155
lib/raycaster.dart Normal file
View File

@@ -0,0 +1,155 @@
import 'dart:math';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'maze.dart';
import 'player.dart';
class RaycasterPainter extends CustomPainter {
final Maze maze;
final Player player;
static const double fov = pi / 3;
static const int maxDepth = 20;
RaycasterPainter({required this.maze, required this.player});
@override
void paint(Canvas canvas, Size size) {
_drawSky(canvas, size);
_drawFloor(canvas, size);
_drawWalls(canvas, size);
_drawMinimap(canvas, size);
}
void _drawSky(Canvas canvas, Size size) {
final paint = Paint()
..shader = const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.center,
colors: [Color(0xFF1a1a2e), Color(0xFF16213e)],
).createShader(Rect.fromLTWH(0, 0, size.width, size.height / 2));
canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height / 2), paint);
}
void _drawFloor(Canvas canvas, Size size) {
final paint = Paint()
..shader = const LinearGradient(
begin: Alignment.center,
end: Alignment.bottomCenter,
colors: [Color(0xFF2d4059), Color(0xFF1a1a2e)],
).createShader(Rect.fromLTWH(0, size.height / 2, size.width, size.height / 2));
canvas.drawRect(Rect.fromLTWH(0, size.height / 2, size.width, size.height / 2), paint);
}
void _drawWalls(Canvas canvas, Size size) {
final numRays = size.width.toInt();
for (int i = 0; i < numRays; i++) {
final rayAngle = player.angle - fov / 2 + (i / numRays) * fov;
final result = _castRay(rayAngle);
final distance = result.$1 * cos(rayAngle - player.angle);
if (distance <= 0) continue;
final wallHeight = size.height / distance;
final wallTop = (size.height - wallHeight) / 2;
final brightness = (1.0 - (distance / maxDepth)).clamp(0.1, 1.0);
final isVertical = result.$2;
final r = isVertical ? (40 * brightness).toInt() : (60 * brightness).toInt();
final g = isVertical ? (80 * brightness).toInt() : (100 * brightness).toInt();
final b = isVertical ? (120 * brightness).toInt() : (160 * brightness).toInt();
final paint = Paint()
..color = Color.fromARGB(255, r, g, b)
..strokeWidth = 1.5;
canvas.drawLine(
Offset(i.toDouble(), wallTop),
Offset(i.toDouble(), wallTop + wallHeight),
paint,
);
}
}
(double, bool) _castRay(double angle) {
final sinA = sin(angle);
final cosA = cos(angle);
double dist = 0;
bool vertical = false;
for (double d = 0.01; d < maxDepth; d += 0.02) {
final testX = player.x + cosA * d;
final testY = player.y + sinA * d;
if (maze.isWall(testX.toInt(), testY.toInt())) {
dist = d;
final fracX = testX - testX.floorToDouble();
final fracY = testY - testY.floorToDouble();
vertical = fracX < 0.05 || fracX > 0.95;
if (!vertical) vertical = !(fracY < 0.05 || fracY > 0.95);
break;
}
}
return (dist, vertical);
}
void _drawMinimap(Canvas canvas, Size size) {
const cellSize = 6.0;
const offsetX = 10.0;
const offsetY = 10.0;
final mapWidth = maze.width * cellSize;
final mapHeight = maze.height * cellSize;
// Background
canvas.drawRect(
Rect.fromLTWH(offsetX - 2, offsetY - 2, mapWidth + 4, mapHeight + 4),
Paint()..color = const Color(0x88000000),
);
// Walls
final wallPaint = Paint()..color = const Color(0xFF4a6fa5);
for (int y = 0; y < maze.height; y++) {
for (int x = 0; x < maze.width; x++) {
if (maze.grid[y][x] == 1) {
canvas.drawRect(
Rect.fromLTWH(offsetX + x * cellSize, offsetY + y * cellSize, cellSize, cellSize),
wallPaint,
);
}
}
}
// Goal
canvas.drawCircle(
Offset(offsetX + (maze.width - 2) * cellSize + cellSize / 2,
offsetY + (maze.height - 2) * cellSize + cellSize / 2),
cellSize / 2,
Paint()..color = const Color(0xFF00ff00),
);
// Player
canvas.drawCircle(
Offset(offsetX + player.x * cellSize, offsetY + player.y * cellSize),
cellSize / 2,
Paint()..color = const Color(0xFFff4444),
);
// Direction
canvas.drawLine(
Offset(offsetX + player.x * cellSize, offsetY + player.y * cellSize),
Offset(
offsetX + (player.x + cos(player.angle) * 2) * cellSize,
offsetY + (player.y + sin(player.angle) * 2) * cellSize,
),
Paint()
..color = const Color(0xFFff4444)
..strokeWidth = 2,
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}