Initial commit: labirynt 3D pseudo-raycasting game
This commit is contained in:
209
lib/main.dart
Normal file
209
lib/main.dart
Normal 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
45
lib/maze.dart
Normal 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
50
lib/player.dart
Normal 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
155
lib/raycaster.dart
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user